Guest User

Untitled

a guest
Oct 14th, 2026
3
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 410.99 KB | None | 0 0
  1. function widget:GetInfo()
  2. return {
  3. name = "Combat Advisor",
  4. desc = "Five panels answering one question at five scales, in a single widget: "
  5. .. "what did my scout just find, what does the enemy have, which factory "
  6. .. "should I be running against it, what should I build next, and should I "
  7. .. "take this fight. Enemy Intel "
  8. .. "remembers every enemy unit you have seen and subtracts the ones you have "
  9. .. "watched die, with a strip weighing your army against it and the "
  10. .. "remembered army marked on the minimap. Build Proposer ranks what to build next against that army, "
  11. .. "through a counter matrix over Zero-K's unit archetypes, and will queue the "
  12. .. "picks for you. Fac Switcher puts the same matrix to whole factories and "
  13. .. "names the three whose rosters best cover that army. Engagement Predictor "
  14. .. "takes a selection, gathers the fight around it and reports a win "
  15. .. "probability, and rings that fight and every other front on the map in "
  16. .. "its colour. Scout Cam opens a viewer down the left edge the first time a "
  17. .. "scout finds a major enemy structure, counts down enemy superweapons, can "
  18. .. "mark enemy anti-nukes on the map with their build progress, and rings "
  19. .. "your key buildings an enemy nuke could reach. Build Herald puts a map "
  20. .. "marker on every large unit or building your team completes, on the "
  21. .. "first enemy unit of each new type and class, and on your eco when it is "
  22. .. "raided. Each panel shows and "
  23. .. "hides on its own, "
  24. .. "from the one page at Settings/HUD Panels/Combat Advisor.",
  25. author = "Thomas",
  26. date = "2026",
  27. license = "GPL-v2",
  28. layer = 0,
  29. enabled = false, -- enable from the F11 widget list
  30. }
  31. end
  32.  
  33. --------------------------------------------------------------------------------
  34. -- One widget, one panel per module
  35. --
  36. -- Enemy Intel, Build Proposer and Engagement Predictor were three widgets that were only
  37. -- ever useful together: the second ranks builds against the army the first remembers, and
  38. -- the third scores a fight with the matrix the second publishes. Merged, they load and
  39. -- unload as one and cannot be left half-wired. Fac Switcher was written here rather than
  40. -- as a fourth file, for the same reason: it is a third reader of those same two tables.
  41. -- Scout Cam joins them on a weaker claim -- it reads neither table -- but it is answering
  42. -- the same question at the scouting end of it, and a player who wants the advisor wants to
  43. -- be told what the scout found.
  44. --
  45. -- Each module keeps its own `do ... end` block, and that is structural rather than
  46. -- decorative. Lua allows 200 locals per function and these bodies declare well over 300
  47. -- between them at file scope, so a flat concatenation does not compile. A block releases
  48. -- its locals at its `end`, so they never hold registers at the same time, while the
  49. -- closures each one registers below keep whatever they captured alive as upvalues --
  50. -- exactly the arrangement each had as its own chunk. The three merged bodies are otherwise
  51. -- untouched, indentation included, so each still diffs against the widget it came from.
  52. --
  53. -- They still speak to each other through WG.EnemyIntel and WG.CounterMatrix rather than
  54. -- reaching across the blocks. That is the published contract: the consumers are written to
  55. -- degrade gracefully when either is missing, other widgets may read them, and a merge is
  56. -- no reason to grow a second, private path saying the same thing. It is also why a new
  57. -- module goes in below the producers rather than beside them -- registration order is
  58. -- Initialize order forwards and Shutdown order backwards, so every consumer is gone before
  59. -- the thing it consumes.
  60. --------------------------------------------------------------------------------
  61.  
  62. local modules = {} -- in load order; Shutdown walks it backwards
  63.  
  64. -- One widget, one settings page. Every option lands here in module order, each run opening
  65. -- with a sub-header naming the panel it belongs to -- `type = 'label'` is epicmenu's own
  66. -- section divider, and the page a module used to own supplies the name.
  67. --
  68. -- Keys still carry a module prefix, because EPIC Menu files every option of one widget
  69. -- under `epic_<widget name>_<option key>` in one flat namespace, and three modules bring
  70. -- three `updateRate`s between them. Display names are disambiguated in the modules
  71. -- themselves, so that a name still reads correctly in epicmenu's search and in the hotkey
  72. -- list, where there is no section header standing above it.
  73. local OPTIONS_PATH = 'Settings/HUD Panels/Combat Advisor'
  74. local allOptions, allOptionsOrder = {}, {}
  75.  
  76. local function Add(key, opt)
  77. allOptions[key] = opt
  78. allOptionsOrder[#allOptionsOrder + 1] = key
  79. end
  80.  
  81. local function RegisterModule(def)
  82. modules[#modules + 1] = def.callins
  83.  
  84. -- `noSave`, because a section header has nothing to persist and its caption lives in
  85. -- the same `value` field epicmenu would otherwise try to load a saved setting into.
  86. local title = def.optionsPath:match("([^/]+)$")
  87. Add(def.prefix .. "section", {type = 'label', name = title, value = title, noSave = true})
  88.  
  89. for i = 1, #def.optionsOrder do
  90. local key = def.optionsOrder[i]
  91. Add(def.prefix .. key, def.options[key])
  92. end
  93. end
  94.  
  95. --------------------------------------------------------------------------------
  96. -- Enemy Intel
  97. --
  98. -- Was gui_enemy_intel.lua.
  99. --------------------------------------------------------------------------------
  100.  
  101. do
  102. local M = {} -- this module's callins, collected by RegisterModule below
  103. local options_path, options_order, options -- were file-scope globals
  104.  
  105. --------------------------------------------------------------------------------
  106. -- Includes / static per-unitDef data
  107. --------------------------------------------------------------------------------
  108.  
  109. -- Tactical icon resolution, same pattern as gui_contextmenu.lua / gui_icontransition.lua
  110. local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
  111. local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
  112.  
  113. local iconFormat = ".dds"
  114. do
  115. -- chilitip_conf.lua returns: ignoreweapon, iconFormat
  116. local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
  117. if ok and fmt then
  118. iconFormat = fmt
  119. end
  120. end
  121.  
  122. local spGetUnitDefID = Spring.GetUnitDefID
  123. local spGetUnitTeam = Spring.GetUnitTeam
  124. local spGetUnitIsDead = Spring.GetUnitIsDead
  125. local spGetAllUnits = Spring.GetAllUnits
  126. local spGetTeamInfo = Spring.GetTeamInfo
  127. local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
  128. local spGetTeamList = Spring.GetTeamList
  129. local spGetUnitPosition = Spring.GetUnitPosition
  130. local spGetGameSeconds = Spring.GetGameSeconds
  131. local spGetAllFeatures = Spring.GetAllFeatures
  132. local spGetFeatureDefID = Spring.GetFeatureDefID
  133. local spGetFeatureTeam = Spring.GetFeatureTeam
  134. local spGetFeatureAllyTeam = Spring.GetFeatureAllyTeam
  135. local spGetFeaturePosition = Spring.GetFeaturePosition
  136. local spGetTeamUnits = Spring.GetTeamUnits
  137. local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt
  138. local spGetMyTeamID = Spring.GetMyTeamID
  139.  
  140. local iconFile = {}
  141. local unitCost = {}
  142. local unitName = {}
  143. local isStaticDef = {}
  144. local isCommDef = {} -- every chassis and level is its own def; the table shows them as one
  145. -- Units that are not intel about anybody's army. Zero-K models terraforming as a unit:
  146. -- every patch of ground being levelled is a static, permanently unfinished `terraunit`
  147. -- called Terraform, flagged `dontcount` and priced at 100000 metal -- so counting it put a
  148. -- blank icon at the top of the Static column for every enemy ramp. The same flag covers
  149. -- fakeunit_los, the Planet Wars props, starlight_satellite, statictempshield, tele_beacon
  150. -- and wolverine_mine. Drones carry `is_drone` and are gated with it in Zero-K's own
  151. -- widgets; they are left in here, because a carrier's drones do shoot at you.
  152. local notIntel = {}
  153. -- Which bar of the army balance strip a unit type fills: "ground", "air" or "defence", or
  154. -- nil for everything that is not fighting strength -- constructors, economy, radar, the
  155. -- factories themselves. Armed means it carries a weapon at all, so an armed builder such
  156. -- as the Funnelweb, and every commander, counts as ground. The minimap ghosts draw the
  157. -- same ground and air set: the remembered army, not the remembered builders.
  158. local bucketOf = {}
  159.  
  160. local DEFAULT_ICON = "icons/default.dds"
  161.  
  162. for defID = 1, #UnitDefs do
  163. local ud = UnitDefs[defID]
  164. local it = ud.iconType or "default"
  165. local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
  166. or ("icons/" .. it .. iconFormat)
  167. -- Zero-K's own square build texture, which the engine renders from the model, rather
  168. -- than icons/default.dds -- that file is fully transparent, so a missing icon drew a
  169. -- blank box with a count beside it (submarine, none, the legacy tank/kbot set).
  170. if not VFS.FileExists(path) then
  171. path = "#" .. defID
  172. end
  173. iconFile[defID] = path
  174. unitCost[defID] = ud.metalCost or 0
  175. unitName[defID] = ud.humanName or ud.name or "?"
  176. isStaticDef[defID] = (ud.speed or 0) == 0
  177. local cp = ud.customParams
  178. isCommDef[defID] = cp and (cp.dynamic_comm or cp.commtype) and true or nil
  179. -- customParams arrive stringified, so a flag that is present but off is the string
  180. -- "false", which is truthy in Lua.
  181. local dc = cp and cp.dontcount
  182. notIntel[defID] = (dc and dc ~= "false" and dc ~= "0") and true or nil
  183. if not notIntel[defID] and #(ud.weapons or {}) > 0 and not ud.isFactory then
  184. if isStaticDef[defID] then
  185. bucketOf[defID] = "defence"
  186. else
  187. bucketOf[defID] = ud.canFly and "air" or "ground"
  188. end
  189. end
  190. end
  191.  
  192. -- Wreck/debris featureDef -> the unitDef it came from. Same resolution Zero-K's own
  193. -- cursortip uses (gui_chili_selections_and_cursortip.lua): an explicit customParams.unit
  194. -- when the def declares one, otherwise strip the trailing '_dead' / '_dead2' / '_heap'.
  195. -- Defs that resolve to no unit (map features, module_wreck_N for dynamic commanders) are
  196. -- simply absent from the table and get ignored downstream.
  197. local wreckUnitDef = {}
  198. for fdID, fd in pairs(FeatureDefs) do
  199. if type(fdID) == "number" then
  200. local uName = (fd.customParams and fd.customParams.unit)
  201. or (fd.name and fd.name:gsub("(.*)_.*", "%1"))
  202. local ud = uName and UnitDefNames[uName]
  203. if ud then
  204. wreckUnitDef[fdID] = ud.id
  205. end
  206. end
  207. end
  208.  
  209. --------------------------------------------------------------------------------
  210. -- State (declared before `options` so the option closures can capture them)
  211. --------------------------------------------------------------------------------
  212.  
  213. local seen = {} -- unitID -> unitDefID, every enemy unit observed minus confirmed deaths
  214. local lastSeen = {} -- unitID -> game time (s) this unit was last in sight or on radar
  215. local counts = {} -- unitDefID -> count
  216. -- The same tally split by owner, so a consumer can answer one opponent's army rather than
  217. -- the whole enemy side. Kept beside `counts` rather than replacing it: every reader that
  218. -- does not care who owns what would otherwise have to sum this back up on every pass.
  219. local teamOf = {} -- unitID -> the enemy team that owns it
  220. local countsByTeam = {} -- teamID -> {unitDefID -> count}
  221. local dirty = true
  222. local needFullRebuild = false
  223. local lastSweep = 0 -- game time (s) of the last full sweep, published for WG consumers
  224.  
  225. -- Wreck-derived intel. Deductions are kept apart from `counts` on purpose: the 5s sweep
  226. -- re-adds any live unit missing from `seen`, so mutating `seen` to deduct would just be
  227. -- undone on the next pass.
  228. local visibleByDef = {} -- unitDefID -> enemies of that type in LOS as of the last sweep
  229. local wreckDeduct = {} -- unitDefID -> deaths inferred from wrecks
  230. local knownFeature = {} -- featureID -> featureDefID, unit wrecks already accounted for
  231. local ignoredFeature = {} -- featureID -> featureDefID, map/allied/unresolvable, skip cheaply
  232. local deathSites = {} -- unitDefID -> array of {x, z, clock, absorbed = {featureDefID}}
  233. local gaiaTeamID = -1
  234.  
  235. -- Wrecks slide around under feature_debris_physics, so a corpse is matched to its death
  236. -- within this radius rather than exactly.
  237. local MATCH_RADIUS = 48
  238. local MATCH_RADIUS_SQ = MATCH_RADIUS * MATCH_RADIUS
  239. -- Only sites that never received a corpse expire, and even then slowly. Corpses are found
  240. -- by polling what is in LOS, so the wreck of a kill you witnessed and then walked away
  241. -- from is not seen until you return -- possibly many minutes later. Expiring the site
  242. -- before then would let that corpse deduct a death already counted. A site that did
  243. -- absorb a corpse is kept for the game, since the corpse can decay into debris much later
  244. -- and the site is the only thing stopping the debris being counted as a second death.
  245. local BARREN_SITE_MAX_AGE = 900
  246. local MAX_SITES_PER_DEF = 256
  247.  
  248. local myAllyTeamID = spGetMyAllyTeamID()
  249. local teamAlly = {}
  250.  
  251. -- The army balance strip. `mine` is summed from our own units in the 5 s sweep; the enemy
  252. -- side is read from GetEffectiveCounts at layout time, so it can never disagree with the
  253. -- rows beneath it. `rows` holds the Chili controls, one per bucket.
  254. local BUCKETS = {"ground", "air", "defence"}
  255. local balance = {mine = {}, rows = {}}
  256.  
  257. -- Last known positions of remembered enemy mobiles, for the minimap. `pos[unitID]` is
  258. -- {x, z, t}, refreshed once a second while the engine still gives a position (sight or
  259. -- radar) and left standing when it stops. `list` is what DrawInMiniMap draws: the ones out
  260. -- of sight and radar, bucketed, rebuilt on the same second.
  261. local ghosts = {pos = {}, list = {}, next = 0}
  262.  
  263. local ResetIntel -- forward declaration, defined below
  264. local SetPanelVisible -- forward declaration, defined below
  265.  
  266. --------------------------------------------------------------------------------
  267. -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
  268. --------------------------------------------------------------------------------
  269.  
  270. options_path = 'Settings/HUD Panels/Enemy Intel'
  271. options_order = {'showPanel', 'updateRate', 'memoryTime', 'iconSize', 'minCount', 'sortMode', 'showStatic', 'staticFirst', 'hideCons', 'useWrecks', 'showBalance', 'balanceSide', 'minimapGhosts', 'reset'}
  272. options = {
  273. showPanel = {
  274. name = 'Show Enemy Intel panel',
  275. desc = 'Hide the on-screen table without unloading the widget. Intel is still tracked '
  276. .. 'while hidden, so the counts are right the moment you bring it back, and the '
  277. .. 'Build Proposer keeps reading them through WG.EnemyIntel either way.',
  278. type = 'bool',
  279. value = true,
  280. OnChange = function(self)
  281. SetPanelVisible(self.value)
  282. end,
  283. },
  284. updateRate = {
  285. name = 'Intel update interval (s)',
  286. desc = 'How often the table redraws. Higher is cheaper.',
  287. type = 'number',
  288. min = 0.1, max = 3, step = 0.1, value = 0.1,
  289. },
  290. memoryTime = {
  291. name = 'Forget unseen units after (min)',
  292. desc = 'A remembered unit is dropped once this long has passed without seeing it again. '
  293. .. 'Units currently in sight never expire.',
  294. type = 'number',
  295. min = 1, max = 60, step = 1, value = 10,
  296. OnChange = function()
  297. dirty = true
  298. end,
  299. },
  300. iconSize = {
  301. name = 'Icon size',
  302. type = 'number',
  303. min = 16, max = 64, step = 2, value = 28,
  304. OnChange = function()
  305. needFullRebuild = true
  306. end,
  307. },
  308. minCount = {
  309. name = 'Hide unit types below N',
  310. desc = 'Suppress rows with fewer than this many known units.',
  311. type = 'number',
  312. min = 1, max = 20, step = 1, value = 1,
  313. OnChange = function()
  314. dirty = true
  315. end,
  316. },
  317. sortMode = {
  318. name = 'Sort by',
  319. type = 'radioButton',
  320. value = 'value',
  321. items = {
  322. {key = 'value', name = 'Metal value', desc = 'Biggest threat first.'},
  323. {key = 'count', name = 'Count', desc = 'Most numerous first.'},
  324. },
  325. OnChange = function()
  326. dirty = true
  327. end,
  328. },
  329. showStatic = {
  330. name = 'Show Static section',
  331. desc = 'Show the buildings/turrets section below the mobile army.',
  332. type = 'bool',
  333. value = true,
  334. OnChange = function()
  335. dirty = true
  336. end,
  337. },
  338. hideCons = {
  339. name = 'Hide enemy constructors',
  340. desc = 'Leave mobile constructors (and Athenas) out of the table; Caretakers and '
  341. .. 'factories stay. They are still counted, so Build Proposer '
  342. .. 'and the other panels see them exactly as before; this only takes their icons '
  343. .. 'off the panel.',
  344. type = 'bool',
  345. value = true,
  346. OnChange = function()
  347. dirty = true
  348. end,
  349. },
  350. staticFirst = {
  351. name = 'Static column first',
  352. desc = 'Swap the two columns, so buildings/turrets sit on the left and the mobile '
  353. .. 'army on the right.',
  354. type = 'bool',
  355. value = true,
  356. OnChange = function()
  357. dirty = true
  358. end,
  359. },
  360. useWrecks = {
  361. name = 'Deduct from wrecks',
  362. desc = 'Scouting an enemy wreck proves the unit died, even if you never saw the kill. '
  363. .. 'Turn off to count only kills you witnessed.',
  364. type = 'bool',
  365. value = true,
  366. OnChange = function()
  367. dirty = true
  368. end,
  369. },
  370. showBalance = {
  371. name = 'Show army balance strip',
  372. desc = 'Three bars above the table comparing fighting strength in metal: ground, air '
  373. .. 'and static defence. Green is your side, red is the enemy army you remember. '
  374. .. 'Constructors, economy and factories are left out of both sides.',
  375. type = 'bool',
  376. value = true,
  377. OnChange = function()
  378. dirty = true
  379. end,
  380. },
  381. balanceSide = {
  382. name = 'My side of the balance is',
  383. desc = 'Whose army the balance strip puts against the enemy. The enemy side is '
  384. .. 'everything your team has seen, so your whole team is the like-for-like '
  385. .. 'comparison.',
  386. type = 'radioButton',
  387. value = 'team',
  388. items = {
  389. {key = 'team', name = 'My whole team', desc = 'You and your allies.'},
  390. {key = 'me', name = 'Only me', desc = 'Just your own units.'},
  391. },
  392. OnChange = function()
  393. balance.due = true
  394. end,
  395. },
  396. minimapGhosts = {
  397. name = 'Show remembered enemy army on the minimap',
  398. desc = 'Mark on the minimap where remembered enemy army units were last seen, once '
  399. .. 'they are out of sight and off radar. Nearby units share one mark, sized by '
  400. .. 'their metal value, and each mark fades out over the "forget unseen units" time.',
  401. type = 'bool',
  402. value = true,
  403. },
  404. reset = {
  405. name = 'Reset intel memory',
  406. desc = 'Clear all remembered enemy units. Use when counts have drifted stale.',
  407. type = 'button',
  408. OnChange = function()
  409. if ResetIntel then ResetIntel() end
  410. end,
  411. },
  412. }
  413.  
  414. --------------------------------------------------------------------------------
  415. -- Intel readout
  416. --------------------------------------------------------------------------------
  417.  
  418. -- unitDefID -> how many of that type we still believe the enemy has.
  419. -- Wrecks only subtract, and never past what is standing in front of you: a unit in LOS
  420. -- is alive, and no amount of corpse evidence can argue otherwise.
  421. -- Nothing is filtered for display here. `minCount` is presentation, and widgets reading
  422. -- this through WG.EnemyIntel want the whole belief, including the single sightings.
  423. local function GetEffectiveCounts()
  424. local applyWrecks = options.useWrecks.value
  425. local out = {}
  426. for defID, raw in pairs(counts) do
  427. local n = raw
  428. if applyWrecks then
  429. n = raw - (wreckDeduct[defID] or 0)
  430. local onScreen = visibleByDef[defID]
  431. if onScreen and onScreen > n then
  432. n = onScreen
  433. end
  434. end
  435. if n > 0 then
  436. out[defID] = n
  437. end
  438. end
  439. return out
  440. end
  441.  
  442. -- The same belief, restricted to one enemy team. Wreck evidence cannot be attributed to
  443. -- an owner -- a corpse proves a unit of that type died, not whose it was -- so each type's
  444. -- deduction is shared out across the teams holding it in proportion to how many each
  445. -- holds. Counts therefore come back fractional. They are threat weights, not a roster.
  446. -- Returns nil for a team nothing has ever been seen from, which is a different answer
  447. -- from an empty table: one means "no intel", the other means "nothing left standing".
  448. local function GetEffectiveCountsForTeam(teamID)
  449. local perTeam = countsByTeam[teamID]
  450. if not perTeam then
  451. return nil
  452. end
  453. local effective = GetEffectiveCounts()
  454. local out = {}
  455. for defID, raw in pairs(perTeam) do
  456. local total = counts[defID] or 0
  457. local eff = effective[defID]
  458. if eff and total > 0 then
  459. local n = raw * (eff / total)
  460. if n > 0 then
  461. out[defID] = n
  462. end
  463. end
  464. end
  465. return out
  466. end
  467.  
  468. -- Enemy teams we currently hold any intel on, which is not the same as the enemy teams in
  469. -- the game: a player we have never scouted is simply absent.
  470. local function GetEnemyTeams()
  471. local out = {}
  472. for teamID in pairs(countsByTeam) do
  473. out[#out + 1] = teamID
  474. end
  475. table.sort(out)
  476. return out
  477. end
  478.  
  479. --------------------------------------------------------------------------------
  480. -- Tracking
  481. --------------------------------------------------------------------------------
  482.  
  483. local function RebuildTeamMap()
  484. teamAlly = {}
  485. local teams = spGetTeamList()
  486. if teams then
  487. for i = 1, #teams do
  488. local t = teams[i]
  489. teamAlly[t] = select(6, spGetTeamInfo(t, false))
  490. end
  491. end
  492. myAllyTeamID = spGetMyAllyTeamID()
  493. end
  494.  
  495. local function IsEnemyTeam(unitTeam)
  496. if not unitTeam then
  497. return false
  498. end
  499. -- Gaia owns the critters and the map's own units. They are not an opponent, and
  500. -- counting them puts a fake player in front of every consumer that reads
  501. -- GetEnemyTeams. The wreck scan already excludes Gaia for the same reason.
  502. if unitTeam == gaiaTeamID then
  503. return false
  504. end
  505. local at = teamAlly[unitTeam]
  506. if at == nil then
  507. at = select(6, spGetTeamInfo(unitTeam, false))
  508. teamAlly[unitTeam] = at
  509. end
  510. return at ~= myAllyTeamID
  511. end
  512.  
  513. local function AddUnit(unitID, unitDefID, unitTeam)
  514. if not unitID then
  515. return
  516. end
  517. if seen[unitID] then
  518. lastSeen[unitID] = spGetGameSeconds() -- already tracked; this sighting restarts its timer
  519. return
  520. end
  521. if not IsEnemyTeam(unitTeam) then
  522. return
  523. end
  524. unitDefID = unitDefID or spGetUnitDefID(unitID)
  525. if not unitDefID or not UnitDefs[unitDefID] then
  526. return -- radar dot with no known type yet
  527. end
  528. if notIntel[unitDefID] then
  529. return
  530. end
  531. seen[unitID] = unitDefID
  532. lastSeen[unitID] = spGetGameSeconds()
  533. counts[unitDefID] = (counts[unitDefID] or 0) + 1
  534. teamOf[unitID] = unitTeam
  535. local perTeam = countsByTeam[unitTeam]
  536. if not perTeam then
  537. perTeam = {}
  538. countsByTeam[unitTeam] = perTeam
  539. end
  540. perTeam[unitDefID] = (perTeam[unitDefID] or 0) + 1
  541. dirty = true
  542. end
  543.  
  544. local function RemoveUnit(unitID)
  545. local defID = seen[unitID]
  546. if not defID then
  547. return
  548. end
  549. seen[unitID] = nil
  550. lastSeen[unitID] = nil
  551. ghosts.pos[unitID] = nil
  552. local n = (counts[defID] or 1) - 1
  553. counts[defID] = (n > 0) and n or nil
  554. -- Clamp the wreck evidence to what is left, the same way the 5 s sweep does.
  555. -- Without it a deduction taken against a count that has since fallen can exceed
  556. -- it, and GetEffectiveCounts drops the whole type until the next sweep -- a live
  557. -- unit gone from the panel, and from every WG.EnemyIntel consumer, for up to 5 s.
  558. local owed = wreckDeduct[defID]
  559. if owed and owed > n then
  560. wreckDeduct[defID] = (n > 0) and n or nil
  561. end
  562. local team = teamOf[unitID]
  563. teamOf[unitID] = nil
  564. local perTeam = team and countsByTeam[team]
  565. if perTeam then
  566. local m = (perTeam[defID] or 1) - 1
  567. perTeam[defID] = (m > 0) and m or nil
  568. if not next(perTeam) then
  569. countsByTeam[team] = nil
  570. end
  571. end
  572. dirty = true
  573. end
  574.  
  575. --------------------------------------------------------------------------------
  576. -- Wreck tracking
  577. --
  578. -- Every death is counted once, whether it was witnessed or only inferred from the
  579. -- corpse. A death site is the bridge: a kill seen in LOS plants one (already counted),
  580. -- and a corpse that lands on an existing site is that same death rather than a new one.
  581. --
  582. -- Each site holds at most one feature per featureDefID. That is what keeps a pile of
  583. -- identical kills honest -- a second '<unit>_dead' cannot settle on a site that already
  584. -- holds one, so it walks on to the next site or opens its own -- while still letting a
  585. -- wreck and the debris it later decays into share a single site for free, since the two
  586. -- stages carry different featureDefIDs.
  587. --------------------------------------------------------------------------------
  588.  
  589. -- Returns the site that claims this corpse, or nil when the death is news to us.
  590. local function ClaimSite(unitDefID, featureDefID, x, z)
  591. local sites = deathSites[unitDefID]
  592. if not sites then
  593. return nil
  594. end
  595. for i = 1, #sites do
  596. local site = sites[i]
  597. if not site.absorbed[featureDefID] then
  598. local dx, dz = site.x - x, site.z - z
  599. if dx * dx + dz * dz <= MATCH_RADIUS_SQ then
  600. site.absorbed[featureDefID] = true
  601. return site
  602. end
  603. end
  604. end
  605. return nil
  606. end
  607.  
  608. local function AddSite(unitDefID, x, z, featureDefID)
  609. local sites = deathSites[unitDefID]
  610. if not sites then
  611. sites = {}
  612. deathSites[unitDefID] = sites
  613. end
  614. local site = {x = x, z = z, clock = os.clock(), absorbed = {}}
  615. if featureDefID then
  616. site.absorbed[featureDefID] = true
  617. end
  618. sites[#sites + 1] = site
  619. end
  620.  
  621. -- A kill we watched happen: already deducted by RemoveUnit, so the site exists purely to
  622. -- stop the corpse that is about to appear from deducting the same death a second time.
  623. local function NoteWitnessedDeath(unitID, unitDefID)
  624. local x, _, z = spGetUnitPosition(unitID)
  625. if x and unitDefID then
  626. AddSite(unitDefID, x, z, nil)
  627. end
  628. end
  629.  
  630. local function PruneSites()
  631. local cutoff = os.clock() - BARREN_SITE_MAX_AGE
  632. for defID, sites in pairs(deathSites) do
  633. local keep = {}
  634. for i = 1, #sites do
  635. local site = sites[i]
  636. if next(site.absorbed) or site.clock >= cutoff then
  637. keep[#keep + 1] = site
  638. end
  639. end
  640. -- Sites are appended in time order, so the overflow to drop is at the front.
  641. local excess = #keep - MAX_SITES_PER_DEF
  642. if excess > 0 then
  643. local trimmed = {}
  644. for i = excess + 1, #keep do
  645. trimmed[#trimmed + 1] = keep[i]
  646. end
  647. keep = trimmed
  648. end
  649. deathSites[defID] = (#keep > 0) and keep or nil
  650. end
  651. end
  652.  
  653. -- Features are not ghosted by the engine: GetAllFeatures returns only what is known right
  654. -- now, and GetFeatureDefID goes nil the moment a feature leaves LOS (the same behaviour
  655. -- unit_ghostsite.lua leans on). So we poll, and never drop a record just because a wreck
  656. -- went dark -- the unit is still dead.
  657. -- `silent` records what is already lying around without deducting for it. A wreck that
  658. -- predates our counting is not evidence against a tally that had not started yet; it is
  659. -- still registered so that the debris it later decays into does not deduct either.
  660. local function ScanFeatures(silent)
  661. local features = spGetAllFeatures()
  662. if not features then
  663. return
  664. end
  665.  
  666. for i = 1, #features do
  667. local fID = features[i]
  668. local fdID = spGetFeatureDefID(fID)
  669. -- Feature IDs are recycled, so a record only counts while the def still matches.
  670. if fdID and knownFeature[fID] ~= fdID and ignoredFeature[fID] ~= fdID then
  671. local unitDefID = wreckUnitDef[fdID]
  672. local fTeam = unitDefID and spGetFeatureTeam(fID)
  673. local fAlly = unitDefID and spGetFeatureAllyTeam(fID)
  674.  
  675. -- Map features sit on Gaia; anything else carries the team of the unit that died.
  676. if unitDefID and fTeam and fTeam ~= gaiaTeamID
  677. and fAlly and fAlly >= 0 and fAlly ~= myAllyTeamID then
  678. local x, _, z = spGetFeaturePosition(fID)
  679. if x then
  680. knownFeature[fID] = fdID
  681. if not ClaimSite(unitDefID, fdID, x, z) then
  682. -- No death we know of put this here: a kill we never saw.
  683. AddSite(unitDefID, x, z, fdID)
  684. -- Capped at what we actually believe is out there. A battlefield can
  685. -- hold far more enemy corpses than we ever saw alive -- most of them
  686. -- died to allies, out of our sight, before we scouted the type at all.
  687. -- Letting the debt run past the tally would bury later sightings under
  688. -- deductions for deaths that were never in our count to begin with.
  689. local owed = wreckDeduct[unitDefID] or 0
  690. if not silent and owed < (counts[unitDefID] or 0) then
  691. wreckDeduct[unitDefID] = owed + 1
  692. dirty = true
  693. end
  694. end
  695. end
  696. else
  697. ignoredFeature[fID] = fdID
  698. end
  699. end
  700. end
  701. end
  702.  
  703. --------------------------------------------------------------------------------
  704. -- Army balance and the minimap ghosts
  705. --------------------------------------------------------------------------------
  706.  
  707. -- Our side of the strip: finished units only, since a nanoframe is not yet strength. The
  708. -- whole ally team by default, because the enemy side is everything the team has seen.
  709. local function UpdateBalanceMine()
  710. local teams
  711. if options.balanceSide.value == 'me' then
  712. teams = {spGetMyTeamID()}
  713. else
  714. teams = {}
  715. for t, at in pairs(teamAlly) do
  716. if at == myAllyTeamID and t ~= gaiaTeamID then
  717. teams[#teams + 1] = t
  718. end
  719. end
  720. end
  721. local sums = {ground = 0, air = 0, defence = 0}
  722. for i = 1, #teams do
  723. local units = spGetTeamUnits(teams[i])
  724. for j = 1, (units and #units or 0) do
  725. local uID = units[j]
  726. local defID = spGetUnitDefID(uID)
  727. local b = defID and bucketOf[defID]
  728. if b and not spGetUnitIsBeingBuilt(uID) then
  729. sums[b] = sums[b] + unitCost[defID]
  730. end
  731. end
  732. end
  733. local old = balance.mine
  734. for i = 1, #BUCKETS do
  735. local b = BUCKETS[i]
  736. if old[b] ~= sums[b] then
  737. dirty = true
  738. end
  739. end
  740. balance.mine = sums
  741. balance.due = false
  742. end
  743.  
  744. local function BalanceTheirs()
  745. local sums = {ground = 0, air = 0, defence = 0}
  746. for defID, n in pairs(GetEffectiveCounts()) do
  747. local b = bucketOf[defID]
  748. if b then
  749. sums[b] = sums[b] + n * unitCost[defID]
  750. end
  751. end
  752. return sums
  753. end
  754.  
  755. local function FmtMetal(v)
  756. if v >= 10000 then
  757. return string.format("%dk", math.floor(v / 1000 + 0.5))
  758. elseif v >= 1000 then
  759. return string.format("%.1fk", v / 1000)
  760. end
  761. return tostring(math.floor(v + 0.5))
  762. end
  763.  
  764. -- Coarse on purpose: a mark on the minimap says "an army was here", and one per unit would
  765. -- be a smear of dots that reads worse than the cluster it came from.
  766. local GHOST_CELL = 512
  767.  
  768. -- Once a second. A position the engine still hands us is sight or radar, both of which the
  769. -- minimap already shows, so that unit only has its record refreshed. One it no longer
  770. -- hands us is out of both, and joins the mark for its cell. A cell's fade follows its
  771. -- freshest sighting, so a trickle of new sightings keeps a staging area lit.
  772. local function RefreshGhosts(gameNow)
  773. local pos = ghosts.pos
  774. local cells, list = {}, {}
  775. for uID, defID in pairs(seen) do
  776. local b = bucketOf[defID]
  777. if b == "ground" or b == "air" then
  778. local x, _, z = spGetUnitPosition(uID)
  779. local p = pos[uID]
  780. if x then
  781. if p then
  782. p[1], p[2], p[3] = x, z, gameNow
  783. else
  784. pos[uID] = {x, z, gameNow}
  785. end
  786. elseif p then
  787. local key = math.floor(p[1] / GHOST_CELL) .. ":" .. math.floor(p[2] / GHOST_CELL)
  788. local c = cells[key]
  789. if not c then
  790. c = {x = 0, z = 0, w = 0, value = 0, t = p[3]}
  791. cells[key] = c
  792. list[#list + 1] = c
  793. end
  794. local v = unitCost[defID]
  795. local w = math.max(v, 1)
  796. c.x, c.z, c.w = c.x + p[1] * w, c.z + p[2] * w, c.w + w
  797. c.value = c.value + v
  798. if p[3] > c.t then
  799. c.t = p[3]
  800. end
  801. end
  802. end
  803. end
  804. local fadeOver = math.max(1, options.memoryTime.value * 60)
  805. for i = 1, #list do
  806. local c = list[i]
  807. c.x, c.z = c.x / c.w, c.z / c.w
  808. c.alpha = math.max(0.15, 1 - (gameNow - c.t) / fadeOver)
  809. end
  810. ghosts.list = list
  811. end
  812.  
  813. -- A unit circle, built once; DrawInMiniMap scales it per mark.
  814. local CIRCLE = {}
  815. for i = 0, 15 do
  816. local a = i / 16 * 2 * math.pi
  817. CIRCLE[#CIRCLE + 1] = {math.cos(a), math.sin(a)}
  818. end
  819.  
  820. local function CircleVerts(x, y, r, centre)
  821. if centre then
  822. gl.Vertex(x, y)
  823. end
  824. for i = 1, #CIRCLE do
  825. gl.Vertex(x + CIRCLE[i][1] * r, y + CIRCLE[i][2] * r)
  826. end
  827. if centre then
  828. gl.Vertex(x + CIRCLE[1][1] * r, y + CIRCLE[1][2] * r)
  829. end
  830. end
  831.  
  832. --------------------------------------------------------------------------------
  833. -- Chili UI
  834. --------------------------------------------------------------------------------
  835.  
  836. local Chili
  837. local window_main, scrollPanel, holder
  838. local headerMobile, headerStatic
  839. local rowByDefID = {}
  840.  
  841. -- Inner padding, and the gap between the Mobile and Static columns.
  842. local PAD_L, PAD_T, PAD_R, PAD_B = 8, 6, 8, 8
  843. local COL_GAP = 14
  844. local HEADER_H = 20
  845. local COUNT_GAP = 10 -- icon -> count label
  846. local COUNT_TRAIL = 4 -- breathing room after the widest count label
  847. local MIN_COL_W = 40
  848. local WIN_VPAD = 12 -- window padding, top + bottom
  849. local WIN_HPAD = 12 -- window padding, left + right
  850. local MIN_WIN_H = 56
  851. local MIN_WIN_W = 80
  852. local MAX_WIN_FRAC = 0.6 -- never grow past this fraction of screen height
  853. local BAL_ROW_H = 16 -- army balance strip: one row per bucket
  854. local BAL_NAME_W = 54 -- "Defence" and a little air
  855. local BAL_BAR_W = 64
  856. local BAL_BAR_H = 8
  857.  
  858. -- Chili exposes each control's font; fall back to a rough estimate if it does not.
  859. local function TextWidth(ctrl, text)
  860. local font = ctrl and ctrl.font
  861. if font and font.GetTextWidth then
  862. local ok, w = pcall(font.GetTextWidth, font, text)
  863. if ok and w then
  864. return math.ceil(w)
  865. end
  866. end
  867. return math.ceil(#text * 7)
  868. end
  869.  
  870. local function DestroyRows()
  871. for _, row in pairs(rowByDefID) do
  872. if row.ctrl then
  873. row.ctrl:Dispose()
  874. end
  875. end
  876. rowByDefID = {}
  877. end
  878.  
  879. ResetIntel = function()
  880. seen = {}
  881. lastSeen = {}
  882. counts = {}
  883. teamOf = {}
  884. countsByTeam = {}
  885. wreckDeduct = {}
  886. visibleByDef = {}
  887. knownFeature = {}
  888. ignoredFeature = {}
  889. deathSites = {}
  890. ghosts.pos, ghosts.list = {}, {}
  891. -- Reset means "count from now", so visible wrecks are re-registered as history rather
  892. -- than immediately charged against the fresh tally.
  893. ScanFeatures(true)
  894. dirty = true
  895. end
  896.  
  897. local function EnsureRow(defID)
  898. local row = rowByDefID[defID]
  899. if row then
  900. return row
  901. end
  902.  
  903. local size = options.iconSize.value
  904. -- Width is assigned per-column by RefreshLayout, so no `right` anchor here.
  905. local ctrl = Chili.Control:New{
  906. parent = holder,
  907. x = 0, y = 0,
  908. width = 160,
  909. height = size + 4,
  910. padding = {0, 0, 0, 0},
  911. tooltip = unitName[defID],
  912. }
  913. Chili.Image:New{
  914. parent = ctrl,
  915. x = 2, y = 2,
  916. width = size, height = size,
  917. keepAspect = true,
  918. file = iconFile[defID],
  919. }
  920. local lblCount = Chili.Label:New{
  921. parent = ctrl,
  922. x = size + COUNT_GAP, y = math.floor(size / 2) - 6,
  923. font = {
  924. size = 14,
  925. color = {1, 1, 1, 1},
  926. outline = true,
  927. outlineWidth = 3,
  928. outlineWeight = 6,
  929. outlineColor = {0, 0, 0, 1},
  930. },
  931. caption = "",
  932. }
  933. row = {ctrl = ctrl, count = lblCount}
  934. rowByDefID[defID] = row
  935. return row
  936. end
  937.  
  938. local function RefreshLayout()
  939. if not (window_main and holder) then
  940. return
  941. end
  942.  
  943. local minCount = options.minCount.value
  944. local mobile, static = {}, {}
  945. -- Commanders are folded into one row here, at display time only: WG.EnemyIntel keeps
  946. -- them apart, since a level 5 Strike and a level 1 Recon are different fights. The row
  947. -- wears the icon of the most numerous kind, and the tooltip says what is in it.
  948. local comm = {count = 0, value = 0, names = {}, best = 0}
  949. -- Constructors by Build Proposer's own classification when it is running, so the two
  950. -- panels agree on what one is; otherwise build power, unarmed, not a commander -- the
  951. -- same test Engagement Predictor uses, which keeps an armed builder like the Funnelweb
  952. -- on the panel. Mobile only either way: that classification also calls every factory,
  953. -- plate, silo and Caretaker a "con", and hiding the enemy's factories is not the point.
  954. local hideCons = options.hideCons.value
  955. local cmRole = hideCons and WG.CounterMatrix and WG.CounterMatrix.role
  956. for defID, n in pairs(GetEffectiveCounts()) do
  957. local ud = UnitDefs[defID]
  958. if hideCons and ud and (ud.speed or 0) > 0 and ((cmRole and cmRole[defID] == "con")
  959. or (not cmRole and (ud.buildSpeed or 0) > 0 and not isCommDef[defID]
  960. and #(ud.weapons or {}) == 0)) then
  961. -- left off the panel
  962. elseif isCommDef[defID] then
  963. if n > 0 then
  964. comm.count = comm.count + n
  965. comm.value = comm.value + n * unitCost[defID]
  966. local nm = unitName[defID]
  967. comm.names[nm] = (comm.names[nm] or 0) + n
  968. if n > comm.best or (n == comm.best and defID < comm.defID) then
  969. comm.best, comm.defID = n, defID
  970. end
  971. end
  972. elseif n >= minCount then
  973. local entry = {defID = defID, count = n, value = n * unitCost[defID]}
  974. if isStaticDef[defID] then
  975. static[#static + 1] = entry
  976. else
  977. mobile[#mobile + 1] = entry
  978. end
  979. end
  980. end
  981. if comm.defID and comm.count >= minCount then
  982. local lines = {}
  983. for nm, n in pairs(comm.names) do
  984. lines[#lines + 1] = n .. " x " .. nm
  985. end
  986. table.sort(lines)
  987. mobile[#mobile + 1] = {defID = comm.defID, count = comm.count, value = comm.value,
  988. tip = "Commanders\n" .. table.concat(lines, "\n")}
  989. end
  990.  
  991. local byCount = (options.sortMode.value == "count")
  992. local function cmp(a, b)
  993. if byCount then
  994. if a.count ~= b.count then return a.count > b.count end
  995. return a.value > b.value
  996. end
  997. if a.value ~= b.value then return a.value > b.value end
  998. return a.count > b.count
  999. end
  1000. table.sort(mobile, cmp)
  1001. table.sort(static, cmp)
  1002.  
  1003. local used = {}
  1004. local iconSize = options.iconSize.value
  1005. local rowH = iconSize + 4
  1006.  
  1007. local sections = {{header = headerMobile, label = "Mobile", list = mobile}}
  1008. if options.showStatic.value then
  1009. local st = {header = headerStatic, label = "Static", list = static}
  1010. if options.staticFirst.value then
  1011. table.insert(sections, 1, st)
  1012. else
  1013. sections[#sections + 1] = st
  1014. end
  1015. end
  1016.  
  1017. -- Only sections with rows get a column, so an empty one costs no width at all.
  1018. local shown = {}
  1019. for i = 1, #sections do
  1020. if #sections[i].list > 0 then
  1021. shown[#shown + 1] = sections[i]
  1022. end
  1023. end
  1024. if #shown == 0 then
  1025. shown[1] = sections[1] -- nothing scouted yet: keep one header rather than a sliver
  1026. end
  1027.  
  1028. headerMobile:Hide()
  1029. headerStatic:Hide()
  1030.  
  1031. -- The army balance strip sits above both columns, one row per bucket that either side
  1032. -- has anything in: no air on the field means no air row, not an empty bar.
  1033. local top = PAD_T
  1034. local stripW = 0
  1035. local theirs = options.showBalance.value and BalanceTheirs()
  1036. for i = 1, #BUCKETS do
  1037. local b = BUCKETS[i]
  1038. local r = balance.rows[b]
  1039. local mine = balance.mine[b] or 0
  1040. local their = theirs and theirs[b] or 0
  1041. if r and theirs and (mine > 0 or their > 0) then
  1042. local cap = FmtMetal(mine) .. " vs " .. FmtMetal(their)
  1043. r.name:SetPos(PAD_L, top)
  1044. r.name:Show()
  1045. r.bar:SetPos(PAD_L + BAL_NAME_W, top + math.floor((BAL_ROW_H - BAL_BAR_H) / 2),
  1046. BAL_BAR_W, BAL_BAR_H)
  1047. r.bar:SetValue(math.floor(100 * mine / (mine + their) + 0.5))
  1048. r.bar:Show()
  1049. r.nums:SetCaption(cap)
  1050. r.nums:SetPos(PAD_L + BAL_NAME_W + BAL_BAR_W + 6, top)
  1051. r.nums:Show()
  1052. r.shown = cap
  1053. stripW = math.max(stripW, BAL_NAME_W + BAL_BAR_W + 6 + TextWidth(r.nums, cap))
  1054. top = top + BAL_ROW_H
  1055. elseif r then
  1056. r.name:Hide()
  1057. r.bar:Hide()
  1058. r.nums:Hide()
  1059. r.shown = nil
  1060. end
  1061. end
  1062. if top > PAD_T then
  1063. top = top + 4
  1064. end
  1065.  
  1066. -- Measure before placing: a column is as wide as its widest row (icon + count
  1067. -- text) or its own header, whichever is greater.
  1068. local function MeasureSection(s)
  1069. local w = TextWidth(s.header, s.label)
  1070. for i = 1, #s.list do
  1071. local e = s.list[i]
  1072. e.row = EnsureRow(e.defID)
  1073. e.caption = tostring(e.count)
  1074. local rowW = iconSize + COUNT_GAP + TextWidth(e.row.count, e.caption) + COUNT_TRAIL
  1075. if rowW > w then
  1076. w = rowW
  1077. end
  1078. end
  1079. return math.max(w, MIN_COL_W)
  1080. end
  1081.  
  1082. local function PlaceSection(s, colX, colW)
  1083. local y = top
  1084. s.header:SetCaption(s.label)
  1085. s.header:SetPos(colX, y)
  1086. s.header:Show()
  1087. y = y + HEADER_H
  1088.  
  1089. for i = 1, #s.list do
  1090. local e = s.list[i]
  1091. used[e.defID] = true
  1092. e.row.ctrl:SetPos(colX, y, colW)
  1093. e.row.ctrl:Show()
  1094. e.row.count:SetCaption(e.caption)
  1095. -- Rows are cached per def, so a row that once stood for all the commanders
  1096. -- has to be given its own name back when it is just one unit type again.
  1097. e.row.ctrl.tooltip = e.tip or unitName[e.defID]
  1098. y = y + rowH
  1099. end
  1100. return y
  1101. end
  1102.  
  1103. -- Each column runs its own y, so the sections sit side by side.
  1104. local colX = PAD_L
  1105. local contentBottom = top + HEADER_H
  1106. for i = 1, #shown do
  1107. local s = shown[i]
  1108. local colW = MeasureSection(s)
  1109. local endY = PlaceSection(s, colX, colW)
  1110. if endY > contentBottom then
  1111. contentBottom = endY
  1112. end
  1113. colX = colX + colW + COL_GAP
  1114. end
  1115. local contentW = math.max(colX - COL_GAP, PAD_L + stripW) + PAD_R
  1116.  
  1117. for defID, row in pairs(rowByDefID) do
  1118. if not used[defID] then
  1119. row.ctrl:Hide()
  1120. end
  1121. end
  1122.  
  1123. local contentH = contentBottom + PAD_B
  1124. holder:SetPos(nil, nil, nil, contentH)
  1125.  
  1126. -- Both dimensions follow the content. Height is capped so a large army cannot
  1127. -- run off screen; past the cap the overflow is simply clipped, since the panel
  1128. -- has no scrollbar to give away width to.
  1129. local screenH = (Chili.Screen0 and Chili.Screen0.height) or 1080
  1130. local screenW = (Chili.Screen0 and Chili.Screen0.width) or 1920
  1131. local maxH = math.floor(screenH * MAX_WIN_FRAC)
  1132. local wantH = math.max(math.min(contentH + WIN_VPAD, maxH), MIN_WIN_H)
  1133. local wantW = math.max(contentW + WIN_HPAD, MIN_WIN_W)
  1134.  
  1135. if window_main.width ~= wantW or window_main.height ~= wantH then
  1136. local x = window_main.x
  1137. -- Grow towards the middle of the screen: a panel on the right half keeps its
  1138. -- right edge planted, otherwise the left edge is the one that stays put.
  1139. if x + window_main.width * 0.5 > screenW * 0.5 then
  1140. x = math.max(0, x + window_main.width - wantW)
  1141. end
  1142. window_main:SetPos(x, window_main.y, wantW, wantH)
  1143. end
  1144. end
  1145.  
  1146. local function CreateWindow()
  1147. window_main = Chili.Window:New{
  1148. parent = Chili.Screen0,
  1149. name = "EnemyIntel",
  1150. classname = "main_window_small_very_flat",
  1151. -- Fully transparent panel: the skin bitmap and the background are both tinted
  1152. -- to zero alpha, so the rows float over the map with no frame behind them.
  1153. color = {0, 0, 0, 0},
  1154. backgroundColor = {0, 0, 0, 0},
  1155. borderColor = {0, 0, 0, 0},
  1156. dockable = true,
  1157. dockableSavePositionOnly = true,
  1158. parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
  1159. x = 0,
  1160. y = 41,
  1161. clientWidth = 165, -- starting values only; RefreshLayout fits both axes
  1162. clientHeight = 80, -- to the content on the first refresh
  1163. minWidth = MIN_WIN_W,
  1164. minHeight = MIN_WIN_H,
  1165. draggable = false,
  1166. resizable = false,
  1167. tweakDraggable = true,
  1168. tweakResizable = true,
  1169. padding = {6, 6, 6, 6},
  1170. }
  1171.  
  1172. scrollPanel = Chili.ScrollPanel:New{
  1173. parent = window_main,
  1174. x = 0, y = 0, right = 0, bottom = 0,
  1175. horizontalScrollbar = false,
  1176. verticalScrollbar = false, -- clip the overflow instead of showing a bar
  1177. padding = {0, 0, 0, 0},
  1178. borderColor = {0, 0, 0, 0},
  1179. backgroundColor = {0, 0, 0, 0},
  1180. }
  1181.  
  1182. holder = Chili.Control:New{
  1183. parent = scrollPanel,
  1184. x = 0, y = 0, right = 0,
  1185. height = 1,
  1186. padding = {0, 0, 0, 0},
  1187. }
  1188.  
  1189. headerMobile = Chili.Label:New{
  1190. parent = holder, x = 2, y = 0,
  1191. caption = "Mobile",
  1192. font = {
  1193. size = 13,
  1194. color = {1.0, 0.85, 0.40, 1},
  1195. outline = true,
  1196. outlineWidth = 3,
  1197. outlineWeight = 6,
  1198. outlineColor = {0, 0, 0, 1},
  1199. },
  1200. }
  1201. headerStatic = Chili.Label:New{
  1202. parent = holder, x = 2, y = 0,
  1203. caption = "Static",
  1204. font = {
  1205. size = 13,
  1206. color = {0.60, 0.80, 1.00, 1},
  1207. outline = true,
  1208. outlineWidth = 3,
  1209. outlineWeight = 6,
  1210. outlineColor = {0, 0, 0, 1},
  1211. },
  1212. }
  1213.  
  1214. local BAL_TITLE = {ground = "Ground", air = "Air", defence = "Defence"}
  1215. balance.rows = {}
  1216. for i = 1, #BUCKETS do
  1217. local b = BUCKETS[i]
  1218. local function Font(c)
  1219. return {size = 12, color = c, outline = true, outlineWidth = 3, outlineWeight = 6,
  1220. outlineColor = {0, 0, 0, 1}}
  1221. end
  1222. balance.rows[b] = {
  1223. name = Chili.Label:New{parent = holder, x = PAD_L, y = 0, caption = BAL_TITLE[b],
  1224. font = Font({0.85, 0.85, 0.85, 1})},
  1225. -- The bar is the split itself, as in Engagement Predictor: filled is your side,
  1226. -- the ground behind it is theirs.
  1227. bar = Chili.Progressbar:New{
  1228. parent = holder, x = PAD_L + BAL_NAME_W, y = 0,
  1229. width = BAL_BAR_W, height = BAL_BAR_H,
  1230. min = 0, max = 100, value = 50,
  1231. noFont = true,
  1232. color = {0.35, 0.80, 0.40, 0.95},
  1233. backgroundColor = {0.55, 0.16, 0.16, 0.85},
  1234. },
  1235. nums = Chili.Label:New{parent = holder, x = 0, y = 0, caption = "",
  1236. font = Font({1, 1, 1, 1})},
  1237. }
  1238. end
  1239. end
  1240.  
  1241. -- Show/hide only. The window is kept alive so its docked position, and anything the
  1242. -- tweak-mode layout did to it, survive being toggled off and on again.
  1243. function SetPanelVisible(visible)
  1244. if not window_main then
  1245. return
  1246. end
  1247. if visible then
  1248. window_main:Show()
  1249. dirty = true -- the table went stale while hidden; redraw before it is looked at
  1250. else
  1251. window_main:Hide()
  1252. end
  1253. end
  1254.  
  1255. --------------------------------------------------------------------------------
  1256. -- Callins
  1257. --------------------------------------------------------------------------------
  1258.  
  1259. local nextRefresh = 0
  1260. local nextSweep = 0
  1261.  
  1262. function M:Initialize()
  1263. Chili = WG.Chili
  1264. if not Chili then
  1265. Spring.Echo("Enemy Intel: WG.Chili unavailable, removing widget.")
  1266. widgetHandler:RemoveWidget(widget)
  1267. return
  1268. end
  1269.  
  1270. gaiaTeamID = Spring.GetGaiaTeamID() or -1
  1271.  
  1272. RebuildTeamMap()
  1273. CreateWindow()
  1274. SetPanelVisible(options.showPanel.value)
  1275.  
  1276. -- Seed from everything currently visible, so enabling mid-game is not blind.
  1277. local units = spGetAllUnits()
  1278. if units then
  1279. for i = 1, #units do
  1280. local uID = units[i]
  1281. AddUnit(uID, spGetUnitDefID(uID), spGetUnitTeam(uID))
  1282. end
  1283. end
  1284. ScanFeatures(true) -- wrecks already on the field predate the tally; register, don't deduct
  1285. dirty = true
  1286. balance.due = true -- our side of the strip, on the first Update rather than the first sweep
  1287.  
  1288. -- Published so gui_build_proposer.lua scores against the same numbers this panel
  1289. -- shows, rather than re-deriving the tally and drifting out of step with it.
  1290. WG.EnemyIntel = {
  1291. -- 2 adds the per-team readouts. A consumer should check for the functions rather
  1292. -- than the number, so that an older copy of this widget still drives it.
  1293. version = 2,
  1294. GetEffectiveCounts = GetEffectiveCounts, -- unitDefID -> count, every enemy
  1295. GetEffectiveCountsForTeam = GetEffectiveCountsForTeam, -- teamID -> that, or nil
  1296. GetEnemyTeams = GetEnemyTeams, -- array of enemy teamIDs we hold intel on
  1297. GetLastUpdate = function() return lastSweep end, -- game seconds
  1298. }
  1299. end
  1300.  
  1301. function M:Shutdown()
  1302. WG.EnemyIntel = nil
  1303. DestroyRows()
  1304. if window_main then
  1305. window_main:Dispose()
  1306. window_main = nil
  1307. end
  1308. balance.rows = {} -- went with the window
  1309. end
  1310.  
  1311. -- Deliberately no UnitLeftLos handler: that is what makes counts persist.
  1312. function M:UnitEnteredLos(unitID)
  1313. AddUnit(unitID, spGetUnitDefID(unitID), spGetUnitTeam(unitID))
  1314. end
  1315.  
  1316. function M:UnitCreated(unitID, unitDefID, unitTeam)
  1317. AddUnit(unitID, unitDefID, unitTeam)
  1318. end
  1319.  
  1320. function M:UnitFinished(unitID, unitDefID, unitTeam)
  1321. AddUnit(unitID, unitDefID, unitTeam)
  1322. end
  1323.  
  1324. function M:UnitDestroyed(unitID, unitDefID)
  1325. local trackedDefID = seen[unitID]
  1326. RemoveUnit(unitID)
  1327. if trackedDefID then
  1328. NoteWitnessedDeath(unitID, unitDefID or trackedDefID)
  1329. end
  1330. end
  1331.  
  1332. function M:PlayerChanged()
  1333. -- Fires for ANY player's change, so only act when our own viewpoint moved.
  1334. -- Wiping on every event would clear intel whenever someone disconnected.
  1335. local prevAllyTeam = myAllyTeamID
  1336. RebuildTeamMap()
  1337. if myAllyTeamID ~= prevAllyTeam then
  1338. ResetIntel() -- now looking from a different side, old counts are meaningless
  1339. end
  1340. balance.due = true -- our own side of the strip may have changed seat too
  1341. end
  1342.  
  1343. function M:DrawInMiniMap(sx, sy)
  1344. local list = ghosts.list
  1345. if #list == 0 or not options.minimapGhosts.value then
  1346. return
  1347. end
  1348. local util = Spring.Utilities
  1349. local flipped = util and util.IsMinimapFlipped and util.IsMinimapFlipped()
  1350. local rx, rz = sx / Game.mapSizeX, sy / Game.mapSizeZ
  1351. for i = 1, #list do
  1352. local c = list[i]
  1353. -- Minimap pixels, origin bottom left: the same mapping as Zero-K's Point Tracker.
  1354. local x, y = c.x * rx, sy - c.z * rz
  1355. if flipped then
  1356. x, y = sx - x, sy - y
  1357. end
  1358. local r = math.min(12, 2.5 + 1.2 * math.sqrt(c.value / 100))
  1359. gl.Color(1.0, 0.35, 0.20, 0.30 * c.alpha)
  1360. gl.BeginEnd(GL.TRIANGLE_FAN, CircleVerts, x, y, r, true)
  1361. gl.Color(1.0, 0.45, 0.30, 0.90 * c.alpha)
  1362. gl.BeginEnd(GL.LINE_LOOP, CircleVerts, x, y, r, false)
  1363. end
  1364. gl.Color(1, 1, 1, 1)
  1365. end
  1366.  
  1367. function M:Update()
  1368. local now = os.clock()
  1369.  
  1370. if now >= nextSweep then
  1371. nextSweep = now + 5
  1372. local gameNow = spGetGameSeconds()
  1373. lastSweep = gameNow
  1374. -- Pick up anything the callins missed, and drop confirmed deaths.
  1375. local units = spGetAllUnits()
  1376. if units then
  1377. visibleByDef = {}
  1378. for i = 1, #units do
  1379. local uID = units[i]
  1380. local uDefID = spGetUnitDefID(uID)
  1381. local uTeam = spGetUnitTeam(uID)
  1382. if not seen[uID] then
  1383. AddUnit(uID, uDefID, uTeam)
  1384. elseif teamOf[uID] ~= uTeam then
  1385. -- Captured, gifted or resigned into another team: re-file it under its
  1386. -- new owner. AddUnit drops it outright if that owner is now an ally.
  1387. RemoveUnit(uID)
  1388. AddUnit(uID, uDefID, uTeam)
  1389. end
  1390. -- Radar dots have no defID and are not "seen" in any useful sense.
  1391. if uDefID and IsEnemyTeam(uTeam) then
  1392. visibleByDef[uDefID] = (visibleByDef[uDefID] or 0) + 1
  1393. end
  1394. -- Anything the engine still hands us is in sight or on radar right now,
  1395. -- so its memory timer restarts here.
  1396. if seen[uID] then
  1397. lastSeen[uID] = gameNow
  1398. end
  1399. end
  1400. end
  1401. local forgetAfter = options.memoryTime.value * 60
  1402. for uID in pairs(seen) do
  1403. -- Returns nil for units we cannot see, so this never removes remembered ghosts.
  1404. if spGetUnitIsDead(uID) == true then
  1405. RemoveUnit(uID)
  1406. elseif gameNow - (lastSeen[uID] or gameNow) >= forgetAfter then
  1407. -- Out of sight for the whole memory window: the intel is too old to trust,
  1408. -- so the unit stops counting until we lay eyes on it again.
  1409. RemoveUnit(uID)
  1410. end
  1411. end
  1412.  
  1413. for defID, owed in pairs(wreckDeduct) do
  1414. local have = counts[defID] or 0
  1415. if owed > have then
  1416. wreckDeduct[defID] = (have > 0) and have or nil
  1417. end
  1418. end
  1419.  
  1420. ScanFeatures()
  1421. PruneSites()
  1422. if options.showBalance.value then
  1423. UpdateBalanceMine()
  1424. end
  1425. elseif balance.due and options.showBalance.value then
  1426. UpdateBalanceMine() -- the side option changed; do not wait out the sweep
  1427. end
  1428.  
  1429. -- The minimap is not the panel, so this runs whether or not the panel is shown.
  1430. if now >= ghosts.next then
  1431. ghosts.next = now + 1
  1432. if options.minimapGhosts.value then
  1433. RefreshGhosts(spGetGameSeconds())
  1434. elseif #ghosts.list > 0 then
  1435. ghosts.list = {}
  1436. end
  1437. end
  1438.  
  1439. if not options.showPanel.value then
  1440. -- Nothing to lay out while hidden. `dirty` and `needFullRebuild` are left standing
  1441. -- so the first refresh after unhiding rebuilds against the intel gathered meanwhile.
  1442. return
  1443. end
  1444.  
  1445. if needFullRebuild then
  1446. needFullRebuild = false
  1447. DestroyRows()
  1448. dirty = true
  1449. end
  1450.  
  1451. if dirty and now >= nextRefresh then
  1452. nextRefresh = now + options.updateRate.value
  1453. dirty = false
  1454. RefreshLayout()
  1455. end
  1456. end
  1457.  
  1458. RegisterModule{
  1459. callins = M,
  1460. prefix = 'ei_',
  1461. optionsPath = options_path,
  1462. options = options,
  1463. optionsOrder = options_order,
  1464. }
  1465. end
  1466.  
  1467. --------------------------------------------------------------------------------
  1468. -- Build Proposer
  1469. --
  1470. -- Was gui_build_proposer.lua.
  1471. --------------------------------------------------------------------------------
  1472.  
  1473. do
  1474. local M = {} -- this module's callins, collected by RegisterModule below
  1475. local options_path, options_order, options -- were file-scope globals
  1476.  
  1477. --------------------------------------------------------------------------------
  1478. -- Includes / static per-unitDef data
  1479. --------------------------------------------------------------------------------
  1480.  
  1481. -- Tactical icon resolution, same pattern as gui_enemy_intel.lua
  1482. local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
  1483. local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
  1484.  
  1485. local iconFormat = ".dds"
  1486. do
  1487. -- chilitip_conf.lua returns: ignoreweapon, iconFormat
  1488. local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
  1489. if ok and fmt then
  1490. iconFormat = fmt
  1491. end
  1492. end
  1493.  
  1494. local spGetTeamResources = Spring.GetTeamResources
  1495. local spGetTeamRulesParam = Spring.GetTeamRulesParam
  1496. local spGetTeamUnits = Spring.GetTeamUnits
  1497. local spGetUnitDefID = Spring.GetUnitDefID
  1498. local spGetUnitIsDead = Spring.GetUnitIsDead
  1499. local spGetFactoryCommands = Spring.GetFactoryCommands
  1500. local spGetFullBuildQueue = Spring.GetFullBuildQueue
  1501. local spGiveOrderToUnit = Spring.GiveOrderToUnit
  1502. local spGetMyTeamID = Spring.GetMyTeamID
  1503. local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
  1504. local spGetTeamList = Spring.GetTeamList
  1505. local spGetTeamInfo = Spring.GetTeamInfo
  1506. local spGetPlayerInfo = Spring.GetPlayerInfo
  1507. local spGetAIInfo = Spring.GetAIInfo
  1508. local spGetModKeyState = Spring.GetModKeyState
  1509. local spGetGroundHeight = Spring.GetGroundHeight
  1510.  
  1511. -- Zero-K loads this helper into the widget environment too, but fall back rather than
  1512. -- depend on load order.
  1513. local spGetUnitRepeat = (Spring.Utilities and Spring.Utilities.GetUnitRepeat)
  1514. or function(unitID)
  1515. local states = Spring.GetUnitStates(unitID)
  1516. return states and states["repeat"]
  1517. end
  1518.  
  1519. local DEFAULT_ICON = "icons/default.dds"
  1520.  
  1521. --------------------------------------------------------------------------------
  1522. -- Archetype classification
  1523. --
  1524. -- Every factory in Zero-K carries the same set of functional archetypes, so counters
  1525. -- transfer between factories by role. Classification is therefore the whole job: get the
  1526. -- archetype right and the matrix below does the rest.
  1527. --
  1528. -- The curated table is keyed by the internal unitDef name rather than the display name,
  1529. -- because Zero-K renames units constantly while the internal names have not moved in
  1530. -- years. Rocko is `cloakskirm`, displayed as Ronin; Warrior is `cloakriot`, now Reaver;
  1531. -- Zeus is `cloakassault`, now Knight; Tick is `cloakbomb`, now Imp; Roach is `shieldbomb`,
  1532. -- now Snitch; Sharpshooter is `cloaksnipe`, now Phantom; Leveler is `vehriot`, now Ripper;
  1533. -- Wolverine is `veharty`, now Badger; Reaper is `tankassault`, now Minotaur; Goliath is
  1534. -- `tankheavyassault`, now Cyclops; Pillager is `tankarty`, now Emissary; Banisher is
  1535. -- `tankriot`, now Ogre; Copperhead is `tankaa`, now Ettin; Bantha is `striderbantha`, now
  1536. -- Paladin; Catapult is `striderarty`, now Merlin; Sumo is `jumpsumo`, now Jugglenaut;
  1537. -- Freaker is `jumpaa`, now Toad; Crabe is `spidercrabe`, now Crab; Brawler is
  1538. -- `gunshipassault`, now Revenant; Rapier is `gunshipskirm`, now Harpy; Banshee is
  1539. -- `gunshipraid`, now Locust; the AA gunship Vandal is `gunshipaa`, now Trident, and the
  1540. -- name Vandal moved to the Shieldbot AA, `shieldaa`. Slasher, Panther, Infiltrator and
  1541. -- Archangel no longer exist in any form, so nothing maps to them.
  1542. --------------------------------------------------------------------------------
  1543.  
  1544. local ARCHETYPE = {}
  1545. do
  1546. local curated = {
  1547. -- Fast, high DPS for cost, weak armour and range. Rushes, harassment, flanking.
  1548. raider = {
  1549. "cloakraid", "cloakheavyraid", "shieldraid", "vehscout", "vehraid", "amphraid",
  1550. "jumpraid", "jumpscout", "tankraid", "tankheavyraid", "hoverraid", "hoverheavyraid",
  1551. "shiptorpraider", "subraider",
  1552. },
  1553. -- Extreme speed, no armour. There to see and to screen rather than to fight.
  1554. scout = {
  1555. "spiderscout", "shieldscout", "shipscout", "planescout", "planelightscout",
  1556. },
  1557. -- Tanky mainline. Slow turrets and projectiles, wins the straight brawl.
  1558. assault = {
  1559. "cloakassault", "shieldassault", "vehassault", "spiderassault", "spidercrabe",
  1560. "jumpassault", "jumpsumo", "tankassault", "tankheavyassault", "amphassault",
  1561. "hoverassault", "shipassault",
  1562. "striderbantha", "striderdetriment",
  1563. },
  1564. -- Range-focused, light, kites. Beats whatever has to walk into it.
  1565. skirm = {
  1566. "cloakskirm", "shieldskirm", "spiderskirm", "jumpskirm", "amphfloater",
  1567. "hoverskirm", "hoverarty", "shipskirm",
  1568. },
  1569. -- Short range, high DPS, area damage. Exists to delete cheap things in bulk.
  1570. riot = {
  1571. "cloakriot", "shieldriot", "vehriot", "spiderriot", "spideremp", "tankriot",
  1572. "amphimpulse", "amphriot", "hoverriot", "hoverdepthcharge",
  1573. "shipriot", "striderdante",
  1574. },
  1575. -- Outranges everything, kills structures, helpless once anything reaches it.
  1576. arty = {
  1577. "cloakarty", "cloaksnipe", "shieldarty", "veharty", "vehheavyarty", "jumparty",
  1578. "tankarty", "tankheavyarty", "amphsupport", "shiparty", "shipheavyarty",
  1579. "subtacmissile", "striderarty",
  1580. },
  1581. -- Crawling and jumping bombs. Cheap, one-use, aimed at things that clump.
  1582. bomb = {
  1583. "cloakbomb", "shieldbomb", "amphbomb", "jumpbomb",
  1584. },
  1585. -- The solver layer: cheap answers to units far more expensive than themselves.
  1586. antiheavy = {
  1587. "striderscorpion", "striderantiheavy", "spiderantiheavy", "vehcapture",
  1588. },
  1589. -- Only shoots aircraft, and is useless at everything else by design.
  1590. aa = {
  1591. "cloakaa", "shieldaa", "vehaa", "spideraa", "jumpaa", "tankaa", "hoveraa",
  1592. "amphaa", "shipaa",
  1593. },
  1594. -- Air superiority. Counters other air, cannot touch the ground.
  1595. fighter = {
  1596. "planefighter", "planeheavyfighter", "gunshipaa",
  1597. },
  1598. -- Hover-air that engages the ground directly.
  1599. gunship = {
  1600. "gunshipraid", "gunshipskirm", "gunshipheavyskirm", "gunshipassault", "gunshipemp",
  1601. "gunshipbomb", "gunshipkrow",
  1602. },
  1603. -- Fixed-wing that makes a pass and leaves.
  1604. bomber = {
  1605. "bomberprec", "bomberriot", "bomberdisarm", "bomberheavy", "bomberassault",
  1606. "bomberstrike",
  1607. },
  1608. -- Carries a shield for the army rather than damage for itself.
  1609. shield = {
  1610. "shieldfelon", "shieldshield", "striderfunnelweb",
  1611. },
  1612. -- Long range, low DPS, does something other than damage.
  1613. support = {
  1614. "vehsupport", "amphlaunch", "amphtele", "shipcarrier",
  1615. },
  1616. -- Hides other units. Never a combat answer itself, but a target archetype.
  1617. cloaker = {
  1618. "cloakjammer", "staticjammer",
  1619. },
  1620. -- Commanders are not listed here. They are recognised from the unitDef instead, at
  1621. -- the top of the classification below, because there are seventy-two of them --
  1622. -- twelve chassis by six levels, built by clonedefs.lua -- and naming all seventy-two
  1623. -- here would still miss the campaign ones and anything a future level adds.
  1624. --
  1625. -- Build power. Never proposed, and the thing raiders go looking for.
  1626. con = {
  1627. "cloakcon", "shieldcon", "vehcon", "spidercon", "jumpcon", "tankcon", "amphcon",
  1628. "hovercon", "shipcon", "gunshipcon", "planecon", "staticcon",
  1629. },
  1630. }
  1631. for arch, names in pairs(curated) do
  1632. for i = 1, #names do
  1633. ARCHETYPE[names[i]] = arch
  1634. end
  1635. end
  1636. end
  1637.  
  1638. -- Fallback for anything the curated table misses. Zero-K's iconType vocabulary is itself a
  1639. -- role taxonomy (kbotraider, tankriot, hoverskirm, spideraa ...), so the archetype falls
  1640. -- out of the suffix. First match wins, so the specific entries come first.
  1641. local ARCH_SUFFIX = {
  1642. {"torpraider", "raider"},
  1643. {"torpassault", "assault"},
  1644. {"torpriot", "riot"},
  1645. {"wideriot", "riot"},
  1646. {"heavyraid", "raider"},
  1647. {"skirmaa", "aa"},
  1648. {"heavysam", "aa"},
  1649. {"lrarty", "arty"},
  1650. {"depthcharge", "riot"},
  1651. {"raider", "raider"},
  1652. {"assault", "assault"},
  1653. {"skirm", "skirm"},
  1654. {"riot", "riot"},
  1655. {"arty", "arty"},
  1656. {"lrm", "arty"},
  1657. {"sniper", "arty"},
  1658. {"fighter", "fighter"},
  1659. {"bomb", "bomb"},
  1660. {"scout", "scout"},
  1661. {"aa", "aa"},
  1662. {"builder", "con"},
  1663. {"con", "con"},
  1664. {"shield", "shield"},
  1665. {"jammer", "cloaker"},
  1666. {"stealth", "cloaker"},
  1667. {"radar", "support"},
  1668. {"transport", "support"},
  1669. {"support", "support"},
  1670. {"special", "support"},
  1671. {"generic", "support"},
  1672. {"ears", "support"},
  1673. }
  1674.  
  1675. --------------------------------------------------------------------------------
  1676. -- The counter matrix
  1677. --
  1678. -- One number per ordered pair of units: how much that pick is worth against that
  1679. -- target. 1.0 is even, 1.5 and up is a counter, 0.5 and down is being counter-picked.
  1680. -- There are no layers. The value in the table is the value the ranking uses -- nothing
  1681. -- is averaged, weighted or blended, and the archetype/unit-vs-archetype/unit-vs-unit
  1682. -- structure that used to live here has been resolved into the flat table and archived
  1683. -- beside it in LuaUI/Configs/gui_build_proposer_layers_archive.lua, which nothing loads.
  1684. --
  1685. -- Each row also carries "#"-prefixed fallback columns. They are not a layer: they are
  1686. -- what a lookup falls back to when the TARGET is not a row of the table -- the buildings
  1687. -- outside the 35 statics it names, chickens, drones, and anything ZK adds later. Without
  1688. -- them a raider would stop preferring undefended economy, since most eco buildings are
  1689. -- not rows. "#defense" is any armed building and "#eco" any unarmed one.
  1690. --------------------------------------------------------------------------------
  1691.  
  1692. local NEUTRAL = 1.0
  1693.  
  1694. -- The one place a unit is not looked up under its own name.
  1695. --
  1696. -- There are seventy-two commander unitDefs -- twelve chassis by six levels, built by
  1697. -- clonedefs.lua -- and seventy-two rows of a counter matrix would be seventy-two copies of
  1698. -- five, because level changes how hard a commander hits rather than what it is good
  1699. -- against, and the matrix only ever answers the second question. So every commander is
  1700. -- looked up under its chassis, and the level digit is simply dropped.
  1701. --
  1702. -- The legacy chassis share a row with the dynamic one they resemble, being the same unit
  1703. -- wearing a different model. The chassis a multiplayer game fields are Strike, Guardian
  1704. -- (dynassault), Recon and Engineer (dynsupport); the Knight (dynknight) is the campaign
  1705. -- commander, a Strike with another model, so it reads the Strike row too. Its own row stays
  1706. -- in the matrix file but nothing looks it up. A commander this table has never heard of --
  1707. -- another campaign comm, or a chassis added later -- keeps its own name, finds no row, and
  1708. -- is answered by the "#commander" fallback column like any other stranger.
  1709. local COMMANDER_ROW = {
  1710. dynstrike = "dynstrike", commstrike = "dynstrike", armcom = "dynstrike",
  1711. dynassault = "dynassault", corcom = "dynassault", benzcom = "dynassault",
  1712. dynknight = "dynstrike", cremcom = "dynstrike", -- campaign Knight: a Strike clone
  1713. dynrecon = "dynrecon", commrecon = "dynrecon",
  1714. dynsupport = "dynsupport", commsupport = "dynsupport",
  1715. }
  1716.  
  1717. -- A real game's commanders are mostly not named like the chassis. The starting commander is
  1718. -- generated as "<profile>_base" -- dyntrainer_assault_base for a Guardian picked at the
  1719. -- start, or a player's own profile id -- copied from dyn<chassis>1 with its customParams, so
  1720. -- statsname still says "dynassault1". Only after a morph is the unit dyn<chassis><level>.
  1721. -- statsname first, then the unit name, then commtype; nil if none of them is a chassis.
  1722. local COMMTYPE_ROW = {["1"] = "dynstrike", ["3"] = "dynrecon", ["4"] = "dynsupport",
  1723. ["5"] = "dynassault", ["6"] = "dynstrike"}
  1724. local function CommanderRow(ud, cp)
  1725. local function strip(n) return n and (tostring(n):gsub("%d+$", "")) end
  1726. return COMMANDER_ROW[strip(cp.statsname)] or COMMANDER_ROW[strip(ud.name)]
  1727. or COMMTYPE_ROW[tostring(cp.commtype or "")]
  1728. end
  1729.  
  1730. local MATRIX_FILE = VFS.Include("LuaUI/Configs/gui_build_proposer_matrix.lua")
  1731. local MATRIX = MATRIX_FILE.matrix
  1732.  
  1733. -- What counters each class. Frozen at the 0.55 threshold this used to be derived at, back
  1734. -- when there was an archetype table to read it out of; only the "archetype columns" setting
  1735. -- of the counter-source option consults it, and the matrix reading is the default.
  1736. local COUNTERED_BY = MATRIX_FILE.counteredBy
  1737.  
  1738. --------------------------------------------------------------------------------
  1739. -- Unit quality
  1740. --
  1741. -- The matrix above answers "what beats what". It has nothing to say about the fact that
  1742. -- two units can occupy the same square of it and still not be worth the same metal: a
  1743. -- Glaive and a Blitz are both raiders that lose to riots, and picking between them is not
  1744. -- a matchup question at all. That judgement is what a tier list is, so this is one --
  1745. -- Aquanim's, at zero-k.info/mediawiki/User:Aquanim/UnitTierList, transcribed by internal
  1746. -- name and read through the tier's own stated meaning: S is "I would happily make a new
  1747. -- factory to make only this unit" and F is "I do not make this unit, and you should not
  1748. -- make it either".
  1749. --
  1750. -- It is a single opinion about a moving target, which is exactly why it sits on its own
  1751. -- slider and is applied as a multiplier on the finished score rather than being folded
  1752. -- into the matrix. At weight 0 it does nothing and the ranking is pure counter-picking;
  1753. -- at weight 1 a tier is worth about a sixth of a matchup step; turn it up if you would
  1754. -- rather be told what is good than what is clever. Anything not listed is unrated and
  1755. -- scores 1.0, so a gap in the table is neutral rather than a penalty.
  1756. --
  1757. -- The tier list's water-map section is deliberately not transcribed: those ratings are
  1758. -- explicitly conditional on the map (Conch is "better than mex" there and ordinary on
  1759. -- land), and a flat table cannot say that.
  1760. --------------------------------------------------------------------------------
  1761.  
  1762. local TIER_VALUE = {S = 1.30, A = 1.15, B = 1.00, C = 0.88, D = 0.75, F = 0.62}
  1763.  
  1764. local UNIT_TIER = {
  1765. -- Constructors
  1766. shieldcon = "A", tankcon = "A", jumpcon = "A", cloakcon = "A",
  1767. spidercon = "B", vehcon = "B", hovercon = "B", amphcon = "B",
  1768. gunshipcon = "C", planecon = "D",
  1769.  
  1770. -- Raiders and scouts
  1771. cloakraid = "S",
  1772. vehraid = "A", shieldraid = "A",
  1773. tankraid = "B", jumpraid = "B", cloakheavyraid = "B", hoverraid = "B",
  1774. tankheavyraid = "C",
  1775. spiderscout = "S", vehscout = "A", jumpscout = "A", shieldscout = "B",
  1776.  
  1777. -- Anti-raider escorts
  1778. hoverheavyraid = "B", spideremp = "B", amphimpulse = "B",
  1779.  
  1780. -- Skirmishers and fire support
  1781. vehsupport = "A", hoverskirm = "A", amphfloater = "A", shieldfelon = "A",
  1782. spiderskirm = "B", jumpskirm = "B", spidercrabe = "B", shieldskirm = "B",
  1783. amphsupport = "B",
  1784. cloakskirm = "C", vehcapture = "D",
  1785.  
  1786. -- Riots
  1787. cloakriot = "A", hoverriot = "A", tankriot = "A",
  1788. spiderriot = "B", vehriot = "B", shieldriot = "B", striderdante = "B",
  1789. amphriot = "C", hoverdepthcharge = "C",
  1790.  
  1791. -- Assaults
  1792. tankheavyassault = "S",
  1793. tankassault = "A", shieldassault = "A", jumpsumo = "A",
  1794. hoverassault = "B", jumpassault = "B", amphassault = "B",
  1795. cloakassault = "C", vehassault = "C", spiderassault = "C",
  1796.  
  1797. -- Artillery
  1798. vehheavyarty = "S",
  1799. hoverarty = "A", cloaksnipe = "A", jumparty = "A",
  1800. cloakarty = "B", veharty = "B", striderarty = "B", tankheavyarty = "B",
  1801. tankarty = "C",
  1802.  
  1803. -- Anti-heavy and bombs
  1804. spiderantiheavy = "S", shieldbomb = "S",
  1805. cloakbomb = "A", amphbomb = "A", shieldarty = "A", striderscorpion = "A",
  1806. striderantiheavy = "B", jumpbomb = "C",
  1807.  
  1808. -- Support and utility
  1809. amphlaunch = "S", gunshiptrans = "S",
  1810. cloakjammer = "A", shieldshield = "A", jumpblackhole = "A", amphtele = "A",
  1811. striderfunnelweb = "A",
  1812.  
  1813. -- Fixed wing
  1814. planefighter = "S", bomberheavy = "S", planescout = "S", bomberdisarm = "S",
  1815. bomberassault = "A", bomberriot = "A",
  1816. planeheavyfighter = "B", bomberprec = "B", planelightscout = "B",
  1817. bomberstrike = "C",
  1818.  
  1819. -- Gunships
  1820. gunshipaa = "A", gunshipbomb = "A",
  1821. gunshipassault = "B", gunshipskirm = "B", gunshipemp = "B", gunshipkrow = "B",
  1822. gunshipheavyskirm = "C", gunshipraid = "D",
  1823.  
  1824. -- Anti-air
  1825. amphaa = "A", turretaalaser = "A", tankaa = "A",
  1826. vehaa = "B", spideraa = "B", shieldaa = "B", hoveraa = "B", jumpaa = "B",
  1827. turretaaheavy = "B", turretaafar = "B", turretaaflak = "B",
  1828. cloakaa = "C", turretaaclose = "C",
  1829.  
  1830. -- Other static defence
  1831. turretheavylaser = "A", turretlaser = "A",
  1832. turretmissile = "B", turretimpulse = "B", turretemp = "B", turretgauss = "B",
  1833. staticarty = "B",
  1834. turretriot = "C", turretheavy = "C", turretantiheavy = "D",
  1835.  
  1836. -- Tactical missiles
  1837. empmissile = "S", tacnuke = "S",
  1838. napalmmissile = "B", missileslow = "B", seismic = "B",
  1839.  
  1840. -- Endgame
  1841. staticnuke = "S", striderbantha = "A",
  1842. striderdetriment = "B", zenith = "B", mahlazer = "B",
  1843. staticheavyarty = "C", raveparty = "D",
  1844. }
  1845.  
  1846. local QUALITY = {} -- internal unitDef name -> score multiplier
  1847. for name, tier in pairs(UNIT_TIER) do
  1848. QUALITY[name] = TIER_VALUE[tier] or 1
  1849. end
  1850.  
  1851. local ROLE_PLURAL = {
  1852. raider = "raiders", scout = "scouts", assault = "assaults", skirm = "skirmishers",
  1853. riot = "riots", arty = "artillery", bomb = "mobile bombs", antiheavy = "anti-heavy units",
  1854. aa = "anti-air", fighter = "fighters", gunship = "gunships", bomber = "bombers",
  1855. shield = "shield units", support = "support units", cloaker = "cloakers",
  1856. con = "constructors", defense = "static defence", eco = "economy",
  1857. commander = "commanders",
  1858. }
  1859.  
  1860. -- Archetypes that are never a proposition, whatever the enemy is fielding. Support stays
  1861. -- in: Fencer and Lance are real answers to an assault push. Anything genuinely unarmed --
  1862. -- transports, jammers, Djinn -- is dropped by the weapon gate in PairScore rather than by
  1863. -- name.
  1864. local NON_COMBAT = {con = true, cloaker = true}
  1865.  
  1866. -- Effectiveness never falls below this no matter how one-sided the enemy composition is.
  1867. local COUNTER_FLOOR = 0.1
  1868.  
  1869. --------------------------------------------------------------------------------
  1870. -- Per-unitDef tables, built once at load
  1871. --------------------------------------------------------------------------------
  1872.  
  1873. local iconFile = {}
  1874. local unitName = {}
  1875. local defName = {} -- internal unitDef name
  1876. local matrixName = {} -- the name the counter matrix is keyed by, which is not always defName
  1877. local role = {}
  1878. local cost = {}
  1879. local buildTime = {}
  1880. local health = {} -- effective HP, shields folded in
  1881. local speed = {}
  1882. local range = {}
  1883. local dpsGround = {}
  1884. local dpsAir = {}
  1885. local dpsWater = {} -- against submerged targets
  1886. local isAir = {}
  1887. local isStatic = {}
  1888. local isArmed = {}
  1889. local isAA = {} -- can shoot air and nothing else
  1890. local isSub = {}
  1891. local needsWater = {}
  1892. local isFactory = {}
  1893. local buildSpeed = {}
  1894. -- Which plants the auto-queue can be told to leave alone. Both are read off the def rather
  1895. -- than off a list of unit names: the silo is the one thing carrying the customParam its own
  1896. -- LuaRules logic keys off, and an air plant is simply a factory with nothing but aircraft on
  1897. -- its roster -- which catches the Plane Plate and the Gunship Plate for free, since a plate
  1898. -- builds its parent's roster.
  1899. local isAirFactory = {}
  1900. local isMissileSilo = {}
  1901.  
  1902. local AIR_CATEGORY = {fixedwing = true, gunship = true}
  1903.  
  1904. local function ArchFromIcon(iconType)
  1905. for i = 1, #ARCH_SUFFIX do
  1906. local suffix, r = ARCH_SUFFIX[i][1], ARCH_SUFFIX[i][2]
  1907. if #iconType >= #suffix and iconType:sub(-#suffix) == suffix then
  1908. return r
  1909. end
  1910. end
  1911. return nil
  1912. end
  1913.  
  1914. for defID = 1, #UnitDefs do
  1915. local ud = UnitDefs[defID]
  1916.  
  1917. local it = ud.iconType or "default"
  1918. local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
  1919. or ("icons/" .. it .. iconFormat)
  1920. -- Zero-K's own square build texture, which the engine renders from the model, rather
  1921. -- than icons/default.dds -- that file is fully transparent, so a missing icon drew a
  1922. -- blank box with a count beside it (submarine, none, the legacy tank/kbot set).
  1923. if not VFS.FileExists(path) then
  1924. path = "#" .. defID
  1925. end
  1926. iconFile[defID] = path
  1927. unitName[defID] = ud.humanName or ud.name or "?"
  1928. defName[defID] = ud.name or ""
  1929.  
  1930. cost[defID] = ud.metalCost or 0
  1931. buildTime[defID] = ud.buildTime or 0
  1932. speed[defID] = ud.speed or 0
  1933. isAir[defID] = ud.canFly or false
  1934. isStatic[defID] = (ud.speed or 0) == 0
  1935. isFactory[defID] = ud.isFactory or false
  1936. buildSpeed[defID] = ud.buildSpeed or 0
  1937. isMissileSilo[defID] = (ud.customParams and ud.customParams.missile_silo_capacity) and true or false
  1938. if ud.isFactory and ud.buildOptions then
  1939. -- "All of the roster flies", not "any of it does": a Gunship Plant is air, while a
  1940. -- Hovercraft Platform that happens to list one flying scout is not.
  1941. local mobile, flying = 0, 0
  1942. for i = 1, #ud.buildOptions do
  1943. local od = UnitDefs[ud.buildOptions[i]]
  1944. if od and (od.speed or 0) > 0 then
  1945. mobile = mobile + 1
  1946. if od.canFly then
  1947. flying = flying + 1
  1948. end
  1949. end
  1950. end
  1951. isAirFactory[defID] = mobile > 0 and mobile == flying
  1952. end
  1953. -- Anything with a minimum water depth needs somewhere to float, which is what the dry
  1954. -- map gate keys off. That figure cannot tell a submarine from a surface ship though --
  1955. -- both sit at 5 to 15 -- so the SUB category is what decides who is submerged, and a
  1956. -- submerged target is only reachable by torpedoes.
  1957. needsWater[defID] = (ud.minWaterDepth or 0) > 0
  1958. isSub[defID] = (ud.modCategories and ud.modCategories.sub) or false
  1959.  
  1960. local ehp = ud.health or 1
  1961. local g, a, w = 0, 0, 0
  1962. local maxRange = 0
  1963. local anyWeapon, onlyAir = false, true
  1964.  
  1965. local weapons = ud.weapons or {}
  1966. for i = 1, #weapons do
  1967. local wpn = weapons[i]
  1968. local wd = wpn.weaponDef and WeaponDefs[wpn.weaponDef]
  1969. if wd then
  1970. if wd.isShield then
  1971. -- A shield is stored HP under another name, so it belongs in the EHP figure.
  1972. ehp = ehp + (wd.shieldPower or 0)
  1973. else
  1974. local cp = wd.customParams or {}
  1975. -- Zero-K publishes a curated per-shot damage figure for its own stats panels.
  1976. -- It is more trustworthy than reconstructing one from the raw engine fields.
  1977. local dmg = tonumber(cp.stats_damage) or (wd.damages and wd.damages[0]) or 0
  1978. if wd.paralyzer then
  1979. -- EMP does not kill, it buys time. Worth something, not full value.
  1980. dmg = dmg * 0.5
  1981. end
  1982. local salvo = (wd.salvoSize or 1) * (wd.projectiles or 1)
  1983. local reload = wd.reload or 0
  1984. if reload <= 0 then
  1985. reload = 1
  1986. end
  1987. local dps = dmg * salvo / reload
  1988.  
  1989. if dps > 0 then
  1990. anyWeapon = true
  1991. if (wd.range or 0) > maxRange then
  1992. maxRange = wd.range or 0
  1993. end
  1994.  
  1995. -- onlyTargets is keyed by lowercase category name, the same read
  1996. -- LuaUI/Configs/burst_damages.lua does. An empty table means the weapon
  1997. -- was never restricted, so it hits everything.
  1998. local ot = wpn.onlyTargets
  1999. local hitsAir, hitsGround = false, false
  2000. if not ot or not next(ot) then
  2001. hitsAir, hitsGround = true, true
  2002. else
  2003. for cat in pairs(ot) do
  2004. if AIR_CATEGORY[cat] then
  2005. hitsAir = true
  2006. else
  2007. hitsGround = true
  2008. end
  2009. end
  2010. end
  2011.  
  2012. if hitsAir then
  2013. a = a + dps
  2014. end
  2015. if hitsGround then
  2016. g = g + dps
  2017. onlyAir = false
  2018. if wd.waterWeapon then
  2019. w = w + dps
  2020. end
  2021. end
  2022. end
  2023. end
  2024. end
  2025. end
  2026.  
  2027. health[defID] = ehp
  2028. range[defID] = maxRange
  2029. dpsGround[defID] = g
  2030. dpsAir[defID] = a
  2031. dpsWater[defID] = w
  2032. isArmed[defID] = anyWeapon
  2033. isAA[defID] = anyWeapon and onlyAir
  2034.  
  2035. -- Archetype: commanders first, then the curated table, then the icon suffix, then a
  2036. -- guess from the stats. Aircraft are decided by what they are rather than by their icon,
  2037. -- because the air side of the matrix splits three ways and the icon vocabulary does not.
  2038. --
  2039. -- Commanders have to be caught before any of that, and by flag rather than by name. They
  2040. -- carry build power, so the stats guess at the bottom of this chain called every one of
  2041. -- them a constructor, and "there is an enemy commander in my base" was being answered
  2042. -- with the number for "there is an enemy Conjurer in my base". The test is the one
  2043. -- Zero-K's own unitdefs_post.lua uses to find them, so it covers the dynamic chassis,
  2044. -- the legacy ones, and the campaign comms alike, at every level.
  2045. local cp = ud.customParams or {}
  2046. local r = (cp.dynamic_comm or cp.commtype) and "commander" or ARCHETYPE[ud.name]
  2047. if not r and ud.canFly then
  2048. if isAA[defID] then
  2049. r = "fighter"
  2050. elseif ud.name:sub(1, 6) == "bomber" then
  2051. r = "bomber"
  2052. elseif (ud.buildSpeed or 0) > 0 then
  2053. r = "con"
  2054. else
  2055. r = "gunship"
  2056. end
  2057. end
  2058. r = r or ArchFromIcon(it)
  2059. if not r then
  2060. if isAA[defID] then
  2061. r = "aa"
  2062. elseif (ud.buildSpeed or 0) > 0 then
  2063. r = "con"
  2064. elseif not anyWeapon then
  2065. r = "support"
  2066. elseif maxRange > 600 and (ud.speed or 0) < 60 then
  2067. r = "arty"
  2068. elseif (ud.speed or 0) > 90 then
  2069. r = "raider"
  2070. else
  2071. r = "assault"
  2072. end
  2073. end
  2074. role[defID] = r
  2075.  
  2076. -- Everything is keyed by its own name except a commander, which answers to its chassis.
  2077. if r == "commander" then
  2078. matrixName[defID] = CommanderRow(ud, cp) or defName[defID]
  2079. else
  2080. matrixName[defID] = defName[defID]
  2081. end
  2082. end
  2083.  
  2084. --------------------------------------------------------------------------------
  2085. -- State
  2086. --------------------------------------------------------------------------------
  2087.  
  2088. local myTeamID = spGetMyTeamID()
  2089. local myAllyTeamID = spGetMyAllyTeamID()
  2090. local amSpectating = false
  2091.  
  2092. -- Engagement tracking, for the focused modes below.
  2093. --
  2094. -- Damage is the only signal a widget can read that says who you are actually fighting.
  2095. -- Positions say where the armies are, not whose army is shooting at yours, and the
  2096. -- engine will not hand out an enemy team's stats mid-game. So every hit that lands
  2097. -- between our units and theirs is tallied against the team on the other end of it,
  2098. -- ours dealt and ours taken alike -- being shot at is as much a fight as shooting.
  2099. --
  2100. -- What they have landed on us is then tallied a second time on its own, because "who am I
  2101. -- fighting" and "who is hurting me" stop being the same question the moment you are
  2102. -- winning one fight and losing another. An opponent you are shelling for free and one who
  2103. -- is taking your base apart can trade the same total with you, and only the second one
  2104. -- says anything about what you need out of the factory next.
  2105. local enemyTeams = {} -- teamID -> true, every hostile team in the game
  2106. local engagement = {} -- enemy teamID -> damage traded with us, decayed over time
  2107. local harm = {} -- enemy teamID -> damage *they* landed on us, same decay
  2108. local lastFought = {} -- enemy teamID -> clock of the last shot traded with them
  2109.  
  2110. -- And a third tally, one level below the player: what has shot at us, unit by unit.
  2111. --
  2112. -- Even the narrowest player mode answers a whole roster, and a roster is mostly things you
  2113. -- have never met -- their other front, their home defence, the raiders that died to an ally.
  2114. -- What has actually put damage on you is a far shorter and far more specific list, it is the
  2115. -- list that says what is beating you right now, and unlike a player it stops existing: a dead
  2116. -- Ripper is not something to counter-build against. So this is keyed by unitID rather than by
  2117. -- type, and dropped the moment the unit dies.
  2118. local attackers = {} -- enemy unitID -> {def, team, dmg it did us, clock of its last hit}
  2119.  
  2120. local focusTeams = nil -- teamID -> true, whose army is being answered; nil for everyone
  2121. local focusWeights = nil -- teamID -> how heavily that army counts, 1 for the main front
  2122. local focusUnits = nil -- unitDefID -> count, an army assembled from single units, not teams
  2123. local focusMain = nil -- the hardest-fought opponent, kept for the header and hysteresis
  2124. local focusNote = nil -- why we are not focusing, when the mode asked us to
  2125. local focusActive = false -- whether the last ranking really was restricted to one front
  2126. local lastDecay = os.clock()
  2127. local clockNow = lastDecay -- refreshed once per Update, so UnitDamaged need not ask the OS
  2128.  
  2129. -- A challenger has to beat the incumbent by this much before the panel changes its mind.
  2130. -- A fight opening on a second front should not swing the whole ranking the moment it edges
  2131. -- ahead, and a lull on the main front should not hand the panel to whoever poked us last.
  2132. local FOCUS_SWITCH_MARGIN = 1.25
  2133. -- Below this much traded damage nobody counts as an opponent you are fighting. A scout
  2134. -- trading pot shots is not a front, and one stray shot should not redirect production.
  2135. local FOCUS_MIN_DAMAGE = 400
  2136.  
  2137. local nextRefresh = 0 -- clock of the next recompute; reset to 0 to ask for one now
  2138. local picks = {} -- ranked results, at most PICK_COUNT of them
  2139. local facPicks = {} -- facID -> defID, the best-scoring unit that factory can build itself
  2140. local emptyReason = nil -- why there are no picks, for the empty panel
  2141. local dirty = true
  2142. local waterFraction = 0
  2143.  
  2144. -- Auto-queue bookkeeping. Every order we place is recorded so it can be taken back;
  2145. -- nothing we did not place is ever touched.
  2146. local autoOrders = {} -- array of {facID, tag, defID}, orders we placed and can take back
  2147. local pendingAuto = nil -- an order issued but not yet visible in the queue
  2148. local ourTags = {} -- tag -> true, rebuilt from autoOrders each poll
  2149. local seenTags = {} -- facID -> {tag -> true} from the previous poll
  2150. local armed = false -- currently spending metal that would otherwise be wasted
  2151. local yielded = false -- the player queued something during this excess; hands off while it is queued
  2152. local playerTags = {} -- facID -> {tag -> true}, the player orders `yielded` is waiting on
  2153. -- One table rather than two locals, because this block is close to Lua's 200-local ceiling.
  2154. -- picks: the last non-empty facPicks. The ranking goes blank whenever everything seen
  2155. -- is filtered out (commanders, cons and buildings early on, say), and a blank
  2156. -- ranking used to leave every factory idle while the bar sat full.
  2157. -- refused: facID -> clock time until which it is skipped, after an order to it never
  2158. -- showed up. It stays the emptiest queue, so it used to be picked again every
  2159. -- pass and every other factory waited behind it forever.
  2160. local aqHold = {picks = {}, refused = {}}
  2161. local REFUSED_COOLDOWN = 60
  2162. local nextAutoAdd = 0
  2163.  
  2164. local PICK_COUNT = 4
  2165. local STATIC_THREAT_WEIGHT = 0.5 -- enemy buildings can be walked around, so they count for less
  2166. local WATER_THRESHOLD = 0.12 -- below this share of submerged map, ships are pointless
  2167.  
  2168. local RetractAll -- forward declaration, defined below
  2169. local SetPanelVisible -- forward declaration, defined below
  2170. local SetDebugVisible -- forward declaration, defined below
  2171. local LogRoles -- forward declaration, defined below
  2172.  
  2173. -- Everything the last ranking did, for the debug panel to read. Nil whenever the ranking
  2174. -- bailed out early, in which case `emptyReason` above is the whole story and the panel says
  2175. -- so. Only built while that panel is on: see BuildTrace.
  2176. local trace = nil
  2177.  
  2178. --------------------------------------------------------------------------------
  2179. -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
  2180. --------------------------------------------------------------------------------
  2181.  
  2182. options_path = 'Settings/HUD Panels/Build Proposer'
  2183. options_order = {'showPanel', 'iconOnly', 'debugPanel', 'updateRate', 'horizon', 'availWeight',
  2184. 'qualityWeight', 'counterStrength', 'counterSource',
  2185. 'mobileOnly', 'ignoreCommanders', 'ignoreCons', 'ignoreAA', 'ignoreAir',
  2186. 'ignoreEco', 'noBoats',
  2187. 'focusMode', 'focusBias', 'focusHarmBias', 'focusIdle', 'focusMemory',
  2188. 'dampOwnRoles', 'showFactoryTag', 'autoQueueMode', 'leadTime',
  2189. 'maxAutoQueued', 'autoQueueSkipSilos', 'autoQueueSkipAir', 'logRoles'}
  2190. options = {
  2191. showPanel = {
  2192. name = 'Show Build Proposer panel',
  2193. desc = 'Hide the on-screen list without unloading the widget. Everything behind it keeps '
  2194. .. 'running while hidden -- the ranking, the fronts it is tracking, and the '
  2195. .. 'auto-queue below, which goes on placing and retracting orders unwatched. '
  2196. .. 'WG.CounterMatrix stays published either way.',
  2197. type = 'bool',
  2198. value = true,
  2199. OnChange = function(self)
  2200. SetPanelVisible(self.value)
  2201. end,
  2202. },
  2203. iconOnly = {
  2204. name = 'Build Proposer: icons only',
  2205. desc = 'Replace the four-row list with one icon and name for every factory that has a '
  2206. .. 'unit queued, showing the best unit for that factory. With every queue empty it '
  2207. .. 'shows the single best pick instead. Hover an icon for the reasoning the list would '
  2208. .. 'have shown, click it to queue at that factory exactly as a row does. It is its own '
  2209. .. 'window, so it docks separately and keeps its own position in tweak mode.',
  2210. type = 'bool',
  2211. value = true,
  2212. OnChange = function()
  2213. SetPanelVisible(options.showPanel.value)
  2214. end,
  2215. },
  2216. debugPanel = {
  2217. name = 'Show Build Proposer debug panel',
  2218. desc = 'A second window showing the working behind the ranking rather than its result: '
  2219. .. 'which enemy units are being countered and how heavily each one weighs, the '
  2220. .. 'multiplier chain behind each of the four picks, and what the top pick is being '
  2221. .. 'charged for. Off by default, and free while off -- the breakdowns are only '
  2222. .. 'gathered when something is going to read them. Drag it in tweak mode like any '
  2223. .. 'other panel.',
  2224. type = 'bool',
  2225. value = false,
  2226. OnChange = function(self)
  2227. SetDebugVisible(self.value)
  2228. end,
  2229. },
  2230. updateRate = {
  2231. name = 'Ranking update interval (s)',
  2232. desc = 'How often the ranking is recomputed. Scoring is heavier than a plain unit count, '
  2233. .. 'so this is slower than the Enemy Intel table by default.',
  2234. type = 'number',
  2235. min = 0.25, max = 5, step = 0.25, value = 0.25,
  2236. },
  2237. horizon = {
  2238. name = 'Availability horizon (s)',
  2239. desc = 'How far ahead you care about. A unit that takes about this long to afford and '
  2240. .. 'build is scored at half weight. Lower values favour cheap units you can field now.',
  2241. type = 'number',
  2242. min = 5, max = 120, step = 5, value = 30,
  2243. OnChange = function() dirty = true end,
  2244. },
  2245. availWeight = {
  2246. name = 'Weight on availability',
  2247. desc = '0 ranks purely on how well the unit counters the enemy, ignoring whether you can '
  2248. .. 'afford it. Higher values push the ranking towards what you can actually field soon.',
  2249. type = 'number',
  2250. min = 0, max = 2, step = 0.1, value = 0,
  2251. OnChange = function() dirty = true end,
  2252. },
  2253. qualityWeight = {
  2254. name = 'Weight: pick quality',
  2255. desc = 'How much a unit being generally good counts, on top of how well it happens to '
  2256. .. 'counter what the enemy has. Two units can sit in the same square of the matrix '
  2257. .. 'and still not be worth the same metal, and this is the tier list that says so. '
  2258. .. '0 ignores it entirely and ranks on counter-picking alone; 1 makes a tier worth '
  2259. .. 'about a sixth of a matchup step. Raise it to be told what is good rather than '
  2260. .. 'what is clever. Unrated units are unaffected either way.',
  2261. type = 'number',
  2262. min = 0, max = 3, step = 0.1, value = 0,
  2263. OnChange = function() dirty = true end,
  2264. },
  2265. counterStrength = {
  2266. name = 'Counter-presence penalty',
  2267. desc = 'How much the mere presence of a hard counter drags a pick down across the whole '
  2268. .. 'enemy army, not just against those units. A riot screen protects everything '
  2269. .. 'behind it from your raiders. 0 turns this off and scores each matchup on its own.',
  2270. type = 'number',
  2271. min = 0, max = 2, step = 0.1, value = 1,
  2272. OnChange = function() dirty = true end,
  2273. },
  2274. counterSource = {
  2275. name = 'Counter presence reads',
  2276. desc = 'What the penalty above looks at. Archetype columns is the blunt reading: a pick '
  2277. .. 'is held back by whatever share of the enemy army sits in a column its own '
  2278. .. 'archetype row scores badly against, whichever units those happen to be. The '
  2279. .. 'matrix reading puts every enemy unit type to the counter matrix instead, in '
  2280. .. 'both directions, so a single named counter standing on the field -- an Outlaw '
  2281. .. 'against your raiders, a Racketeer against your shield ball -- holds a pick back '
  2282. .. 'even when its archetype is fine, while an enemy that cannot shoot your pick at '
  2283. .. 'all stops holding it back at all. It is graded rather than counted, so at the '
  2284. .. 'same setting of the slider above it damps more gently than the columns do; '
  2285. .. 'raise that slider if you preferred the harder version. Set it to 0 to turn '
  2286. .. 'either reading off.',
  2287. type = 'radioButton',
  2288. value = 'matrix',
  2289. items = {
  2290. {
  2291. key = 'matrix',
  2292. name = 'Every enemy unit, through the matrix',
  2293. desc = 'Each enemy unit type is looked up against your pick in the counter '
  2294. .. 'matrix, and the ones that beat it drag it down in proportion to how '
  2295. .. 'much of the enemy army they are and how badly they beat it. Sees the '
  2296. .. 'specific counters that a per-archetype reading cannot -- an Outlaw '
  2297. .. 'against your raiders, a Racketeer against your shield ball -- and sees '
  2298. .. 'that the wall of anti air in front of you is not holding your tanks '
  2299. .. 'back. Every pairing gets an answer, because a row that does not name '
  2300. .. 'the target falls back to its column for that class.',
  2301. },
  2302. {
  2303. key = 'arch',
  2304. name = 'Archetype columns only',
  2305. desc = 'Count the share of the enemy army sitting in the columns this pick\'s '
  2306. .. 'archetype row scores at or below 0.55, and damp by that share. Coarser '
  2307. .. 'and blind to which particular unit it is, reading only the class the '
  2308. .. 'target belongs to rather than the target.',
  2309. },
  2310. },
  2311. OnChange = function() dirty = true end,
  2312. },
  2313. mobileOnly = {
  2314. name = 'Counter mobile units only',
  2315. desc = 'Ignore enemy buildings when ranking, so the picks answer their army rather than '
  2316. .. 'their turrets and economy. Static anti-air still counts against air picks either '
  2317. .. 'way, since it shoots you whether or not you meant to attack it.',
  2318. type = 'bool',
  2319. value = true,
  2320. OnChange = function() dirty = true end,
  2321. },
  2322. ignoreCons = {
  2323. name = 'Ignore enemy constructors',
  2324. desc = 'Leave enemy constructors out of the army being answered. They are not a fight, '
  2325. .. 'and everything beats them, so a ranking that counts them drifts towards whatever '
  2326. .. 'is quickest at chasing builders rather than towards what wins the engagement. '
  2327. .. 'Leave it off if hunting their expansion is the plan, since raiders score well '
  2328. .. 'here for exactly that reason.',
  2329. type = 'bool',
  2330. value = true,
  2331. OnChange = function() dirty = true end,
  2332. },
  2333. ignoreAA = {
  2334. name = 'Ignore enemy anti-air',
  2335. desc = 'Leave enemy anti-air out of the army being answered -- the archetype that only '
  2336. .. 'shoots aircraft, mobile and turret alike. It cannot touch a ground army, so if '
  2337. .. 'you are not flying it is metal that will never trade with you, and counting it '
  2338. .. 'only dilutes the units that will. Fighters are a separate archetype and are not '
  2339. .. 'affected. This does not make the sky look emptier: whether an air push is worth '
  2340. .. 'trying is still judged against every anti-air they own, on the same reasoning as '
  2341. .. 'the mobile-only setting above.',
  2342. type = 'bool',
  2343. value = true,
  2344. OnChange = function() dirty = true end,
  2345. },
  2346. ignoreAir = {
  2347. name = 'Ignore enemy air',
  2348. desc = 'Leave enemy aircraft out of the army being answered -- gunships, planes and '
  2349. .. 'bombers, and the fighters and air constructors with them. Most of a ground '
  2350. .. 'army cannot shoot up at all, so a handful of gunships scouted across the map '
  2351. .. 'carries far more of the ranking than it should and pulls the picks towards '
  2352. .. 'anti-air that the rest of their army then walks straight past. Turn this on '
  2353. .. 'when their air is for somebody else to answer -- static anti-air you are placing '
  2354. .. 'yourself, or an ally holding the sky -- and off whenever meeting it with your '
  2355. .. 'army is the plan. It does not make your own air picks look safer: whether an '
  2356. .. 'air push is worth trying is still judged against every anti-air they own, on '
  2357. .. 'the same reasoning as the settings above.',
  2358. type = 'bool',
  2359. value = true,
  2360. OnChange = function() dirty = true end,
  2361. },
  2362. ignoreEco = {
  2363. name = 'Ignore enemy economy buildings',
  2364. desc = 'Leave unarmed enemy buildings out of the army being answered -- mexes, solars, '
  2365. .. 'storage, radar, the whole undefended half of their base -- while their turrets '
  2366. .. 'still count. The narrower half of "counter mobile units only": that one drops '
  2367. .. 'their static defence too, this one keeps it, so the picks still have to get past '
  2368. .. 'a Stinger while no longer being scored on how fast they flatten a solar farm.',
  2369. type = 'bool',
  2370. value = false,
  2371. OnChange = function() dirty = true end,
  2372. },
  2373. ignoreCommanders = {
  2374. name = 'Ignore enemy commanders',
  2375. desc = 'Leave enemy commanders out of the army being answered. A commander is 1200 metal '
  2376. .. 'that is always on the field, so early on it can be most of the enemy metal you '
  2377. .. 'have scouted, and the ranking answers it rather than the army coming at you -- '
  2378. .. 'anti-heavy and assassination picks at the top while their raiders are the actual '
  2379. .. 'problem. Turn this on when you have no intention of going for the commander, off '
  2380. .. 'when killing it is the plan. Enemy anti-air is still judged across everything they '
  2381. .. 'own either way, commanders included, on the same reasoning as the setting above.',
  2382. type = 'bool',
  2383. value = true,
  2384. OnChange = function() dirty = true end,
  2385. },
  2386. focusMode = {
  2387. name = 'Whose army to answer',
  2388. desc = 'In a team game the enemy side owns far more than you will ever meet. Countering '
  2389. .. 'all of it builds against three players you are not fighting; countering only the '
  2390. .. 'front you are on builds against the army actually coming at you. Who you are '
  2391. .. 'fighting is worked out from damage traded, ours dealt and ours taken alike; who '
  2392. .. 'is hurting you from their half of that alone. Both fade out over the memory '
  2393. .. 'below. Falls back to the whole enemy side whenever no fight has been seen yet, '
  2394. .. 'and does nothing at all in a 1v1.',
  2395. type = 'radioButton',
  2396. value = 'balanced',
  2397. items = {
  2398. {
  2399. key = 'all',
  2400. name = 'Everyone',
  2401. desc = 'Every enemy unit Enemy Intel remembers, whoever owns it.',
  2402. },
  2403. {
  2404. key = 'fighting',
  2405. name = 'Players you are fighting',
  2406. desc = 'Only the armies of the opponents you have actually traded damage with, '
  2407. .. 'each weighted by how much damage you have traded with them, so the front '
  2408. .. 'you are fighting hardest shapes the ranking most. The right answer when '
  2409. .. 'you are holding against two of them at once, or when an ally is fighting '
  2410. .. 'someone you never meet.',
  2411. },
  2412. {
  2413. key = 'balanced',
  2414. name = 'Fighting, balanced against who hurts you',
  2415. desc = 'The same opponents as above, but each army weighted by a blend of the '
  2416. .. 'damage you have traded with it and the damage it has landed on you, '
  2417. .. 'mixed by the second slider below. The front you are busiest on and the '
  2418. .. 'front that is actually hurting you are rarely the same player: a fight '
  2419. .. 'you are winning eats most of your shots and none of your army. This '
  2420. .. 'answers both in proportion instead of picking one.',
  2421. },
  2422. {
  2423. key = 'main',
  2424. name = 'The player you are fighting most',
  2425. desc = 'Only the army of the single opponent you have traded the most damage '
  2426. .. 'with. The sharpest counter-pick, and the one that swings hardest when '
  2427. .. 'the fight moves to a different player.',
  2428. },
  2429. {
  2430. key = 'threat',
  2431. name = 'The player hurting you most',
  2432. desc = 'Only the army of the single opponent that has dealt the most damage to '
  2433. .. 'your units, ignoring everything you have dealt back. Answers whoever is '
  2434. .. 'actually killing your things rather than whoever you have spent the most '
  2435. .. 'shots on, which is the question worth asking while you are losing '
  2436. .. 'ground. Falls back to the whole enemy side until something has hurt you.',
  2437. },
  2438. {
  2439. key = 'hurting',
  2440. name = 'Every player hurting you, in proportion',
  2441. desc = 'Every opponent that has landed damage on you, each weighted by its share '
  2442. .. 'of that damage, so two players grinding you down are both answered and '
  2443. .. 'the one hurting you twice as hard counts twice as much. The spread '
  2444. .. 'version of the mode above: it will not throw the whole ranking at one '
  2445. .. 'player the moment they edge ahead, while a front you are shelling for '
  2446. .. 'free still shapes nothing. An opponent you have traded shots with but '
  2447. .. 'taken no damage from drops out entirely.',
  2448. },
  2449. {
  2450. key = 'attackers',
  2451. name = 'Only the units that have shot you',
  2452. desc = 'Forget players. Rank against the individual enemy units that have '
  2453. .. 'actually damaged you, each weighted by how much damage it has done, and '
  2454. .. 'drop each one the moment it dies. The narrowest answer there is: even a '
  2455. .. 'single opponent owns far more than has ever met you, so this counters the '
  2456. .. 'Ripper killing your Glaives rather than the Ripper company parked across '
  2457. .. 'the map, and it forgets a threat as soon as you have killed it. The only '
  2458. .. 'mode here that does something in a 1v1. Enemy anti-air is still judged '
  2459. .. 'against the whole armies behind those units, because AA that has not shot '
  2460. .. 'you yet is exactly the AA that kills your first gunship.',
  2461. },
  2462. },
  2463. OnChange = function() dirty = true end,
  2464. },
  2465. focusBias = {
  2466. name = 'Bias towards the main front',
  2467. desc = 'Only used by the modes that spread across several opponents: the two "fighting" '
  2468. .. 'ones and "Every player hurting you". How hard the ranking leans towards the front '
  2469. .. 'leading their tally. 0 weighs every front you are on equally. 1 weighs '
  2470. .. 'each army by its share of that tally, so a front counting half as much shapes '
  2471. .. 'the ranking half as much. Higher values push further towards answering the '
  2472. .. 'leading fight alone.',
  2473. type = 'number',
  2474. min = 0, max = 3, step = 0.1, value = 1,
  2475. OnChange = function() dirty = true end,
  2476. },
  2477. focusHarmBias = {
  2478. name = 'Weigh damage taken over damage traded',
  2479. desc = 'Only used by "Fighting, balanced against who hurts you". 0 ranks the fronts '
  2480. .. 'purely by damage traded, which is what "Players you are fighting" does. 1 ranks '
  2481. .. 'them purely by the damage they have landed on you, so a front you are shelling '
  2482. .. 'for free stops shaping the picks and one that is quietly grinding you down '
  2483. .. 'starts. In between, every front is scored on both, each as a share of the front '
  2484. .. 'leading it.',
  2485. type = 'number',
  2486. min = 0, max = 1, step = 0.05, value = 0.5,
  2487. OnChange = function() dirty = true end,
  2488. },
  2489. focusIdle = {
  2490. name = 'Drop a quiet front after (s)',
  2491. desc = 'An opponent you have not traded a shot with for this long stops counting '
  2492. .. 'towards the ranking entirely. A fight you won and walked away from should not '
  2493. .. 'keep shaping what you build. 0 never drops a front and leaves the memory '
  2494. .. 'half-life below to fade it out on its own. In "Only the units that have shot '
  2495. .. 'you" the same rule applies one level down, to each individual unit.',
  2496. type = 'number',
  2497. min = 0, max = 900, step = 30, value = 300,
  2498. OnChange = function() dirty = true end,
  2499. },
  2500. focusMemory = {
  2501. name = 'Engagement memory (s)',
  2502. desc = 'Half-life of the damage tally that decides who you are fighting. A front you '
  2503. .. 'stop fighting fades to half its weight over this long, so the panel follows the '
  2504. .. 'battle rather than remembering a skirmish from ten minutes ago. Fades the damage '
  2505. .. 'taken alongside it, and the per-unit tally behind the units mode with it. Only '
  2506. .. 'used by the focused modes above.',
  2507. type = 'number',
  2508. min = 30, max = 600, step = 15, value = 120,
  2509. OnChange = function() dirty = true end,
  2510. },
  2511. dampOwnRoles = {
  2512. name = 'Damp roles you already have',
  2513. desc = 'Off by default: the four weights are a pure counter ranking, so near-identical '
  2514. .. 'picks can appear together. Turn on to push the list towards a mixed army instead.',
  2515. type = 'bool',
  2516. value = false,
  2517. OnChange = function() dirty = true end,
  2518. },
  2519. showFactoryTag = {
  2520. name = 'Show factory tag',
  2521. desc = 'Label each pick with the factory that would build it.',
  2522. type = 'bool',
  2523. value = false,
  2524. OnChange = function() dirty = true end,
  2525. },
  2526. autoQueueMode = {
  2527. name = 'Build the proposals automatically',
  2528. type = 'radioButton',
  2529. value = 'excess',
  2530. items = {
  2531. {
  2532. key = 'off',
  2533. name = 'Off',
  2534. desc = 'The panel only proposes. Nothing is ever queued unless you click it.',
  2535. },
  2536. {
  2537. key = 'excess',
  2538. name = 'Only when excessing metal',
  2539. desc = 'When your income is about to fill metal storage, quietly queue at every '
  2540. .. 'factory, each building the best answer it can, so the metal is not '
  2541. .. 'wasted. Once the bar comes back down, anything of ours not yet started '
  2542. .. 'is pulled, so only overflow is ever spent. The moment you queue anything '
  2543. .. 'yourself, our orders are pulled at every factory and nothing more is '
  2544. .. 'added until what you queued is built or cancelled, or the next time '
  2545. .. 'metal overflows. Orders you placed are never touched.',
  2546. },
  2547. {
  2548. key = 'always',
  2549. name = 'Continuously, at every factory',
  2550. desc = 'Keep every factory working on the best answer it can build itself, whatever '
  2551. .. 'the metal situation. Each factory is topped up to the queue length below, '
  2552. .. 'counting your own orders, so anything you queue is built first and we only '
  2553. .. 'fill what is left.',
  2554. },
  2555. },
  2556. OnChange = function(self)
  2557. -- The player-order watch keeps a snapshot per factory, and a snapshot taken
  2558. -- under another mode is not something to draw conclusions from.
  2559. seenTags = {}
  2560. yielded = false
  2561. playerTags = {}
  2562. if self.value == 'off' then
  2563. armed = false
  2564. if RetractAll then RetractAll() end
  2565. end
  2566. end,
  2567. },
  2568. leadTime = {
  2569. name = 'Start this long before excess (s)',
  2570. desc = 'Begin queueing once the current net income would fill the remaining metal '
  2571. .. 'storage within this many seconds, rather than waiting for the bar to cap.',
  2572. type = 'number',
  2573. min = 0, max = 30, step = 1, value = 30,
  2574. },
  2575. maxAutoQueued = {
  2576. name = 'Queue depth',
  2577. desc = 'Per factory, either way. When excessing: how many orders of our own each '
  2578. .. 'factory may have outstanding at once. Continuously: how deep each factory '
  2579. .. 'queue is kept, your own orders included.',
  2580. type = 'number',
  2581. min = 1, max = 20, step = 1, value = 6,
  2582. },
  2583. noBoats = {
  2584. name = 'Build Proposer: never propose boats',
  2585. desc = 'Leave ships and submarines out of the ranking altogether, so they are never '
  2586. .. 'proposed and never auto-queued. Hovercraft and amphibious units are not boats '
  2587. .. 'and stay in. A Shipyard with nothing else to build gets no pick. On a map too '
  2588. .. 'dry to float anything, boats are left out anyway.',
  2589. type = 'bool',
  2590. value = false,
  2591. OnChange = function() dirty = true end,
  2592. },
  2593. autoQueueSkipSilos = {
  2594. name = 'Auto-queue: skip missile silos',
  2595. desc = 'Keep the auto-queue off the Missile Silo. A silo shot is a decision about when '
  2596. .. 'and where to spend it rather than about what should be coming off a line, and '
  2597. .. 'filler that quietly stocks four warheads out of an overflow has taken that '
  2598. .. 'decision away from you. The panel still ranks silo munitions like anything '
  2599. .. 'else -- this only stops them being queued on your behalf. Orders of ours '
  2600. .. 'already sitting at a silo are taken back when you switch this on, bar one '
  2601. .. 'already being built.',
  2602. type = 'bool',
  2603. value = true,
  2604. OnChange = function() dirty = true end,
  2605. },
  2606. autoQueueSkipAir = {
  2607. name = 'Auto-queue: skip air factories',
  2608. desc = 'Keep the auto-queue off the Airplane Plant and the Gunship Plant, and off '
  2609. .. 'their plates. Air is a commitment rather than a unit -- it wants to be massed, '
  2610. .. 'flown together and kept away from anti-air -- so a plane queued on its own out '
  2611. .. 'of an overflow usually flies off and dies alone. Leaves the rest of the base '
  2612. .. 'auto-building as before. Same as above: they are still proposed, just never '
  2613. .. 'queued for you, and orders of ours already at such a plant are taken back.',
  2614. type = 'bool',
  2615. value = true,
  2616. OnChange = function() dirty = true end,
  2617. },
  2618. logRoles = {
  2619. name = 'Log unit roles to console',
  2620. desc = 'Prints how every unit your factories can build has been classified, and marks '
  2621. .. 'the ones the icon and the curated list disagree about. For checking the model.',
  2622. type = 'button',
  2623. OnChange = function()
  2624. if LogRoles then LogRoles() end
  2625. end,
  2626. },
  2627. }
  2628.  
  2629. --------------------------------------------------------------------------------
  2630. -- Economy
  2631. --
  2632. -- Spring reports a storage figure inflated by Zero-K's hidden buffer (HIDDEN_STORAGE in
  2633. -- LuaRules/Configs/constants.lua), and every Zero-K panel subtracts it before showing or
  2634. -- comparing anything. Using the raw figure is why this never fired: with 1500 real metal
  2635. -- storage the bar caps at 1500, but GetTeamResources reports 11500.
  2636. --------------------------------------------------------------------------------
  2637.  
  2638. local HIDDEN_STORAGE = 10000
  2639. local MIN_STORAGE = 0.5
  2640. -- How full the bar has to stay for an arming to hold. Arming reads the net rate, but the
  2641. -- rate stops being a fair reading of the surplus the moment we act on it, so what we are
  2642. -- already sitting on is what keeps us armed. See UpdateArmed.
  2643. local EXCESS_HOLD = 0.9
  2644.  
  2645. -- Returns current metal, real storage, and the net rate, all as the economy panel sees them.
  2646. local function GetMetalState()
  2647. local mCurr, mStor, mPull, mInco, _, _, _, mReci = spGetTeamResources(myTeamID, "metal")
  2648. if not mCurr then
  2649. return nil
  2650. end
  2651. -- Zero-K charges some pull outside the engine's own accounting.
  2652. mPull = (mPull or 0) + (spGetTeamRulesParam(myTeamID, "extraMetalPull") or 0)
  2653. local stor = math.max((mStor or 0) - HIDDEN_STORAGE, MIN_STORAGE)
  2654. if mCurr > stor then
  2655. mCurr = stor
  2656. end
  2657. return mCurr, stor, (mInco or 0) - mPull + (mReci or 0)
  2658. end
  2659.  
  2660. -- True when storage is full, or when the current net rate would fill what is left of it
  2661. -- within `lead` seconds. Waiting for the bar to actually cap means the first metal is
  2662. -- already wasted by the time anything is queued, hence starting early.
  2663. local function ProjectedToExcess(lead)
  2664. local cur, stor, net = GetMetalState()
  2665. if not cur or net <= 0 then
  2666. return false
  2667. end
  2668. return (stor - cur) <= net * lead
  2669. end
  2670.  
  2671. --------------------------------------------------------------------------------
  2672. -- Who we are fighting
  2673. --
  2674. -- `UnitDamaged` reaches a widget for its own units always and for enemy units in sight,
  2675. -- which between them covers every exchange we are part of: our shots land on units we can
  2676. -- see by definition, and shots landing on us are ours to see either way. Damage dealt by
  2677. -- an ally to someone we never meet is invisible here, which is exactly the intent -- the
  2678. -- question is which front *we* are on, not which fronts exist.
  2679. --------------------------------------------------------------------------------
  2680.  
  2681. local function RebuildEnemyTeams()
  2682. enemyTeams = {}
  2683. local gaia = Spring.GetGaiaTeamID()
  2684. local teams = spGetTeamList()
  2685. if not teams then
  2686. return
  2687. end
  2688. for i = 1, #teams do
  2689. local t = teams[i]
  2690. if t ~= gaia then
  2691. local _, _, _, _, _, allyTeam = spGetTeamInfo(t, false)
  2692. if allyTeam and allyTeam ~= myAllyTeamID then
  2693. enemyTeams[t] = true
  2694. end
  2695. end
  2696. end
  2697. end
  2698.  
  2699. -- Zero-K's own convention: a team is named after its leader, or after the AI running it.
  2700. local function TeamName(teamID)
  2701. local _, leader, _, isAI = spGetTeamInfo(teamID, false)
  2702. local name
  2703. if isAI then
  2704. name = select(2, spGetAIInfo(teamID))
  2705. elseif leader then
  2706. name = spGetPlayerInfo(leader, false)
  2707. end
  2708. return name or ("Team " .. tostring(teamID))
  2709. end
  2710.  
  2711. -- Damage fades rather than accumulating for the game, so the panel follows the battle
  2712. -- instead of remembering a skirmish from ten minutes ago.
  2713. local function DecayEngagement(now)
  2714. local dt = now - lastDecay
  2715. if dt <= 0 then
  2716. return
  2717. end
  2718. lastDecay = now
  2719. local half = options.focusMemory.value
  2720. if half <= 0 then
  2721. return
  2722. end
  2723. local keep = 0.5 ^ (dt / half)
  2724. for team, v in pairs(engagement) do
  2725. v = v * keep
  2726. engagement[team] = (v >= 1) and v or nil
  2727. end
  2728. for team, v in pairs(harm) do
  2729. v = v * keep
  2730. harm[team] = (v >= 1) and v or nil
  2731. end
  2732. -- The per-unit tally fades on the same half-life and for the same reason: the Ripper
  2733. -- that clipped you once four minutes ago is not what you are building against now. It is
  2734. -- also the only way a unit that died out of sight ever leaves the books, since a death
  2735. -- we did not witness never reaches UnitDestroyed.
  2736. for uID, rec in pairs(attackers) do
  2737. local v = rec.dmg * keep
  2738. if v >= 1 then
  2739. rec.dmg = v
  2740. else
  2741. attackers[uID] = nil
  2742. end
  2743. end
  2744. end
  2745.  
  2746. -- The tally a mode ranks opponents by.
  2747. --
  2748. -- "Fighting" and "fought most" ask who the shooting is between, so they read the traded
  2749. -- total. The two modes that ask who is winning it -- "hurting you most" and "every player
  2750. -- hurting you" -- read only what has landed on us. The balanced mode scores every front on
  2751. -- both, each as a share of the front leading that tally, and mixes them by the slider: an
  2752. -- opponent who has hurt you badly still counts when most of your own shooting has gone
  2753. -- somewhere else, and one you are shelling for free stops drowning them out. The blend is
  2754. -- rescaled back into damage at the end so the thresholds it meets still mean what they say.
  2755. local function FocusMetric(mode, fought)
  2756. if mode == 'threat' or mode == 'hurting' then
  2757. return harm
  2758. end
  2759. if mode ~= 'balanced' then
  2760. return engagement
  2761. end
  2762.  
  2763. local tradedMax, harmMax = 0, 0
  2764. for team in pairs(fought) do
  2765. local t, h = engagement[team] or 0, harm[team] or 0
  2766. if t > tradedMax then tradedMax = t end
  2767. if h > harmMax then harmMax = h end
  2768. end
  2769. local b = options.focusHarmBias.value
  2770. local blended = {}
  2771. for team in pairs(fought) do
  2772. local t = (tradedMax > 0) and ((engagement[team] or 0) / tradedMax) or 0
  2773. local h = (harmMax > 0) and ((harm[team] or 0) / harmMax) or 0
  2774. blended[team] = ((1 - b) * t + b * h) * tradedMax
  2775. end
  2776. return blended
  2777. end
  2778.  
  2779. -- The units mode, which answers a hand-assembled army rather than anybody's roster.
  2780. --
  2781. -- Downstream the ranking weighs each enemy type by `count * cost`, so damage is turned back
  2782. -- into a count by dividing the cost out again: a type that has done twice the damage then
  2783. -- carries twice the weight whatever it costs, which is what "in proportion to how much it
  2784. -- hurt you" has to mean. Weighing the survivors by cost instead would say a Detriment that
  2785. -- has grazed you matters more than the Ripper company taking your front apart.
  2786. --
  2787. -- Teams are still worked out, but only so the header and the tooltip can say whose units
  2788. -- these are, and so the anti-air check in Recompute has whole armies to look at.
  2789. local function AttackerFocus(incumbent)
  2790. local units, total = {}, 0
  2791. for _, rec in pairs(attackers) do
  2792. local c = cost[rec.def]
  2793. if c and c > 0 then
  2794. units[rec.def] = (units[rec.def] or 0) + rec.dmg / c
  2795. total = total + rec.dmg
  2796. end
  2797. end
  2798. -- The same floor the player modes use, for the same reason: one stray shot is not a
  2799. -- reason to redirect production.
  2800. if total < FOCUS_MIN_DAMAGE then
  2801. focusNote = (total > 0) and "nothing has hurt you much yet" or "nothing has hurt you yet"
  2802. return
  2803. end
  2804. focusUnits = units
  2805.  
  2806. local perTeam, best, bestVal = {}, nil, 0
  2807. for _, rec in pairs(attackers) do
  2808. perTeam[rec.team] = (perTeam[rec.team] or 0) + rec.dmg
  2809. end
  2810. for team, v in pairs(perTeam) do
  2811. if v > bestVal then
  2812. best, bestVal = team, v
  2813. end
  2814. end
  2815. local held = incumbent and perTeam[incumbent]
  2816. if held and best ~= incumbent and bestVal <= held * FOCUS_SWITCH_MARGIN then
  2817. best = incumbent
  2818. end
  2819. focusMain = best
  2820.  
  2821. focusTeams, focusWeights = {}, {}
  2822. for team, v in pairs(perTeam) do
  2823. focusTeams[team] = true
  2824. focusWeights[team] = v / bestVal
  2825. end
  2826. end
  2827.  
  2828. -- Decides whose army the ranking answers this pass. Sets `focusTeams` to nil whenever the
  2829. -- answer is "everyone", which is also what every failure falls back to: a panel that
  2830. -- proposes against the whole enemy side is merely blunt, one that proposes against nothing
  2831. -- is useless.
  2832. local function UpdateFocus()
  2833. local mode = options.focusMode.value
  2834. local incumbent = focusMain -- read before the reset below; the hysteresis needs it
  2835. focusTeams, focusWeights, focusUnits, focusMain, focusNote = nil, nil, nil, nil, nil
  2836. if mode == 'all' then
  2837. return
  2838. end
  2839.  
  2840. -- A front that has gone quiet stops counting outright, rather than fading for as long
  2841. -- as the half-life takes. A fight you won ten minutes ago and walked away from says
  2842. -- nothing about what to build now, however hard it was at the time.
  2843. local idle = options.focusIdle.value
  2844. if idle > 0 then
  2845. for team, when in pairs(lastFought) do
  2846. if clockNow - when > idle then
  2847. engagement[team] = nil
  2848. harm[team] = nil
  2849. lastFought[team] = nil
  2850. end
  2851. end
  2852. -- The same rule one level down: a unit that shot you once and has left you alone
  2853. -- since is no more part of the fight than a player who has.
  2854. for uID, rec in pairs(attackers) do
  2855. if clockNow - rec.last > idle then
  2856. attackers[uID] = nil
  2857. end
  2858. end
  2859. end
  2860.  
  2861. -- Deliberately ahead of the head count below. Counting single units narrows the ranking
  2862. -- in a 1v1 just as much as in a team game -- one opponent still owns far more than has
  2863. -- ever met you -- so the "nothing to narrow to" shortcut does not apply here.
  2864. if mode == 'attackers' then
  2865. AttackerFocus(incumbent)
  2866. return
  2867. end
  2868.  
  2869. -- With a single opponent the modes are the whole enemy side anyway, and saying so is
  2870. -- better than waiting for first blood to say the same thing.
  2871. local only, count = nil, 0
  2872. for t in pairs(enemyTeams) do
  2873. count = count + 1
  2874. only = t
  2875. end
  2876. if count == 1 then
  2877. focusMain = only
  2878. return
  2879. end
  2880. if count == 0 then
  2881. return -- spectating a finished game, or teams not resolved yet
  2882. end
  2883.  
  2884. -- Which teams are on a front at all is one question for every mode, and the traded total
  2885. -- answers it: a mode that ranks by damage taken still should not count a player we have
  2886. -- never met. Damage taken is part of the traded total, so nothing any mode cares about
  2887. -- can qualify on its own tally without qualifying here first.
  2888. local fought, anyFought = {}, false
  2889. for team, v in pairs(engagement) do
  2890. if v >= FOCUS_MIN_DAMAGE then
  2891. fought[team], anyFought = true, true
  2892. end
  2893. end
  2894. if not anyFought then
  2895. focusNote = "no fight seen yet"
  2896. return
  2897. end
  2898.  
  2899. local metric = FocusMetric(mode, fought)
  2900. local best, bestVal = nil, 0
  2901. for team in pairs(fought) do
  2902. local v = metric[team] or 0
  2903. if v > bestVal then
  2904. best, bestVal = team, v
  2905. end
  2906. end
  2907. if not best then
  2908. -- Shots have been traded, but nothing has landed on us, so the modes that rank by
  2909. -- damage taken have nothing to rank yet. Answering the whole enemy side beats picking
  2910. -- a front out of a tally that is all zeroes.
  2911. focusNote = "nothing has hurt you yet"
  2912. return
  2913. end
  2914.  
  2915. -- Hysteresis on the leader only. Which teams qualify at all is a plain threshold: a
  2916. -- front either has a fight on it or it does not, and there is nothing to flip between.
  2917. local held = incumbent and metric[incumbent]
  2918. if held and best ~= incumbent and bestVal <= held * FOCUS_SWITCH_MARGIN then
  2919. best = incumbent
  2920. end
  2921. focusMain = best
  2922.  
  2923. if mode == 'main' or mode == 'threat' then
  2924. focusTeams, focusWeights = {[best] = true}, {[best] = 1}
  2925. return
  2926. end
  2927.  
  2928. -- Every front you are on counts, but not equally: each army is weighted by its share of
  2929. -- this mode's tally, so two fronts are answered in the proportion you are actually
  2930. -- fighting them rather than as one undifferentiated enemy. Shares are taken against the
  2931. -- heaviest front rather than the hysteresis pick, so the weights follow the damage even
  2932. -- in the moment the header has not caught up yet. A front scoring zero on the tally is
  2933. -- left out entirely, which is how the blend at its extreme drops a fight you are taking
  2934. -- nothing from.
  2935. local bias = options.focusBias.value
  2936. focusTeams, focusWeights = {}, {}
  2937. for team in pairs(fought) do
  2938. local v = metric[team] or 0
  2939. if v > 0 or team == best then
  2940. focusTeams[team] = true
  2941. focusWeights[team] = (bias > 0) and ((v / bestVal) ^ bias) or 1
  2942. end
  2943. end
  2944. end
  2945.  
  2946. function M:UnitDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, _, _,
  2947. attackerID, attackerDefID, attackerTeam)
  2948. -- Tallied whatever the mode is set to, so that turning a focused mode on mid-game
  2949. -- narrows the ranking immediately rather than waiting for the next fight to start.
  2950. if not damage or damage <= 0 or not unitTeam then
  2951. return
  2952. end
  2953.  
  2954. local enemy, theirShot
  2955. if unitTeam == myTeamID then
  2956. -- what is shooting at us; nil when the shooter is out of sight
  2957. enemy, theirShot = attackerTeam, true
  2958. elseif attackerTeam == myTeamID then
  2959. enemy = unitTeam -- what we are shooting at
  2960. end
  2961. if not enemy or not enemyTeams[enemy] then
  2962. return
  2963. end
  2964.  
  2965. -- Paralyser numbers are large and kill nothing, and overkill is reported in full, so a
  2966. -- Detriment shot at a Glaive would otherwise register as a battle on its own.
  2967. if paralyzer then
  2968. damage = damage * 0.5
  2969. end
  2970. local hp = unitDefID and health[unitDefID]
  2971. if hp and damage > hp then
  2972. damage = hp
  2973. end
  2974.  
  2975. engagement[enemy] = (engagement[enemy] or 0) + damage
  2976. if theirShot then
  2977. harm[enemy] = (harm[enemy] or 0) + damage
  2978. -- The shooter itself, for the units mode. Both are nil when the shot came from
  2979. -- something we cannot see, which is the same blind spot that leaves `attackerTeam`
  2980. -- nil above: an unseen shooter is simply not on anybody's list.
  2981. if attackerID and attackerDefID then
  2982. local rec = attackers[attackerID]
  2983. if rec and rec.def == attackerDefID then
  2984. rec.dmg = rec.dmg + damage
  2985. rec.last = clockNow
  2986. else
  2987. -- No matching record, or the engine has recycled that unitID onto a new unit.
  2988. attackers[attackerID] = {def = attackerDefID, team = enemy,
  2989. dmg = damage, last = clockNow}
  2990. end
  2991. end
  2992. end
  2993. lastFought[enemy] = clockNow
  2994. end
  2995.  
  2996. -- What makes the units mode a different question from every mode above it: a dead unit is
  2997. -- not something to build against, so it leaves the tally outright rather than fading.
  2998. --
  2999. -- This only reaches us for enemies visible as they die, which is nearly always true of
  3000. -- something that has been shooting at us. Anything that dies out of sight decays out on the
  3001. -- engagement half-life instead, which is slower but gets there.
  3002. function M:UnitDestroyed(unitID)
  3003. attackers[unitID] = nil
  3004. end
  3005.  
  3006. --------------------------------------------------------------------------------
  3007. -- Scoring
  3008. --------------------------------------------------------------------------------
  3009.  
  3010. -- The archetype an enemy presents to the matrix. A building is a position rather than a
  3011. -- unit -- it can neither chase nor retreat -- so an armed one is answered as static
  3012. -- defence and an unarmed one is just economy standing in the open.
  3013. local function ColumnArch(enemyDef)
  3014. if isStatic[enemyDef] then
  3015. return isArmed[enemyDef] and "defense" or "eco"
  3016. end
  3017. return role[enemyDef]
  3018. end
  3019.  
  3020. -- One lookup, no arithmetic. The number in the table is the answer.
  3021. --
  3022. -- Both sides go in under `matrixName` rather than `defName`, which differs only for
  3023. -- commanders: all seventy-two of them answer to one of four chassis rows.
  3024. --
  3025. -- `col` is only reached for a target the table does not name -- an unlisted building, a
  3026. -- chicken, a drone, a unit added after the table was generated -- and picks the row's
  3027. -- fallback column for that class. A row we have never heard of at all comes back even.
  3028. local function MatrixValue(myDef, enemyDef, col)
  3029. local row = MATRIX[matrixName[myDef]]
  3030. if not row then
  3031. return NEUTRAL
  3032. end
  3033. return row[matrixName[enemyDef]] or row["#" .. col] or NEUTRAL
  3034. end
  3035.  
  3036. -- The tier multiplier for a unit, independent of what it is being scored against.
  3037. -- Unrated units come back 1.0, so this can only ever move a pick relative to its peers.
  3038. local function Quality(defID)
  3039. return QUALITY[defName[defID]] or 1
  3040. end
  3041.  
  3042. -- Can this even be a fight? No entry in the matrix means anything against a target you
  3043. -- have no weapon for. Both of the terms below gate on this, in opposite directions: our
  3044. -- pick shooting the enemy, and the enemy shooting our pick.
  3045. local function CanEngage(shooter, target)
  3046. if isAir[target] then
  3047. return dpsAir[shooter] > 0
  3048. elseif isSub[target] then
  3049. return dpsWater[shooter] > 0
  3050. end
  3051. return dpsGround[shooter] > 0
  3052. end
  3053.  
  3054. -- The whole proposition calculation. One lookup in the counter matrix, gated on whether the
  3055. -- fight can happen at all. There is no second term: cost, HP and DPS decide nothing here,
  3056. -- only what counters what.
  3057. local function PairScore(myDef, enemyDef, enemyAAShare)
  3058. if not CanEngage(myDef, enemyDef) then
  3059. return 0
  3060. end
  3061.  
  3062. local score = MatrixValue(myDef, enemyDef, ColumnArch(enemyDef))
  3063.  
  3064. -- Dedicated AA hard-counters all air, and it does so with the AA already standing on
  3065. -- the field, not only with the AA our pick happens to be scored against.
  3066. if isAir[myDef] and enemyAAShare > 0 then
  3067. score = score * ((1 - enemyAAShare) ^ 1.5)
  3068. end
  3069.  
  3070. return score
  3071. end
  3072.  
  3073. -- How hard one enemy unit type holds a pick of ours back, as a non-negative figure in log
  3074. -- space: 0 is "no opinion, or the matchup is ours", and about 0.8 is an outright counter.
  3075. -- Weighted by that unit's share of the enemy army and summed over the army, this is what
  3076. -- the matrix reading of the counter-presence penalty damps by. It is deliberately one-sided:
  3077. -- a pairing our pick wins says nothing here, because winning it is what the ranking itself
  3078. -- is already made of, and paying for the same matchup twice is how a term like this turns
  3079. -- into a second opinion about the same question.
  3080. --
  3081. -- Both directions of the matrix are asked and the louder one answers, rather than the two
  3082. -- being added. A named pair is written into the table in both directions already, so summing
  3083. -- would charge for a hard counter twice; but each direction also knows something the other
  3084. -- does not -- static defence is a column and never a row, so a Picket can only be read
  3085. -- backwards -- and averaging would quietly halve whichever one of them spoke.
  3086. local function CounterPressure(myDef, enemyDef)
  3087. -- The gate above, read the other way round: something that cannot shoot our pick cannot
  3088. -- screen anything from it, whatever the matrix thinks of the two archetypes in the
  3089. -- abstract. This is what stops a wall of enemy anti-air holding back our tanks.
  3090. if not CanEngage(enemyDef, myDef) then
  3091. return 0
  3092. end
  3093.  
  3094. local bad = 0
  3095. local reverse = MatrixValue(enemyDef, myDef, ColumnArch(myDef))
  3096. if reverse > 1 then
  3097. bad = math.log(reverse)
  3098. end
  3099. -- The forward direction only gets a say where our pick can shoot back at all. Having no
  3100. -- weapon for a target is a hole in the answer rather than a claim about who wins the
  3101. -- fight, and the pairwise average has already charged for it by scoring that pairing at
  3102. -- nothing.
  3103. if CanEngage(myDef, enemyDef) then
  3104. local forward = MatrixValue(myDef, enemyDef, ColumnArch(enemyDef))
  3105. if forward < 1 then
  3106. local f = -math.log(forward)
  3107. if f > bad then
  3108. bad = f
  3109. end
  3110. end
  3111. end
  3112. return bad
  3113. end
  3114.  
  3115. -- A plant that takes orders and never builds them. Two of those: a factory plate whose
  3116. -- parent is dead, stunned or out of range -- Zero-K's unit_factory_plate zeroes its build
  3117. -- speed and publishes `nofactory` = 1 while it has no parent -- and a factory that is itself
  3118. -- still a nanoframe. Both matter more than they sound, because both have an *empty queue*,
  3119. -- and an empty queue is exactly what GatherCandidates below reads as "this is the plant that
  3120. -- would get to it first". So an unlinked plate quietly won the attribution for every unit it
  3121. -- can build, and the auto-queue fed it orders that could never come out. Fac Switcher asks a
  3122. -- plate the same question to decide whether we already have its factory.
  3123. local function PlantIdle(facID, facDefID)
  3124. facDefID = facDefID or spGetUnitDefID(facID)
  3125. if not facDefID then
  3126. return true
  3127. end
  3128. local cp = UnitDefs[facDefID].customParams
  3129. if cp and cp.child_of_factory and Spring.GetUnitRulesParam(facID, "nofactory") ~= 0 then
  3130. return true
  3131. end
  3132. return Spring.GetUnitIsBeingBuilt(facID) == true
  3133. end
  3134.  
  3135. -- Every unit any factory we own could start building right now, mapped to the factory
  3136. -- that would build it. Nothing outside this set can ever be proposed.
  3137. --
  3138. -- The second return is the other way round: every factory we own and what it can build,
  3139. -- which is what continuous production needs. The first is a global ranking and names only
  3140. -- one plant per unit, so it cannot say what the other plants should be doing.
  3141. local function GatherCandidates()
  3142. local candidates = {} -- unitDefID -> {facID, facDefID, queueLen}
  3143. local factories = {} -- facID -> {facDefID, canBuild = {unitDefID -> true}}
  3144. local units = spGetTeamUnits(myTeamID)
  3145. if not units then
  3146. return candidates, factories
  3147. end
  3148.  
  3149. for i = 1, #units do
  3150. local facID = units[i]
  3151. local facDefID = spGetUnitDefID(facID)
  3152. if facDefID and isFactory[facDefID] and not PlantIdle(facID, facDefID) then
  3153. local opts = UnitDefs[facDefID].buildOptions
  3154. if opts then
  3155. local queue = spGetFullBuildQueue(facID)
  3156. local queueLen = 0
  3157. if queue then
  3158. for j = 1, #queue do
  3159. for _, n in pairs(queue[j]) do
  3160. queueLen = queueLen + n
  3161. end
  3162. end
  3163. end
  3164. local canBuild = {}
  3165. factories[facID] = {facDefID = facDefID, canBuild = canBuild}
  3166. for j = 1, #opts do
  3167. local defID = opts[j]
  3168. canBuild[defID] = true
  3169. local prev = candidates[defID]
  3170. -- Prefer whichever factory has the least queued up, so a pick is
  3171. -- attributed to the plant that would actually get to it first.
  3172. if not prev or queueLen < prev.queueLen then
  3173. candidates[defID] = {
  3174. facID = facID,
  3175. facDefID = facDefID,
  3176. queueLen = queueLen,
  3177. }
  3178. end
  3179. end
  3180. end
  3181. end
  3182. end
  3183. return candidates, factories
  3184. end
  3185.  
  3186. -- Dumps the archetype of everything currently buildable. Classification is what the whole
  3187. -- counter matrix hangs off, and it is the part of this widget most likely to be quietly
  3188. -- wrong, so it is worth being able to read it.
  3189. LogRoles = function()
  3190. local list = {}
  3191. for defID in pairs(GatherCandidates()) do
  3192. list[#list + 1] = defID
  3193. end
  3194. if #list == 0 then
  3195. Spring.Echo("Build Proposer: no factories owned, nothing to classify.")
  3196. return
  3197. end
  3198. table.sort(list, function(a, b)
  3199. if role[a] ~= role[b] then
  3200. return role[a] < role[b]
  3201. end
  3202. return unitName[a] < unitName[b]
  3203. end)
  3204.  
  3205. Spring.Echo("Build Proposer: " .. #list .. " buildable units")
  3206. for i = 1, #list do
  3207. local d = list[i]
  3208. local ud = UnitDefs[d]
  3209. local flags = {}
  3210. if isAA[d] then flags[#flags + 1] = "AA" end
  3211. if isAir[d] then flags[#flags + 1] = "air" end
  3212. if isSub[d] then flags[#flags + 1] = "sub" end
  3213. if needsWater[d] then flags[#flags + 1] = "water" end
  3214.  
  3215. -- Where the role came from, and whether the sources agreed.
  3216. local iconGuess = ArchFromIcon(ud.iconType or "default")
  3217. local curated = ARCHETYPE[ud.name]
  3218. local note = ""
  3219. if curated and iconGuess and curated ~= iconGuess then
  3220. note = " (icon said " .. iconGuess .. ")"
  3221. elseif not curated and not iconGuess then
  3222. note = " (guessed from stats)"
  3223. end
  3224.  
  3225. Spring.Echo(string.format(" %-9s %-20s %-12s%s",
  3226. role[d], unitName[d], table.concat(flags, ","), note))
  3227. end
  3228. end
  3229.  
  3230. -- How soon this unit could realistically be standing on the map: the wait to afford it
  3231. -- plus the time the factory needs to produce it.
  3232. local function Availability(defID, cand, mCurr, netMetal, horizon)
  3233. local bp = buildSpeed[cand.facDefID]
  3234. local prodTime = (bp > 0) and (buildTime[defID] / bp) or 0
  3235.  
  3236. local affordWait = 0
  3237. local shortfall = cost[defID] - mCurr
  3238. if shortfall > 0 then
  3239. affordWait = shortfall / math.max(netMetal, 0.1)
  3240. end
  3241.  
  3242. return 1 / (1 + (prodTime + affordWait) / horizon)
  3243. end
  3244.  
  3245. --------------------------------------------------------------------------------
  3246. -- The debug trace
  3247. --
  3248. -- A ranking is a chain of multipliers over a threat-weighted average of matchups, and the
  3249. -- main panel shows only where that chain ended up. That is the right thing for it to show
  3250. -- and the wrong thing to tune against: a pick sitting at 22% could be there because it
  3251. -- counters the army, because everything better is unaffordable, or because the one enemy
  3252. -- unit it loses to is a fifth of the field, and the panel reads the same in all three cases.
  3253. -- This gathers the middle of the calculation so the panel below can say which it was.
  3254. --
  3255. -- It is a second pass rather than something the scoring loop accumulates as it goes, because
  3256. -- every interesting breakdown is per enemy unit type and only four picks are ever read.
  3257. -- Collecting them inside the loop would mean building fifty to throw away forty-six.
  3258. --------------------------------------------------------------------------------
  3259.  
  3260. local function BuildTrace(env)
  3261. local threat, totalThreat = env.threat, env.totalThreat
  3262. local t = {
  3263. totalThreat = totalThreat,
  3264. enemyAAShare = env.enemyAAShare,
  3265. mobileOnly = env.mobileOnly,
  3266. dropped = env.dropped, -- already ordered, heaviest exclusion first
  3267. counterStrength = env.counterStrength,
  3268. matrixCounter = env.matrixCounter,
  3269. qualityWeight = env.qualityWeight,
  3270. availWeight = env.availWeight,
  3271. dampOwnRoles = env.dampOwnRoles,
  3272. horizon = env.horizon,
  3273. candidateCount = 0,
  3274. army = {}, -- every enemy type the ranking answered, heaviest first
  3275. picks = {}, -- the multiplier chain behind each of the four
  3276. }
  3277.  
  3278. for _ in pairs(env.candidates) do
  3279. t.candidateCount = t.candidateCount + 1
  3280. end
  3281.  
  3282. -- Who is being countered. This list is the ranking's entire view of the enemy: anything
  3283. -- not on it moved nothing, whatever Enemy Intel remembers of it.
  3284. for defID, w in pairs(threat) do
  3285. t.army[#t.army + 1] = {defID = defID, threat = w, share = w / totalThreat}
  3286. end
  3287. table.sort(t.army, function(a, b) return a.threat > b.threat end)
  3288.  
  3289. for i = 1, #picks do
  3290. local p = picks[i]
  3291. local d = p.defID
  3292. local e = {
  3293. defID = d,
  3294. base = p.base or 0,
  3295. counterDamp = p.counterDamp or 1,
  3296. mQuality = p.mQuality or 1,
  3297. mAvail = p.mAvail or 1,
  3298. mOwn = p.mOwn or 1,
  3299. score = p.score,
  3300. barFill = p.barFill,
  3301. pressure = p.pressure or 0,
  3302. counterUnit = p.counterUnit,
  3303. counterRole = p.counterRole,
  3304. tier = UNIT_TIER[defName[d]],
  3305. facDefID = p.facDefID,
  3306. gain = 0, weak = 0, blind = 0,
  3307. matchups = {}, blindOn = {}, pressureOn = {},
  3308. }
  3309.  
  3310. -- The matchup average taken apart. `base` is a threat-weighted mean of pair scores
  3311. -- around 1.0, so base = 1 + gain - weak - blind exactly: what this pick beats, what
  3312. -- beats it, and what it has no weapon for. Three terms of one number rather than
  3313. -- three opinions about it, which is what makes them worth showing side by side.
  3314. for eDefID, w in pairs(threat) do
  3315. local share = w / totalThreat
  3316. local pair = PairScore(d, eDefID, env.enemyAAShare)
  3317. if pair > 1 then
  3318. e.gain = e.gain + share * (pair - 1)
  3319. elseif not CanEngage(d, eDefID) then
  3320. -- Charged the whole of its share: a pairing scored at nothing still divides
  3321. -- into totalThreat, which is exactly why an unanswerable slice of the enemy
  3322. -- army drags a pick down without ever appearing in the matchup list.
  3323. e.blind = e.blind + share
  3324. e.blindOn[#e.blindOn + 1] = {defID = eDefID, share = share}
  3325. elseif pair < 1 then
  3326. e.weak = e.weak + share * (1 - pair)
  3327. end
  3328. -- The same walk the ranking made, kept per enemy this time rather than summed.
  3329. -- Only the matrix reading is per unit; the archetype one is a share of columns,
  3330. -- and the leave-one-out below rebuilds it from `col` instead.
  3331. local bad = 0
  3332. if env.matrixCounter and env.counterStrength > 0 then
  3333. bad = CounterPressure(d, eDefID) * share
  3334. if bad > 0 then
  3335. e.pressureOn[#e.pressureOn + 1] = {defID = eDefID, bad = bad}
  3336. end
  3337. end
  3338. e.matchups[#e.matchups + 1] = {
  3339. defID = eDefID, pair = pair, share = share, contrib = share * pair,
  3340. bad = bad, col = ColumnArch(eDefID),
  3341. }
  3342. end
  3343. table.sort(e.matchups, function(a, b) return a.contrib > b.contrib end)
  3344. table.sort(e.blindOn, function(a, b) return a.share > b.share end)
  3345. table.sort(e.pressureOn, function(a, b) return a.bad > b.bad end)
  3346.  
  3347. -- The availability multiplier said in seconds, which is the form it was computed in
  3348. -- and the only form worth arguing with: "x0.83" says nothing, "8s to afford, 6s to
  3349. -- build, against a 30s horizon" says which slider to move.
  3350. local cand = env.candidates[d]
  3351. if cand then
  3352. local bp = buildSpeed[cand.facDefID]
  3353. e.prodTime = (bp > 0) and (buildTime[d] / bp) or 0
  3354. local shortfall = cost[d] - env.mCurr
  3355. e.affordWait = (shortfall > 0) and (shortfall / math.max(env.netMetal, 0.1)) or 0
  3356. end
  3357.  
  3358. t.picks[i] = e
  3359. end
  3360.  
  3361. -- Which enemy units are holding the top pick back, one at a time.
  3362. --
  3363. -- Both halves of the score decompose over the enemy army, which is what makes the
  3364. -- question answerable rather than merely arguable: the matchup average is a sum over
  3365. -- enemy types, and the counter damping is exp() of a sum over them, so a type can be
  3366. -- lifted out of both and the score rebuilt without it. What that rebuild is worth is the
  3367. -- honest figure -- how much better this pick would score if that unit type were not on
  3368. -- the field -- and it is one number per enemy, on the same scale as the score itself.
  3369. --
  3370. -- Not "its matchup value", which double-counts a unit that is both a bad matchup and a
  3371. -- counter, and says nothing at all about a unit whose whole contribution is pressure.
  3372. local top = t.picks[1]
  3373. if top and #top.matchups > 1 then
  3374. local cs = env.counterStrength
  3375. local counters = (not env.matrixCounter) and COUNTERED_BY[role[top.defID]] or nil
  3376.  
  3377. -- The ranking's own damping, re-run over a smaller army. Mirrors the branch in
  3378. -- Recompute rather than simplifying it, floor included, so the two cannot disagree.
  3379. local function Damp(pressure, roleShare)
  3380. if cs <= 0 then
  3381. return 1
  3382. end
  3383. local counterShare = 0
  3384. if env.matrixCounter then
  3385. if pressure > 0 then
  3386. counterShare = 1 - math.exp(-pressure)
  3387. end
  3388. elseif counters then
  3389. for r in pairs(counters) do
  3390. counterShare = counterShare + (roleShare[r] or 0)
  3391. end
  3392. if counterShare > 1 then
  3393. counterShare = 1
  3394. end
  3395. end
  3396. if counterShare <= 0 then
  3397. return 1
  3398. end
  3399. return math.max((1 - counterShare) ^ cs, COUNTER_FLOOR)
  3400. end
  3401.  
  3402. local fixed = top.mQuality * top.mAvail * top.mOwn
  3403. top.perUnit = {}
  3404. for i = 1, #top.matchups do
  3405. local m = top.matchups[i]
  3406. local rest = 1 - m.share
  3407. if rest > 0.0001 then
  3408. -- Every share is renormalised against the smaller army, because that is what
  3409. -- the ranking would have done: totalThreat is the denominator of both terms.
  3410. local base = (top.base - m.share * m.pair) / rest
  3411. local pressure = (top.pressure - m.bad) / rest
  3412. local without
  3413. if counters then
  3414. local rs = {}
  3415. for r, v in pairs(env.roleShare) do
  3416. rs[r] = v / rest
  3417. end
  3418. rs[m.col] = ((env.roleShare[m.col] or 0) - m.share) / rest
  3419. without = base * Damp(pressure, rs)
  3420. else
  3421. without = base * Damp(pressure)
  3422. end
  3423. without = without * fixed
  3424. top.perUnit[#top.perUnit + 1] = {
  3425. defID = m.defID,
  3426. share = m.share,
  3427. pair = m.pair,
  3428. bad = m.bad,
  3429. -- Positive means the pick would score better without it: a drag.
  3430. drag = without - top.score,
  3431. }
  3432. end
  3433. end
  3434. table.sort(top.perUnit, function(a, b) return a.drag > b.drag end)
  3435. end
  3436.  
  3437. trace = t
  3438. end
  3439.  
  3440. -- What can be left out of the army we answer.
  3441. --
  3442. -- Every one of these is the player saying "I am not going to fight that", not a claim about
  3443. -- the matrix. It matters because the ranking is a threat-weighted average: metal that will
  3444. -- never trade with you still divides into the total, so three anti-air turrets you have no
  3445. -- intention of attacking quietly spend a third of the ranking on themselves.
  3446. --
  3447. -- Order is the order they are tested in, and the first match is the one reported, so the
  3448. -- broad exclusions come before the narrow ones they subsume -- "buildings" before "unarmed
  3449. -- buildings", which is why turning both on never double-counts a solar.
  3450. local EXCLUSIONS = {
  3451. {key = 'mobileOnly', label = "buildings",
  3452. test = function(d) return isStatic[d] end},
  3453. {key = 'ignoreEco', label = "economy buildings",
  3454. test = function(d) return isStatic[d] and not isArmed[d] end},
  3455. {key = 'ignoreCommanders', label = "commanders",
  3456. test = function(d) return role[d] == "commander" end},
  3457. -- Mobile only: Zero-K gives every factory, plate, Strider Hub, Missile Silo, Rearm Pad
  3458. -- and Caretaker build power, so they all classify as "con", and this filter is about
  3459. -- not chasing builders rather than about ignoring their base.
  3460. {key = 'ignoreCons', label = "constructors",
  3461. test = function(d) return role[d] == "con" and not isStatic[d] end},
  3462. {key = 'ignoreAA', label = "anti-air",
  3463. test = function(d) return role[d] == "aa" end},
  3464. -- Last of the five, so a Trident or a plane constructor is reported under the narrower
  3465. -- heading it also matches; every one of those is air anyway, so nothing escapes by it.
  3466. {key = 'ignoreAir', label = "aircraft",
  3467. test = function(d) return isAir[d] end},
  3468. }
  3469.  
  3470. local function Recompute()
  3471. picks = {}
  3472. facPicks = {}
  3473. emptyReason = nil
  3474. focusActive = false
  3475. -- Cleared with the picks, so every early return below leaves the debug panel with
  3476. -- nothing to draw and `emptyReason` as the whole explanation.
  3477. trace = nil
  3478.  
  3479. local intel = WG.EnemyIntel
  3480. if not intel then
  3481. emptyReason = "Enemy Intel is not running"
  3482. return
  3483. end
  3484. -- Whose army we are answering. Every way this can fail falls back to the whole enemy
  3485. -- side: a ranking against more of the enemy than you will meet is merely blunt, one
  3486. -- against nothing at all is useless.
  3487. local counts
  3488. local aaCounts -- what air is judged against, which is not always what we rank against
  3489.  
  3490. -- The remembered armies of the focused teams, merged. The ranking itself in the player
  3491. -- modes; only the anti-air backdrop in the units mode.
  3492. local function MergeFocusArmies()
  3493. if not (focusTeams and intel.GetEffectiveCountsForTeam) then
  3494. return nil
  3495. end
  3496. local merged, any = {}, false
  3497. for team in pairs(focusTeams) do
  3498. local c = intel.GetEffectiveCountsForTeam(team)
  3499. local w = (focusWeights and focusWeights[team]) or 1
  3500. if c and w > 0 then
  3501. -- Threat is normalised against its own total further down, so scaling
  3502. -- a front here only ever changes its share of the answer.
  3503. for defID, n in pairs(c) do
  3504. merged[defID] = (merged[defID] or 0) + n * w
  3505. any = true
  3506. end
  3507. end
  3508. end
  3509. return any and merged or nil
  3510. end
  3511.  
  3512. if focusUnits then
  3513. -- The units mode assembled its own army out of single units, so there is no roster to
  3514. -- look up: what has shot us is what we answer. Anti-air is the one deliberate
  3515. -- exception, because a Razor that has never shot you is precisely the Razor that
  3516. -- kills your first gunship -- so that question alone is still put to the whole armies
  3517. -- behind those units.
  3518. counts, focusActive = focusUnits, true
  3519. aaCounts = MergeFocusArmies() or intel.GetEffectiveCounts()
  3520. elseif focusTeams then
  3521. if intel.GetEffectiveCountsForTeam then
  3522. local merged = MergeFocusArmies()
  3523. if merged then
  3524. counts, focusActive = merged, true
  3525. else
  3526. focusNote = "nothing scouted on that front"
  3527. end
  3528. else
  3529. focusNote = "Enemy Intel too old to split by player"
  3530. end
  3531. end
  3532. counts = counts or intel.GetEffectiveCounts()
  3533. if not counts or not next(counts) then
  3534. emptyReason = "No enemy intel yet"
  3535. return
  3536. end
  3537. aaCounts = aaCounts or counts
  3538.  
  3539. local candidates, factories = GatherCandidates()
  3540. if not next(candidates) then
  3541. emptyReason = "No factories owned"
  3542. return
  3543. end
  3544.  
  3545. -- Whether an air push is viable is judged against everything the enemy owns, static AA
  3546. -- included and regardless of the mobile-only setting below. A Razor shoots your
  3547. -- gunships whether or not you ever intended to attack it.
  3548. local aaThreat, allThreat = 0, 0
  3549. for defID, n in pairs(aaCounts) do
  3550. local t = n * cost[defID]
  3551. if t > 0 then
  3552. allThreat = allThreat + t
  3553. if isAA[defID] or dpsAir[defID] > 0 then
  3554. aaThreat = aaThreat + t
  3555. end
  3556. end
  3557. end
  3558. local enemyAAShare = (allThreat > 0) and math.min(aaThreat / allThreat, 0.95) or 0
  3559.  
  3560. -- Threat weighting: what fraction of the enemy's committed metal each unit type is.
  3561. -- Which exclusions are switched on, resolved once rather than per enemy unit type.
  3562. local active = {}
  3563. for i = 1, #EXCLUSIONS do
  3564. if options[EXCLUSIONS[i].key].value then
  3565. active[#active + 1] = EXCLUSIONS[i]
  3566. end
  3567. end
  3568.  
  3569. local mobileOnly = options.mobileOnly.value
  3570. local threat, totalThreat = {}, 0
  3571. local dropped, droppedOrder = {}, {} -- label -> what it took out, for the debug panel
  3572. for defID, n in pairs(counts) do
  3573. local out
  3574. for i = 1, #active do
  3575. if active[i].test(defID) then
  3576. out = active[i].label
  3577. break
  3578. end
  3579. end
  3580. if out then
  3581. local d = dropped[out]
  3582. if not d then
  3583. d = {label = out, types = 0, metal = 0}
  3584. dropped[out] = d
  3585. droppedOrder[#droppedOrder + 1] = d
  3586. end
  3587. d.types = d.types + 1
  3588. d.metal = d.metal + n * cost[defID]
  3589. else
  3590. local t = n * cost[defID]
  3591. if isStatic[defID] then
  3592. t = t * STATIC_THREAT_WEIGHT
  3593. end
  3594. if t > 0 then
  3595. threat[defID] = t
  3596. totalThreat = totalThreat + t
  3597. end
  3598. end
  3599. end
  3600. if totalThreat <= 0 then
  3601. -- Everything seen has been filtered out. Name what did the filtering, because
  3602. -- "no enemy intel yet" would be a lie about a setting the player can move.
  3603. if #droppedOrder > 0 then
  3604. local names = {}
  3605. for i = 1, #droppedOrder do
  3606. names[i] = droppedOrder[i].label
  3607. end
  3608. table.sort(names)
  3609. emptyReason = "Only " .. table.concat(names, ", ") .. " seen"
  3610. else
  3611. emptyReason = "No enemy intel yet"
  3612. end
  3613. return
  3614. end
  3615. table.sort(droppedOrder, function(a, b) return a.metal > b.metal end)
  3616.  
  3617. -- What fraction of the enemy's metal sits in each matrix column, for the
  3618. -- counter-presence damping below. Columns, not roles, so a wall of turrets registers
  3619. -- as static defence rather than as whatever archetype the turrets resemble.
  3620. local roleShare = {}
  3621. for defID, t in pairs(threat) do
  3622. local r = ColumnArch(defID)
  3623. roleShare[r] = (roleShare[r] or 0) + t
  3624. end
  3625. for r, v in pairs(roleShare) do
  3626. roleShare[r] = v / totalThreat
  3627. end
  3628.  
  3629. -- Economy, for the availability term.
  3630. local mCurr, _, netMetal = GetMetalState()
  3631. mCurr = mCurr or 0
  3632. netMetal = netMetal or 0
  3633. local horizon = options.horizon.value
  3634. local availWeight = options.availWeight.value
  3635. local counterStrength = options.counterStrength.value
  3636. local matrixCounter = options.counterSource.value == 'matrix'
  3637. local qualityWeight = options.qualityWeight.value
  3638.  
  3639. -- Optional: what we already own, by role, to damp what we are saturated in.
  3640. local ownRoleShare = {}
  3641. if options.dampOwnRoles.value then
  3642. local ownTotal = 0
  3643. local units = spGetTeamUnits(myTeamID)
  3644. if units then
  3645. for i = 1, #units do
  3646. local d = spGetUnitDefID(units[i])
  3647. if d and not isStatic[d] and not NON_COMBAT[role[d]] then
  3648. local v = cost[d]
  3649. ownRoleShare[role[d]] = (ownRoleShare[role[d]] or 0) + v
  3650. ownTotal = ownTotal + v
  3651. end
  3652. end
  3653. end
  3654. if ownTotal > 0 then
  3655. for r, v in pairs(ownRoleShare) do
  3656. ownRoleShare[r] = v / ownTotal
  3657. end
  3658. end
  3659. end
  3660.  
  3661. local scored = {}
  3662. local dryMap = waterFraction < WATER_THRESHOLD
  3663. local noBoats = options.noBoats.value
  3664. for defID, cand in pairs(candidates) do
  3665. -- A boat, for the noBoats option: anything that needs water under it, plus the
  3666. -- boats that do not, which only their move class gives away (the Cutter floats
  3667. -- at depth 0 on BOAT3; submarines are UBOAT). Hovercraft are HOVER and amphibious
  3668. -- units AKBOT/ATANK, so neither matches. Worked out here rather than in a table
  3669. -- at load, because this block is at the local ceiling.
  3670. local boat = false
  3671. if noBoats then
  3672. local md = UnitDefs[defID].moveDef
  3673. local mclass = (md and md.name or ""):lower()
  3674. boat = needsWater[defID] or mclass:find("^u?boat") ~= nil
  3675. end
  3676. -- Constructors and cloakers are never an answer to an army; anything unarmed drops
  3677. -- out at the weapon gate inside PairScore. A ship is no answer at all on a map with
  3678. -- nowhere to float it.
  3679. if not NON_COMBAT[role[defID]] and not (needsWater[defID] and dryMap) and not boat then
  3680. local sum, best = 0, {}
  3681. -- Counter pressure: the threat-weighted sum of how badly each enemy unit type
  3682. -- beats this pick, and whichever type is carrying most of it. Gathered in the
  3683. -- same pass, but deliberately outside the p > 0 branch, because an enemy our
  3684. -- pick cannot even shoot back at is precisely one that should hold it back.
  3685. local wantPressure = matrixCounter and counterStrength > 0
  3686. local pressure, worstDef, worstPressure = 0, nil, 0
  3687. for eDefID, t in pairs(threat) do
  3688. local p = PairScore(defID, eDefID, enemyAAShare)
  3689. if wantPressure then
  3690. local bad = CounterPressure(defID, eDefID)
  3691. if bad > 0 then
  3692. bad = bad * t / totalThreat
  3693. pressure = pressure + bad
  3694. if bad > worstPressure then
  3695. worstPressure, worstDef = bad, eDefID
  3696. end
  3697. end
  3698. end
  3699. if p > 0 then
  3700. local contrib = t * p
  3701. sum = sum + contrib
  3702. best[#best + 1] = {defID = eDefID, contrib = contrib}
  3703. end
  3704. end
  3705.  
  3706. if sum > 0 then
  3707. local base = sum / totalThreat
  3708. local score = base
  3709.  
  3710. -- Counter presence. Averaging the matchups treats an army that is a quarter
  3711. -- riots as three quarters raider-friendly, which is not how it plays: the
  3712. -- riots screen everything behind them, so raiders lose value against the
  3713. -- whole army rather than against that quarter of it. This sits deliberately
  3714. -- on top of the pairwise matrix value, because being screened out of the
  3715. -- fight and losing the fight are two separate costs.
  3716. --
  3717. -- Two readings of "what is standing out there that beats this", on the one
  3718. -- slider: the archetype columns, or the matrix asked about every enemy unit
  3719. -- type in turn. Both end as a 0..1 severity and are charged for the same way.
  3720. local counterShare, worstRole, worstUnit = 0, nil, nil
  3721. local counterDamp = 1
  3722. if counterStrength > 0 then
  3723. if matrixCounter then
  3724. -- exp(-pressure) is the threat-weighted geometric mean of how badly
  3725. -- the enemy army beats this pick, so an army that is a tenth hard
  3726. -- counter holds it back a tenth as hard as one that is all counter,
  3727. -- and a counter twice as sharp counts for twice as much.
  3728. if pressure > 0 then
  3729. counterShare = 1 - math.exp(-pressure)
  3730. worstUnit = worstDef
  3731. end
  3732. else
  3733. local counters = COUNTERED_BY[role[defID]]
  3734. if counters then
  3735. for r in pairs(counters) do
  3736. local s = roleShare[r] or 0
  3737. counterShare = counterShare + s
  3738. if not worstRole or s > (roleShare[worstRole] or 0) then
  3739. worstRole = r
  3740. end
  3741. end
  3742. if counterShare > 1 then
  3743. counterShare = 1
  3744. end
  3745. end
  3746. end
  3747. if counterShare > 0 then
  3748. counterDamp = math.max((1 - counterShare) ^ counterStrength, COUNTER_FLOOR)
  3749. score = score * counterDamp
  3750. end
  3751. end
  3752.  
  3753. -- Tier. Deliberately last of the multipliers and deliberately gentle: it
  3754. -- breaks ties between picks the matrix rates alike, and is not meant to
  3755. -- overrule a matchup it disagrees with.
  3756. --
  3757. -- Each multiplier is named on the way past rather than folded straight into
  3758. -- `score`, so the debug panel can report what the ranking actually charged
  3759. -- this pick for. Three numbers on a table that is being built anyway, so it
  3760. -- is not worth making conditional -- and a decomposition gathered only while
  3761. -- someone is watching is a decomposition worth nobody's trust.
  3762. local mQuality, mAvail, mOwn = 1, 1, 1
  3763. if qualityWeight > 0 then
  3764. mQuality = Quality(defID) ^ qualityWeight
  3765. score = score * mQuality
  3766. end
  3767. if availWeight > 0 then
  3768. mAvail = Availability(defID, cand, mCurr, netMetal, horizon) ^ availWeight
  3769. score = score * mAvail
  3770. end
  3771. if options.dampOwnRoles.value then
  3772. mOwn = 1 - 0.6 * (ownRoleShare[role[defID]] or 0)
  3773. score = score * mOwn
  3774. end
  3775.  
  3776. -- Keep only the loudest few contributors, for the tooltip.
  3777. table.sort(best, function(a, b) return a.contrib > b.contrib end)
  3778. local why = {}
  3779. for i = 1, math.min(3, #best) do
  3780. why[i] = best[i].defID
  3781. end
  3782.  
  3783. scored[#scored + 1] = {
  3784. defID = defID,
  3785. facID = cand.facID,
  3786. facDefID = cand.facDefID,
  3787. score = score,
  3788. why = why,
  3789. counterShare = counterShare,
  3790. counterRole = worstRole,
  3791. counterUnit = worstUnit,
  3792. counterDamp = counterDamp,
  3793. -- The working, for the debug panel. Read only for the four that survive
  3794. -- the sort below, and ignored entirely by the panel above.
  3795. base = base,
  3796. pressure = pressure,
  3797. mQuality = mQuality,
  3798. mAvail = mAvail,
  3799. mOwn = mOwn,
  3800. }
  3801. end
  3802. end
  3803. end
  3804.  
  3805. table.sort(scored, function(a, b) return a.score > b.score end)
  3806.  
  3807. -- Sorted, so the first is the largest and the guard needs no sum of its own.
  3808. if not scored[1] or scored[1].score <= 0 then
  3809. -- Nothing buildable can engage what was seen at all: air-only enemy and no AA, say.
  3810. emptyReason = "No answer available"
  3811. return
  3812. end
  3813.  
  3814. -- The panel prints the score itself, so nothing is normalised into a share of the four
  3815. -- here any more. A share of the four was a poor thing to read: it moved when the *other*
  3816. -- picks changed while this one stood still, and it squeezed every ranking into the same
  3817. -- narrow band, so beating the field by a nose and beating it by double both came out
  3818. -- near the same number.
  3819. --
  3820. -- The one derived figure left is the bar, which is each score against the best of them.
  3821. -- That makes the bar a picture of the number printed beside it, and it fills the top row
  3822. -- rather than stopping at whatever fraction of a hidden total the leader happened to be.
  3823. local best = scored[1].score
  3824. for i = 1, math.min(PICK_COUNT, #scored) do
  3825. local p = scored[i]
  3826. p.barFill = p.score / best
  3827. picks[i] = p
  3828. end
  3829.  
  3830. -- The icon-only layout falls back to a single unit when no factory has a queue, and it
  3831. -- should not be one from a plant the auto-queue skip settings have closed: the best pick
  3832. -- from a factory still open. Taken
  3833. -- from the whole ranking rather than the top four, which can all be air. Kept on the
  3834. -- picks table itself so a blank ranking clears it along with the rest.
  3835. local skipAir, skipSilo = options.autoQueueSkipAir.value, options.autoQueueSkipSilos.value
  3836. for i = 1, #scored do
  3837. local fd = scored[i].facDefID
  3838. if not ((skipAir and isAirFactory[fd]) or (skipSilo and isMissileSilo[fd])) then
  3839. picks.iconPick = scored[i]
  3840. break
  3841. end
  3842. end
  3843.  
  3844. -- What each factory should be making, for continuous production. Walking the ranking
  3845. -- in order and taking the first entry a factory can build gives every plant its own
  3846. -- best answer, rather than leaving the ones that cannot build the overall top pick
  3847. -- with nothing to do.
  3848. --
  3849. -- The icon-only layout shows one icon per busy factory, so it keeps the whole entry
  3850. -- too, re-attributed to that plant: the ranking names whichever plant has the shortest
  3851. -- queue, and a click on this factory's icon has to queue at this factory. Plants the
  3852. -- skip settings close get no icon, as they get no iconPick.
  3853. picks.byFactory = {}
  3854. for facID, fac in pairs(factories) do
  3855. for i = 1, #scored do
  3856. if fac.canBuild[scored[i].defID] then
  3857. facPicks[facID] = scored[i].defID
  3858. local fd = fac.facDefID
  3859. if not ((skipAir and isAirFactory[fd]) or (skipSilo and isMissileSilo[fd])) then
  3860. local own = {}
  3861. for k, v in pairs(scored[i]) do
  3862. own[k] = v
  3863. end
  3864. own.facID, own.facDefID = facID, fd
  3865. picks.byFactory[facID] = own
  3866. end
  3867. break
  3868. end
  3869. end
  3870. end
  3871.  
  3872. -- Last, because it describes the finished ranking rather than taking part in it, and
  3873. -- skipped whenever the panel that reads it is closed.
  3874. if options.debugPanel.value then
  3875. BuildTrace{
  3876. threat = threat, totalThreat = totalThreat, enemyAAShare = enemyAAShare,
  3877. counts = counts, candidates = candidates, roleShare = roleShare,
  3878. mCurr = mCurr, netMetal = netMetal, horizon = horizon,
  3879. mobileOnly = mobileOnly, dropped = droppedOrder,
  3880. counterStrength = counterStrength,
  3881. matrixCounter = matrixCounter, qualityWeight = qualityWeight,
  3882. availWeight = availWeight, dampOwnRoles = options.dampOwnRoles.value,
  3883. }
  3884. end
  3885. end
  3886.  
  3887. --------------------------------------------------------------------------------
  3888. -- Queueing
  3889. --
  3890. -- Factory build commands always append to the end of the queue, so anything we add sits
  3891. -- behind everything the player has asked for. Removing ours back off the end therefore
  3892. -- cannot disturb a player order. OPT_SHIFT is Zero-K's "times five" modifier here, not a
  3893. -- queue-position flag, which is why it is only used for the click path.
  3894. --------------------------------------------------------------------------------
  3895.  
  3896. -- Alt puts the order directly behind whatever the factory is building right now, so it
  3897. -- comes out next rather than last. Shift is five either way.
  3898. local function QueueUnit(facID, defID, five, behindCurrent)
  3899. if amSpectating or not facID or not defID then
  3900. return
  3901. end
  3902.  
  3903. -- Position 1 is the slot just after the unit under construction. This is the same
  3904. -- INSERT that Zero-K's own build menu uses for its "alt inserts behind" option, down
  3905. -- to the ALT+CTRL flags: ALT makes the first parameter a queue position rather than a
  3906. -- command tag.
  3907. --
  3908. -- It has to be skipped while the factory is on repeat. Repeat depends on the engine
  3909. -- removing each command once it completes, and an inserted command does not behave
  3910. -- that way, so the queue would grow forever. Falling through to a plain append keeps
  3911. -- the click working instead of silently doing nothing.
  3912. if behindCurrent and not spGetUnitRepeat(facID) then
  3913. for _ = 1, (five and 5 or 1) do
  3914. spGiveOrderToUnit(facID, CMD.INSERT, {1, -defID, 0}, CMD.OPT_ALT + CMD.OPT_CTRL)
  3915. end
  3916. return
  3917. end
  3918.  
  3919. spGiveOrderToUnit(facID, -defID, {}, five and CMD.OPT_SHIFT or 0)
  3920. end
  3921.  
  3922. -- How many units this factory has left to build, ours and the player's alike. Read live
  3923. -- rather than from the last ranking pass, because orders land between passes.
  3924. local function FactoryQueueLen(facID)
  3925. local queue = spGetFullBuildQueue(facID)
  3926. if not queue then
  3927. return nil
  3928. end
  3929. local n = 0
  3930. for i = 1, #queue do
  3931. for _, count in pairs(queue[i]) do
  3932. n = n + count
  3933. end
  3934. end
  3935. return n
  3936. end
  3937.  
  3938. -- Queue tags for this unit type in this factory, in queue order.
  3939. local function QueueTags(facID, defID)
  3940. local cmds = spGetFactoryCommands(facID, -1)
  3941. if not cmds then
  3942. return nil
  3943. end
  3944. local out = {}
  3945. for i = 1, #cmds do
  3946. local c = cmds[i]
  3947. if c.id == -defID then
  3948. out[#out + 1] = c.tag
  3949. end
  3950. end
  3951. return out
  3952. end
  3953.  
  3954. -- Issues one unit. The tag it lands on cannot be read back here: an order from a widget
  3955. -- travels through the command network and only appears in the queue a frame or more
  3956. -- later, so claiming it is left to ResolvePending.
  3957. --
  3958. -- What gets recorded is the set of tags present beforehand, not a count. A tag identifies
  3959. -- one queue slot, so whichever tag is there afterwards and was not there before is ours,
  3960. -- no matter what else was added or removed in between.
  3961. local function AutoQueueOne(pick)
  3962. local facID, defID = pick.facID, pick.defID
  3963. if spGetUnitIsDead(facID) ~= false then
  3964. return false
  3965. end
  3966. local before = QueueTags(facID, defID)
  3967. if not before then
  3968. return false
  3969. end
  3970. local beforeSet = {}
  3971. for i = 1, #before do
  3972. beforeSet[before[i]] = true
  3973. end
  3974.  
  3975. spGiveOrderToUnit(facID, -defID, {}, 0)
  3976.  
  3977. pendingAuto = {
  3978. facID = facID,
  3979. defID = defID,
  3980. before = beforeSet,
  3981. -- Generous, because giving up early is the worse mistake: an order that lands after
  3982. -- its claim has expired has no owner, and the player-order watch takes it for the
  3983. -- player's and stands the whole base down. Waiting only delays our next order.
  3984. deadline = os.clock() + 10,
  3985. }
  3986. return true
  3987. end
  3988.  
  3989. -- Removes one of our orders, but only after proving it is still the order we placed.
  3990. -- Command tags get recycled, so a tag that now belongs to something else must be dropped
  3991. -- rather than removed.
  3992. local function RetractOne(rec)
  3993. if spGetUnitIsDead(rec.facID) ~= false then
  3994. return
  3995. end
  3996. local cmds = spGetFactoryCommands(rec.facID, -1)
  3997. if not cmds then
  3998. return
  3999. end
  4000. -- Index 1 is already under construction; cancelling it would waste the metal spent.
  4001. for i = #cmds, 2, -1 do
  4002. local c = cmds[i]
  4003. if c.tag == rec.tag and c.id == -rec.defID then
  4004. spGiveOrderToUnit(rec.facID, CMD.REMOVE, {rec.tag}, CMD.OPT_CTRL)
  4005. return
  4006. end
  4007. end
  4008. end
  4009.  
  4010. RetractAll = function()
  4011. -- Last in, first out: our newest order is the one furthest from being built.
  4012. for i = #autoOrders, 1, -1 do
  4013. RetractOne(autoOrders[i])
  4014. end
  4015. autoOrders = {}
  4016. ourTags = {}
  4017. if pendingAuto then
  4018. -- An order still in flight cannot be taken back until we know which slot it landed
  4019. -- in, so mark it to be pulled the moment it shows up.
  4020. pendingAuto.retractOnArrival = true
  4021. end
  4022. end
  4023.  
  4024. -- Claims the tag of an order once it reaches the queue, or gives up on it.
  4025. local function ResolvePending(now)
  4026. local p = pendingAuto
  4027. if not p then
  4028. return
  4029. end
  4030. if spGetUnitIsDead(p.facID) ~= false then
  4031. pendingAuto = nil
  4032. return
  4033. end
  4034.  
  4035. local tags = QueueTags(p.facID, p.defID)
  4036. local found
  4037. if tags then
  4038. -- Search from the back: ours was appended, and if the player queued the same unit
  4039. -- in the same moment the two are interchangeable anyway.
  4040. for i = #tags, 1, -1 do
  4041. if not p.before[tags[i]] then
  4042. found = tags[i]
  4043. break
  4044. end
  4045. end
  4046. end
  4047.  
  4048. if found then
  4049. local rec = {facID = p.facID, tag = found, defID = p.defID}
  4050. local retract = p.retractOnArrival
  4051. pendingAuto = nil
  4052. if retract then
  4053. RetractOne(rec)
  4054. -- Never recorded as ours, so the player-order watch would see a tag it has no
  4055. -- snapshot of and take it for the player's. It was ours; say so in the snapshot.
  4056. if seenTags[p.facID] then
  4057. seenTags[p.facID][found] = true
  4058. end
  4059. else
  4060. autoOrders[#autoOrders + 1] = rec
  4061. end
  4062. elseif now >= p.deadline then
  4063. -- It never arrived: refused, or the player cleared the queue underneath it. Either
  4064. -- way, try the rest of the base before this plant again.
  4065. aqHold.refused[p.facID] = now + REFUSED_COOLDOWN
  4066. pendingAuto = nil
  4067. end
  4068. end
  4069.  
  4070. -- Forget orders that have already been built or that the player cancelled, so the
  4071. -- outstanding count stays honest.
  4072. local function PruneAutoOrders()
  4073. local keep = {}
  4074. for i = 1, #autoOrders do
  4075. local rec = autoOrders[i]
  4076. if spGetUnitIsDead(rec.facID) == false then
  4077. local cmds = spGetFactoryCommands(rec.facID, -1)
  4078. if cmds then
  4079. for j = 1, #cmds do
  4080. if cmds[j].tag == rec.tag and cmds[j].id == -rec.defID then
  4081. keep[#keep + 1] = rec
  4082. break
  4083. end
  4084. end
  4085. end
  4086. end
  4087. end
  4088. autoOrders = keep
  4089. end
  4090.  
  4091. -- Plants the auto-queue has been told to leave to you. This gates only what we queue: the
  4092. -- ranking is untouched, so such a factory still gets its pick, the panel still proposes from
  4093. -- it, and clicking that proposal still queues it. What stops is us placing the order
  4094. -- unasked -- which is the whole difference between a suggestion and a silo firing itself.
  4095. --
  4096. -- Asked per factory rather than filtered out of `facPicks`, because the pick is what the
  4097. -- retraction below compares against and what the panel prints; a factory that vanished from
  4098. -- that table would read as one with nothing to build, which is a different thing entirely
  4099. -- and is deliberately left alone by RetractStale.
  4100. local function AutoQueueSkips(facID)
  4101. local facDefID = spGetUnitDefID(facID)
  4102. if not facDefID then
  4103. return false
  4104. end
  4105. return (options.autoQueueSkipSilos.value and isMissileSilo[facDefID])
  4106. or (options.autoQueueSkipAir.value and isAirFactory[facDefID])
  4107. end
  4108.  
  4109. -- Takes back orders of ours that the ranking has since moved on from. A proposal is a
  4110. -- claim about what should be coming off the line next, so filler queued under an older
  4111. -- one is not worth building merely because we happened to ask for it first: the queue is
  4112. -- pulled back to whatever the panel is saying now, and the fill passes below re-stock it
  4113. -- with the current pick.
  4114. --
  4115. -- Only what has not been started is touched. RetractOne already refuses to cancel the
  4116. -- order at the head of a factory queue, which is the one the metal is going into, so a
  4117. -- unit already being built is left to finish.
  4118. --
  4119. -- A factory with no pick at all is left alone rather than emptied. There is no pick
  4120. -- during the blank spells -- no enemy seen yet, intel briefly gone -- and those are
  4121. -- silence about what to build, not an instruction to build nothing.
  4122. --
  4123. -- A plant the skip settings have since closed is emptied though, pick or no pick. Switching
  4124. -- one of those on mid-game is an instruction about what to build, and leaving four warheads
  4125. -- on the silo queue would be answering it only for the orders not yet placed.
  4126. local function RetractStale()
  4127. -- Newest first, the same order RetractAll uses: our latest order is the one furthest
  4128. -- from being built, so it is the one that can be taken back most cleanly.
  4129. for i = #autoOrders, 1, -1 do
  4130. local rec = autoOrders[i]
  4131. local want = facPicks[rec.facID]
  4132. if AutoQueueSkips(rec.facID) or (want and want ~= rec.defID) then
  4133. RetractOne(rec)
  4134. end
  4135. end
  4136. -- Records are not dropped here. A removal travels through the command network like
  4137. -- any other order, so the queue still holds the slot for a frame or more; leaving the
  4138. -- record for PruneAutoOrders to clear on a later pass means the count stays honest in
  4139. -- the meantime and a removal that never lands is simply reissued.
  4140. end
  4141.  
  4142. --------------------------------------------------------------------------------
  4143. -- Auto-queue
  4144. --
  4145. -- Arming is predictive: it starts a little before the metal bar caps, so the first metal
  4146. -- is not already going to waste by the time an order lands.
  4147. --
  4148. -- Standing down is not tied to the excess at all. It happens when the player queues
  4149. -- something of their own, which is the signal that they have taken production back. Our
  4150. -- filler comes off the queue and theirs is left exactly as they placed it.
  4151. --------------------------------------------------------------------------------
  4152.  
  4153. local function RebuildOurTags()
  4154. ourTags = {}
  4155. for i = 1, #autoOrders do
  4156. ourTags[autoOrders[i].tag] = true
  4157. end
  4158. end
  4159.  
  4160. -- Watches our factories for build orders that appeared without us placing them. Tags are
  4161. -- unique per queue slot, so anything new that is not ours was queued by the player.
  4162. -- Returns them as a list of {facID, tag}, empty when there are none.
  4163. local function PlayerQueuedSomething()
  4164. local found = {}
  4165. local units = spGetTeamUnits(myTeamID)
  4166. if not units then
  4167. return found
  4168. end
  4169.  
  4170. local live = {}
  4171. for i = 1, #units do
  4172. local facID = units[i]
  4173. local facDefID = spGetUnitDefID(facID)
  4174. -- A repeating factory re-appends its own finished orders under fresh tags, which
  4175. -- is indistinguishable from the player queueing more, so it is left out of this
  4176. -- entirely -- both the watching and the queueing.
  4177. if facDefID and isFactory[facDefID] and not spGetUnitRepeat(facID) then
  4178. local cmds = spGetFactoryCommands(facID, -1)
  4179. if cmds then
  4180. local current = {}
  4181. local prev = seenTags[facID]
  4182. for j = 1, #cmds do
  4183. local c = cmds[j]
  4184. if c.id and c.id < 0 then
  4185. current[c.tag] = true
  4186. -- No previous snapshot means this factory is new to us, so this
  4187. -- pass only seeds it rather than reading anything into it.
  4188. if prev and not prev[c.tag] and not ourTags[c.tag] then
  4189. found[#found + 1] = {facID = facID, tag = c.tag}
  4190. end
  4191. end
  4192. end
  4193. live[facID] = current
  4194. end
  4195. end
  4196. end
  4197.  
  4198. seenTags = live
  4199. return found
  4200. end
  4201.  
  4202. -- Whether any order the player placed during this excess is still waiting or being built.
  4203. -- Reads the snapshot PlayerQueuedSomething just took, so it has to run after that.
  4204. local function PlayerOrdersLeft()
  4205. for facID, tags in pairs(playerTags) do
  4206. local queued = seenTags[facID]
  4207. if queued then
  4208. for tag in pairs(tags) do
  4209. if queued[tag] then
  4210. return true
  4211. end
  4212. end
  4213. end
  4214. end
  4215. return false
  4216. end
  4217.  
  4218. local function UpdateArmed()
  4219. -- Only the excess mode arms: continuous production is not waiting for anything.
  4220. if amSpectating or options.autoQueueMode.value ~= 'excess' then
  4221. armed = false
  4222. return
  4223. end
  4224.  
  4225. if ProjectedToExcess(options.leadTime.value) then
  4226. -- A fresh overflow is a fresh offer: whatever the player queued during the last one
  4227. -- was an answer to that one. So was anything queued in the moments before it, which
  4228. -- is why the snapshot starts over too. Otherwise an order that landed after the last
  4229. -- disarmed poll counted as one placed during this overflow and stood the widget down
  4230. -- before it had queued anything.
  4231. if not armed then
  4232. yielded = false
  4233. playerTags = {}
  4234. seenTags = {}
  4235. end
  4236. armed = true
  4237. return
  4238. end
  4239.  
  4240. -- Staying armed is deliberately stickier than arming, because the projection cannot
  4241. -- read the surplus honestly once we have started spending it: a factory draws metal as
  4242. -- soon as it picks our order up, so the net rate that armed us goes negative one order
  4243. -- in. Re-deriving `armed` from that rate stood the whole thing down after a single
  4244. -- order per factory, which is why the queue depth below never appeared to do anything
  4245. -- here -- the allowance was never what we ran out of first.
  4246. --
  4247. -- So while the bar is still nearly full the surplus plainly has not been spent yet,
  4248. -- whatever the rate says, and we stay armed until it has actually come down.
  4249. if armed then
  4250. local cur, stor = GetMetalState()
  4251. armed = (cur ~= nil) and cur > stor * EXCESS_HOLD
  4252. end
  4253. end
  4254.  
  4255. -- Continuous production. Every factory is topped up to the configured queue depth with
  4256. -- whatever it can build that answers the enemy best.
  4257. --
  4258. -- The depth counts the whole queue rather than only our own orders, which is what keeps
  4259. -- this out of the player's way: anything they queue is built first, and we only fill the
  4260. -- room that is left. That is also why this mode does not stand down when the player
  4261. -- queues something -- there is nothing to stand down from, since a full queue already
  4262. -- means we add nothing.
  4263. --
  4264. -- One order per pass, because a queued order cannot be identified until it appears in the
  4265. -- queue a frame or more later and only one such claim can be outstanding at a time.
  4266. local function UpdateContinuous()
  4267. PruneAutoOrders()
  4268. local depth = options.maxAutoQueued.value
  4269. local now = os.clock()
  4270.  
  4271. for facID, defID in pairs(aqHold.picks) do
  4272. if spGetUnitIsDead(facID) == false and not spGetUnitRepeat(facID)
  4273. and not AutoQueueSkips(facID) and not ((aqHold.refused[facID] or 0) > now)
  4274. and not PlantIdle(facID) then
  4275. local len = FactoryQueueLen(facID)
  4276. if len and len < depth and AutoQueueOne({facID = facID, defID = defID}) then
  4277. return
  4278. end
  4279. end
  4280. end
  4281. end
  4282.  
  4283. -- The excess mode: wait until metal is about to be wasted, then spend it across every
  4284. -- factory, and hand production straight back the moment the player queues anything.
  4285. local function UpdateOnExcess()
  4286. -- Our own orders have to be known before the queue can be read for anyone else's. The
  4287. -- watch runs on every pass, armed or not, so its snapshot is never stale when it counts.
  4288. RebuildOurTags()
  4289. local playerOrders = PlayerQueuedSomething()
  4290.  
  4291. -- The overflow is gone, so is the reason for anything of ours not yet started. The unit
  4292. -- at the head of each queue is left to finish (RetractOne never cancels it); the rest
  4293. -- would be spending metal the player now has a use for.
  4294. if not armed then
  4295. if #autoOrders > 0 then
  4296. RetractAll()
  4297. end
  4298. return
  4299. end
  4300.  
  4301. -- The player has taken production back. Pulling our orders and then re-adding them on the
  4302. -- next pass, because the bar is still full, is what used to make this last one second:
  4303. -- `yielded` holds it while anything the player queued is still on a factory queue.
  4304. if #playerOrders > 0 then
  4305. for i = 1, #playerOrders do
  4306. local o = playerOrders[i]
  4307. playerTags[o.facID] = playerTags[o.facID] or {}
  4308. playerTags[o.facID][o.tag] = true
  4309. end
  4310. yielded = true
  4311. aqHold.yieldAt = os.clock()
  4312. RetractAll()
  4313. return
  4314. end
  4315. -- The yield ends when the player's orders are built or cancelled, not only at the next
  4316. -- overflow. The next overflow only begins once the bar has dropped under EXCESS_HOLD, and
  4317. -- a bar still pinned full after the player's unit comes out never drops. Waiting for that
  4318. -- left every factory idle with the metal running to waste until the widget was
  4319. -- restarted. It is also what makes a false yield recoverable: an order of ours that lands
  4320. -- after its claim expired reads as the player's, and it goes away once it is built.
  4321. -- An order parked somewhere that never builds -- a stunned plant, a plate that lost its
  4322. -- parent -- never leaves a queue, and the base stayed handed back for the rest of the
  4323. -- game. After ninety seconds the overflow is taken back: it is being wasted either way,
  4324. -- and nothing of the player's is ever touched.
  4325. if yielded then
  4326. if PlayerOrdersLeft() and (os.clock() - (aqHold.yieldAt or 0)) < 90 then
  4327. return
  4328. end
  4329. yielded = false
  4330. playerTags = {}
  4331. end
  4332.  
  4333. PruneAutoOrders()
  4334.  
  4335. -- Where the top pick alone used to be fed, every factory is now, each with the best
  4336. -- answer it can build itself -- the same per-factory picks continuous production uses.
  4337. -- A surplus is a whole-team one and a single plant only turns so much of it into units
  4338. -- per second, so pinning the spend to whichever factory happened to own the overall
  4339. -- top pick left the rest of the base idle while metal still overflowed.
  4340. --
  4341. -- The queue depth is a per-factory allowance, the way it already reads in continuous
  4342. -- production: each factory may hold that many orders of ours, so what we commit grows
  4343. -- with the number of plants there are to spend the surplus. Unlike continuous mode it
  4344. -- counts only our own orders, because this mode retracts on a player order rather than
  4345. -- filling in behind one.
  4346. local depth = options.maxAutoQueued.value
  4347. local outstanding = {}
  4348. for i = 1, #autoOrders do
  4349. local facID = autoOrders[i].facID
  4350. outstanding[facID] = (outstanding[facID] or 0) + 1
  4351. end
  4352.  
  4353. -- Ties go to the factory whose pick ranked highest, so the best answers are still the
  4354. -- ones built first; factories with no pick in the visible ranking come after those.
  4355. local rank = {}
  4356. for i = 1, #picks do
  4357. local facID = picks[i].facID
  4358. if not rank[facID] then
  4359. rank[facID] = i
  4360. end
  4361. end
  4362.  
  4363. -- Emptiest queue first, so the allowances fill across the base together rather than
  4364. -- one factory reaching its own before the next gets anything.
  4365. local bestFac, bestDefID, bestCount, bestRank
  4366. local now = os.clock()
  4367. for facID, defID in pairs(aqHold.picks) do
  4368. -- A repeating factory re-appends its own finished orders under fresh tags, which
  4369. -- is indistinguishable from a player order, so it is left out here exactly as it
  4370. -- is left out of the player-order watch.
  4371. if spGetUnitIsDead(facID) == false and not spGetUnitRepeat(facID)
  4372. and not AutoQueueSkips(facID) and not ((aqHold.refused[facID] or 0) > now)
  4373. and not PlantIdle(facID) then
  4374. local count = outstanding[facID] or 0
  4375. local r = rank[facID] or math.huge
  4376. if count < depth and (not bestFac or count < bestCount
  4377. or (count == bestCount and r < bestRank)
  4378. or (count == bestCount and r == bestRank and facID < bestFac)) then
  4379. bestFac, bestDefID, bestCount, bestRank = facID, defID, count, r
  4380. end
  4381. end
  4382. end
  4383.  
  4384. if bestFac then
  4385. AutoQueueOne({facID = bestFac, defID = bestDefID})
  4386. end
  4387. end
  4388.  
  4389. local function UpdateAutoQueue(now)
  4390. if now < nextAutoAdd then
  4391. return
  4392. end
  4393.  
  4394. local mode = options.autoQueueMode.value
  4395. -- Only one order can be in flight at a time, so the pass rate is the fill rate: at one a
  4396. -- second a five-plant base took fifteen seconds to reach depth three, with the metal
  4397. -- already running to waste. An overflow is the one moment worth hurrying for.
  4398. nextAutoAdd = now + ((mode == 'excess' and armed) and 0.34 or 1)
  4399.  
  4400. if amSpectating or mode == 'off' then
  4401. return
  4402. end
  4403. -- Both modes claim the tag of the order they placed, and only one claim can be in
  4404. -- flight at a time. Waiting also keeps an order of ours that has not landed yet from
  4405. -- reading as a player order below; nothing is lost, because the next pass still
  4406. -- compares against the older snapshot and catches a genuine player order.
  4407. if pendingAuto then
  4408. -- An order that went out under a pick the ranking has since dropped is stale
  4409. -- before it even arrives. It cannot be taken back while its slot is still unknown,
  4410. -- so it is marked to be pulled the moment it lands, the same way RetractAll marks
  4411. -- one it catches in flight.
  4412. local want = facPicks[pendingAuto.facID]
  4413. if want and want ~= pendingAuto.defID then
  4414. pendingAuto.retractOnArrival = true
  4415. end
  4416. -- Likewise one that went out during an overflow that has since ended, or one
  4417. -- headed for a plant the skip settings closed while it was in flight.
  4418. if mode == 'excess' and (not armed or yielded) then
  4419. pendingAuto.retractOnArrival = true
  4420. end
  4421. if AutoQueueSkips(pendingAuto.facID) then
  4422. pendingAuto.retractOnArrival = true
  4423. end
  4424. return
  4425. end
  4426.  
  4427. -- The picks the fill passes work from: the current ones, or the last ones there were
  4428. -- while the ranking is blank. RetractStale still reads the live facPicks, so a blank
  4429. -- spell never pulls anything.
  4430. if next(facPicks) then
  4431. aqHold.picks = facPicks
  4432. end
  4433.  
  4434. -- Before anything is added: what we already have queued has to agree with what the
  4435. -- panel is proposing now, or the depth is spent holding answers to an older read of
  4436. -- the enemy.
  4437. RetractStale()
  4438.  
  4439. if mode == 'always' then
  4440. UpdateContinuous()
  4441. else
  4442. UpdateOnExcess()
  4443. end
  4444. end
  4445.  
  4446. -- What the auto-queue is doing, in a few words and a colour. The panel has no other way of
  4447. -- saying it: the header that carried "spending excess" only exists on the four-row layout,
  4448. -- and the icon layout -- which is the one being used -- has no header at all, so a queue
  4449. -- that had quietly stopped feeding the base looked exactly like one that was working.
  4450. --
  4451. -- The two idle answers are the ones worth having. "Nothing to build" is a ranking with no
  4452. -- picks in it; "every plant skipped" is a base where each factory is on repeat, closed by
  4453. -- the skip settings, standing off after a refused order, or unable to build at all.
  4454. local AQ_COLOR = {
  4455. off = {0.55, 0.55, 0.55, 0.40}, -- the grip's ordinary grey
  4456. idle = {0.95, 0.45, 0.35, 0.75}, -- wanted to build and could not: the one to notice
  4457. wait = {0.45, 0.65, 0.95, 0.60}, -- armed for an overflow that has not come
  4458. on = {0.45, 0.90, 0.50, 0.75}, -- placing orders
  4459. yield = {1.00, 0.75, 0.25, 0.75}, -- stood down, the player is spending
  4460. }
  4461.  
  4462. local function AutoQueueState()
  4463. local mode = options.autoQueueMode.value
  4464. if amSpectating or mode == 'off' then
  4465. return "Auto-queue off", "off"
  4466. end
  4467. if not next(aqHold.picks) then
  4468. return "Auto-queue: nothing to build", "idle"
  4469. end
  4470. local now = os.clock()
  4471. local eligible = false
  4472. for facID in pairs(aqHold.picks) do
  4473. if spGetUnitIsDead(facID) == false and not spGetUnitRepeat(facID)
  4474. and not AutoQueueSkips(facID) and not ((aqHold.refused[facID] or 0) > now)
  4475. and not PlantIdle(facID) then
  4476. eligible = true
  4477. break
  4478. end
  4479. end
  4480. if not eligible then
  4481. return "Auto-queue: every plant skipped", "idle"
  4482. end
  4483. if mode == 'always' then
  4484. return "Auto-queue: filling factories", "on"
  4485. end
  4486. if yielded then
  4487. return "Auto-queue: yours to spend", "yield"
  4488. end
  4489. if armed then
  4490. return "Auto-queue: spending excess", "on"
  4491. end
  4492. return "Auto-queue: waiting for overflow", "wait"
  4493. end
  4494.  
  4495. --------------------------------------------------------------------------------
  4496. -- Chili UI
  4497. --------------------------------------------------------------------------------
  4498.  
  4499. local Chili
  4500. local window_main, holder, headerLabel, emptyLabel
  4501. local rows = {}
  4502. -- The compact alternative to the list: its window, its grip, one slot (icon and name) per
  4503. -- busy factory and the picks those slots are showing. Kept in a table rather than as more
  4504. -- locals because this block is close to Lua's 200-local ceiling.
  4505. local iconPanel = {}
  4506. local ICON_BIG = 48 -- plus 2px between stacked slots, written inline for the local ceiling
  4507. local ICON_NAME_W = 120 -- room for the name beside the icon
  4508.  
  4509. local PAD = 4
  4510. local ICON = 26
  4511. local ROW_H = 34
  4512. local HEADER_H = 18
  4513. local WIN_W = 250
  4514. local TEXT_X = ICON + 8
  4515. local BAR_H = 6
  4516.  
  4517. local FONT_MAIN = {
  4518. size = 13,
  4519. color = {1, 1, 1, 1},
  4520. outline = true,
  4521. outlineWidth = 3,
  4522. outlineWeight = 6,
  4523. outlineColor = {0, 0, 0, 1},
  4524. }
  4525. local FONT_DIM = {
  4526. size = 11,
  4527. color = {0.70, 0.75, 0.85, 1},
  4528. outline = true,
  4529. outlineWidth = 3,
  4530. outlineWeight = 6,
  4531. outlineColor = {0, 0, 0, 1},
  4532. }
  4533. -- Inline font colour escape, the standard Spring form. Components must be 1-255; a zero
  4534. -- byte would terminate the string, which is why Zero-K writes 001 rather than 000.
  4535. local TAG_COLOR = "\255\160\170\190"
  4536.  
  4537. local FONT_HEADER = {
  4538. size = 13,
  4539. color = {1.0, 0.85, 0.40, 1},
  4540. outline = true,
  4541. outlineWidth = 3,
  4542. outlineWeight = 6,
  4543. outlineColor = {0, 0, 0, 1},
  4544. }
  4545.  
  4546. -- Chili's Object:New turns the table you hand it into the instance itself, so a font table
  4547. -- cannot be shared between controls: the second control to use it would take over the font
  4548. -- object the first one is still pointing at. Every control gets its own copy.
  4549. local function FontCopy(spec)
  4550. local out = {}
  4551. for k, v in pairs(spec) do
  4552. if type(v) == "table" then
  4553. local sub = {}
  4554. for i, x in pairs(v) do
  4555. sub[i] = x
  4556. end
  4557. out[k] = sub
  4558. else
  4559. out[k] = v
  4560. end
  4561. end
  4562. return out
  4563. end
  4564.  
  4565. -- "factorycloak" -> "Cloak". Short enough to sit at the end of a row without crowding it.
  4566. local function FactoryTag(facDefID)
  4567. local n = UnitDefs[facDefID] and UnitDefs[facDefID].name or "?"
  4568. local tag = n:match("^factory(.+)$") or n
  4569. return tag:sub(1, 1):upper() .. tag:sub(2)
  4570. end
  4571.  
  4572. -- Longest opponent name the header can carry before it runs into the auto-mode suffix.
  4573. local NAME_MAX = 14
  4574.  
  4575. -- The header is one short line, so the reasoning goes here rather than into the caption.
  4576. local function FocusTooltip()
  4577. local mode = options.focusMode.value
  4578. if mode == 'all' then
  4579. return "Ranking against every enemy unit Enemy Intel remembers.\n"
  4580. .. "Settings/HUD Panels/Build Proposer can narrow this to the players you fight."
  4581. end
  4582. if mode == 'attackers' then
  4583. if not focusActive then
  4584. return "Ranking against every enemy: "
  4585. .. (focusNote or "nothing has shot you yet") .. "."
  4586. end
  4587. local n = 0
  4588. for _ in pairs(attackers) do
  4589. n = n + 1
  4590. end
  4591. local owners = {}
  4592. for team in pairs(focusTeams) do
  4593. owners[#owners + 1] = TeamName(team)
  4594. end
  4595. table.sort(owners)
  4596. local tip = string.format("Ranking against the %d enemy unit%s that have actually shot "
  4597. .. "you (%s).", n, (n == 1) and "" or "s",
  4598. table.concat(owners, ", "))
  4599. .. "\nEach weighted by the damage it has done to you, halving every "
  4600. .. options.focusMemory.value .. "s."
  4601. .. "\nA unit stops counting the moment it dies"
  4602. if options.focusIdle.value > 0 then
  4603. tip = tip .. ", or after " .. options.focusIdle.value .. "s without shooting you"
  4604. end
  4605. return tip .. ".\nTheir anti-air is still judged across their whole armies."
  4606. end
  4607. if focusActive then
  4608. -- Heaviest front first, each named with the weight its army is carrying, so a pick
  4609. -- that only makes sense against the second front is still explainable.
  4610. local fronts = {}
  4611. for team in pairs(focusTeams) do
  4612. fronts[#fronts + 1] = {name = TeamName(team), w = (focusWeights and focusWeights[team]) or 1}
  4613. end
  4614. table.sort(fronts, function(a, b)
  4615. if a.w ~= b.w then return a.w > b.w end
  4616. return a.name < b.name
  4617. end)
  4618. local parts = {}
  4619. for i = 1, #fronts do
  4620. local f = fronts[i]
  4621. parts[i] = (f.w >= 0.995) and f.name
  4622. or string.format("%s at %d%%", f.name, math.floor(f.w * 100 + 0.5))
  4623. end
  4624. local lead
  4625. if mode == 'main' then
  4626. lead = "Ranking against the opponent you have fought most:"
  4627. elseif mode == 'threat' then
  4628. lead = "Ranking against the opponent hurting you most:"
  4629. elseif mode == 'balanced' then
  4630. lead = "Ranking against the opponents you are fighting, balanced towards who hurts you:"
  4631. elseif mode == 'hurting' then
  4632. lead = "Ranking against every opponent that has hurt you:"
  4633. else
  4634. lead = "Ranking against the opponents you are fighting:"
  4635. end
  4636. local tally = "damage traded"
  4637. if mode == 'threat' or mode == 'hurting' then
  4638. tally = "damage taken"
  4639. elseif mode == 'balanced' then
  4640. tally = string.format("damage traded and damage taken, mixed %d%% towards taken",
  4641. math.floor(options.focusHarmBias.value * 100 + 0.5))
  4642. end
  4643. local tip = lead .. "\n" .. table.concat(parts, ", ")
  4644. .. "\nWeighted by " .. tally .. ", halving every " .. options.focusMemory.value .. "s."
  4645. if options.focusIdle.value > 0 then
  4646. tip = tip .. "\nA front goes quiet for " .. options.focusIdle.value
  4647. .. "s and stops counting."
  4648. end
  4649. return tip
  4650. end
  4651. if focusMain and not focusNote then
  4652. return "Only one opponent in this game, so there is nothing to narrow to."
  4653. end
  4654. return "Ranking against every enemy: " .. (focusNote or "no opponent picked out yet") .. "."
  4655. end
  4656.  
  4657. local function RowClicked(self, _, _, mouseButton)
  4658. if mouseButton ~= 1 then
  4659. return
  4660. end
  4661. local pick
  4662. if self.iconSlot then
  4663. pick = iconPanel.shown and iconPanel.shown[self.iconSlot]
  4664. else
  4665. pick = picks[self.pickIndex]
  4666. end
  4667. if not pick then
  4668. return
  4669. end
  4670. local alt, _, _, shift = spGetModKeyState()
  4671. QueueUnit(pick.facID, pick.defID, shift, alt)
  4672. end
  4673.  
  4674. local function CreateWindow()
  4675. window_main = Chili.Window:New{
  4676. parent = Chili.Screen0,
  4677. name = "BuildProposer",
  4678. classname = "main_window_small_very_flat",
  4679. -- Fully transparent, matching Enemy Intel: the rows float over the map with no frame.
  4680. color = {0, 0, 0, 0},
  4681. backgroundColor = {0, 0, 0, 0},
  4682. borderColor = {0, 0, 0, 0},
  4683. dockable = true,
  4684. dockableSavePositionOnly = true,
  4685. parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
  4686. right = 2,
  4687. y = 42,
  4688. clientWidth = WIN_W,
  4689. clientHeight = HEADER_H + PICK_COUNT * ROW_H + PAD * 2,
  4690. draggable = false,
  4691. resizable = false,
  4692. tweakDraggable = true,
  4693. tweakResizable = true,
  4694. padding = {6, 6, 6, 6},
  4695. }
  4696.  
  4697. holder = Chili.Control:New{
  4698. parent = window_main,
  4699. x = 0, y = 0, right = 0, bottom = 0,
  4700. padding = {0, 0, 0, 0},
  4701. }
  4702.  
  4703. headerLabel = Chili.Label:New{
  4704. parent = holder,
  4705. x = 2, y = 0,
  4706. caption = "Best builds",
  4707. font = FontCopy(FONT_HEADER),
  4708. }
  4709.  
  4710. emptyLabel = Chili.Label:New{
  4711. parent = holder,
  4712. x = 2, y = HEADER_H + 4,
  4713. caption = "No enemy intel yet",
  4714. font = FontCopy(FONT_DIM),
  4715. }
  4716.  
  4717. local barW = WIN_W - TEXT_X - PAD * 2 - 4
  4718. for i = 1, PICK_COUNT do
  4719. local y = HEADER_H + (i - 1) * ROW_H
  4720. local btn = Chili.Button:New{
  4721. parent = holder,
  4722. x = PAD, y = y,
  4723. width = WIN_W - PAD * 2,
  4724. height = ROW_H - 2,
  4725. caption = "",
  4726. padding = {0, 0, 0, 0},
  4727. backgroundColor = {0, 0, 0, 0},
  4728. borderColor = {0, 0, 0, 0},
  4729. focusColor = {1, 1, 1, 0.10},
  4730. OnClick = {RowClicked},
  4731. }
  4732. btn.pickIndex = i
  4733.  
  4734. local row = {
  4735. ctrl = btn,
  4736. -- Chili's Image inherits Button and swallows the hit, so the icon needs its own
  4737. -- copy of the handler or clicking the picture would do nothing. Labels and the
  4738. -- progress bar inherit Control instead and stay click-through.
  4739. icon = Chili.Image:New{
  4740. parent = btn,
  4741. x = 0, y = 2,
  4742. width = ICON, height = ICON,
  4743. keepAspect = true,
  4744. file = DEFAULT_ICON,
  4745. pickIndex = i,
  4746. OnClick = {RowClicked},
  4747. },
  4748. name = Chili.Label:New{
  4749. parent = btn,
  4750. x = TEXT_X, y = 0,
  4751. caption = "",
  4752. font = FontCopy(FONT_MAIN),
  4753. },
  4754. -- autosize is on by default, and for a right-aligned label it rewrites both x
  4755. -- and width from the text on every SetCaption, so the label would walk left a
  4756. -- little further each refresh. Fixed boxes instead: DrawInBox does the aligning.
  4757. score = Chili.Label:New{
  4758. parent = btn,
  4759. x = TEXT_X, y = 0,
  4760. width = barW, height = 16,
  4761. autosize = false,
  4762. align = "right",
  4763. caption = "",
  4764. font = FontCopy(FONT_MAIN),
  4765. },
  4766. bar = Chili.Progressbar:New{
  4767. parent = btn,
  4768. x = TEXT_X, y = 17,
  4769. width = barW, height = BAR_H,
  4770. min = 0, max = 1, value = 0,
  4771. caption = "",
  4772. color = {0.45, 0.75, 1.00, 0.9},
  4773. backgroundColor = {0.1, 0.1, 0.1, 0.5},
  4774. },
  4775. }
  4776. rows[i] = row
  4777. btn:Hide()
  4778. end
  4779. end
  4780.  
  4781. -- One pick per busy factory: its picture and its name, no score, no bar, no header. Its own
  4782. -- window rather than the list window shrunk, so each layout keeps its own docked position
  4783. -- and size -- api_chili_docking keys positions by window name. The slots are made on demand
  4784. -- by RefreshIconPanel, since how many there are is however many plants are working.
  4785. local function CreateIconWindow()
  4786. -- The icons and names are all click target, so there was nothing left to grab. A grip
  4787. -- strip on the left: a Panel has no handlers and no tooltip, so the hit falls through to
  4788. -- the window, which (being draggable) starts the drag there.
  4789. local GRIP_W = 8
  4790. iconPanel.slots = {}
  4791. iconPanel.shown = {}
  4792. iconPanel.window = Chili.Window:New{
  4793. parent = Chili.Screen0,
  4794. name = "BuildProposerIcon",
  4795. classname = "main_window_small_very_flat",
  4796. color = {0, 0, 0, 0},
  4797. backgroundColor = {0, 0, 0, 0},
  4798. borderColor = {0, 0, 0, 0},
  4799. dockable = true,
  4800. dockableSavePositionOnly = true,
  4801. parentWidgetName = widget:GetInfo().name,
  4802. right = 406,
  4803. y = 0,
  4804. clientWidth = GRIP_W + 2 + ICON_BIG + ICON_NAME_W,
  4805. clientHeight = ICON_BIG,
  4806. draggable = true,
  4807. resizable = false,
  4808. tweakDraggable = true,
  4809. tweakResizable = true,
  4810. padding = {4, 4, 4, 4},
  4811. }
  4812. -- The grip is also the status light: it is the only part of this layout that is always
  4813. -- there, pick or no pick.
  4814. iconPanel.grip = Chili.Panel:New{
  4815. parent = iconPanel.window,
  4816. x = 0, y = 0, width = GRIP_W, bottom = 0,
  4817. backgroundColor = AQ_COLOR.off,
  4818. }
  4819. -- Slot n, made the first time that many plants are busy at once. Each is a transparent
  4820. -- button under the icon and the name, as in the list rows, so clicking the name queues
  4821. -- too; the image swallows its own hit and needs its own copy of the handler. `iconSlot`
  4822. -- is what RowClicked looks up in iconPanel.shown. Installed here rather than declared
  4823. -- as a local, for the local ceiling, and it goes with the window when Shutdown drops
  4824. -- the table.
  4825. iconPanel.Slot = function(n)
  4826. local slot = iconPanel.slots[n]
  4827. if slot then
  4828. return slot
  4829. end
  4830. local button = Chili.Button:New{
  4831. parent = iconPanel.window,
  4832. x = GRIP_W + 2, y = (n - 1) * (ICON_BIG + 2),
  4833. right = 0, height = ICON_BIG,
  4834. caption = "",
  4835. padding = {0, 0, 0, 0},
  4836. backgroundColor = {0, 0, 0, 0},
  4837. borderColor = {0, 0, 0, 0},
  4838. focusColor = {1, 1, 1, 0.10},
  4839. iconSlot = n,
  4840. OnClick = {RowClicked},
  4841. }
  4842. slot = {
  4843. button = button,
  4844. image = Chili.Image:New{
  4845. parent = button,
  4846. x = 0, y = 0,
  4847. width = ICON_BIG, height = ICON_BIG,
  4848. keepAspect = true,
  4849. file = DEFAULT_ICON,
  4850. iconSlot = n,
  4851. OnClick = {RowClicked},
  4852. },
  4853. name = Chili.Label:New{
  4854. parent = button,
  4855. -- Fixed box, as for the list's score label: autosize would rewrite the
  4856. -- height from the text and undo the vertical centring.
  4857. x = ICON_BIG + 6, y = 0,
  4858. width = ICON_NAME_W - 6, height = ICON_BIG,
  4859. autosize = false,
  4860. valign = "center",
  4861. caption = "",
  4862. font = FontCopy(FONT_MAIN),
  4863. },
  4864. }
  4865. iconPanel.slots[n] = slot
  4866. return slot
  4867. end
  4868. end
  4869.  
  4870. -- Show/hide only. The windows are kept alive so their docked positions, and anything the
  4871. -- tweak-mode layout did to them, survive being toggled off and on again. Only one of the two
  4872. -- layouts is ever up; `visible` says whether the panel is wanted at all.
  4873. function SetPanelVisible(visible)
  4874. if not (window_main and iconPanel.window) then
  4875. return
  4876. end
  4877. local iconOnly = options.iconOnly.value
  4878. if visible and not iconOnly then
  4879. window_main:Show()
  4880. else
  4881. window_main:Hide()
  4882. end
  4883. if visible and iconOnly then
  4884. iconPanel.window:Show()
  4885. else
  4886. iconPanel.window:Hide()
  4887. end
  4888. if visible then
  4889. dirty = true -- the panel went stale while hidden; redraw before it is looked at
  4890. end
  4891. end
  4892.  
  4893. -- Say which enemy units drove the pick, so the number is arguable rather than something the
  4894. -- panel just asserts. Shared by both layouts, so the lone icon says as much as a row does.
  4895. local function PickTooltip(pick)
  4896. local why = {}
  4897. for j = 1, #pick.why do
  4898. why[j] = unitName[pick.why[j]]
  4899. end
  4900. local tip = unitName[pick.defID] .. " - " .. (cost[pick.defID]) .. " metal"
  4901. if #why > 0 then
  4902. tip = tip .. "\nAnswers: " .. table.concat(why, ", ")
  4903. end
  4904. -- Only worth saying once it actually moved the number. The two readings of counter
  4905. -- presence answer different questions, so they say so differently: one names the share
  4906. -- of the army that screens this pick out, the other names the unit doing most of the
  4907. -- screening and what the whole of it cost the score.
  4908. if pick.counterShare and pick.counterShare >= 0.05 then
  4909. if pick.counterUnit then
  4910. tip = tip .. string.format("\nHeld back most by enemy %s (-%d%% in all)",
  4911. unitName[pick.counterUnit],
  4912. math.floor((1 - (pick.counterDamp or 1)) * 100 + 0.5))
  4913. elseif pick.counterRole then
  4914. tip = tip .. string.format("\nHeld back by enemy %s (%d%% of their army)",
  4915. ROLE_PLURAL[pick.counterRole] or pick.counterRole,
  4916. math.floor(pick.counterShare * 100 + 0.5))
  4917. end
  4918. end
  4919. return tip .. "\nFrom: " .. FactoryTag(pick.facDefID)
  4920. .. "\nClick to queue at the back, shift for five."
  4921. .. "\nAlt queues it next instead, behind the unit being built."
  4922. end
  4923.  
  4924. -- The compact layout: one icon and name for every factory with something in its queue,
  4925. -- showing what the ranking wants from that plant, the reasoning left to the tooltip. With
  4926. -- every queue empty it falls back to the single best pick, so there is always something to
  4927. -- click. The queues are read here rather than in Recompute, because orders land between
  4928. -- passes and a refresh is the moment the panel is looked at.
  4929. local function RefreshIconPanel()
  4930. local state, key = AutoQueueState()
  4931. iconPanel.grip.backgroundColor = AQ_COLOR[key]
  4932. iconPanel.grip.tooltip = state
  4933. iconPanel.grip:Invalidate()
  4934.  
  4935. local shown = {}
  4936. for facID, pick in pairs(picks.byFactory or {}) do
  4937. -- nil for a plant that died since the ranking, so it drops out here too.
  4938. if (FactoryQueueLen(facID) or 0) > 0 then
  4939. shown[#shown + 1] = pick
  4940. end
  4941. end
  4942. -- By unit id, which is build order: a plant keeps its place in the stack while others
  4943. -- come and go, rather than the icons trading places every refresh as scores move.
  4944. table.sort(shown, function(a, b) return a.facID < b.facID end)
  4945. if #shown == 0 then
  4946. shown[1] = picks.iconPick -- may be nil too, and then the stack is empty
  4947. end
  4948. iconPanel.shown = shown
  4949.  
  4950. for i = 1, math.max(#shown, #iconPanel.slots) do
  4951. local pick = shown[i]
  4952. if pick then
  4953. local slot = iconPanel.Slot(i)
  4954. local tip = PickTooltip(pick) .. "\n" .. state
  4955. slot.image.file = iconFile[pick.defID]
  4956. slot.image.tooltip = tip
  4957. slot.image:Invalidate()
  4958. slot.name:SetCaption(unitName[pick.defID])
  4959. slot.button.tooltip = tip
  4960. slot.button:Show()
  4961. else
  4962. iconPanel.slots[i].button:Hide()
  4963. end
  4964. end
  4965.  
  4966. -- The window follows the stack, so the empty space under it does not swallow clicks on
  4967. -- the map. Height only: the width is the user's if tweak mode resized it. One slot's
  4968. -- worth at least, for the grip.
  4969. local n = math.max(#shown, 1)
  4970. if iconPanel.count ~= n then
  4971. iconPanel.count = n
  4972. iconPanel.window:Resize(nil, n * ICON_BIG + (n - 1) * 2, true)
  4973. end
  4974. end
  4975.  
  4976. local function RefreshLayout()
  4977. if not (window_main and holder) then
  4978. return
  4979. end
  4980. if options.iconOnly.value then
  4981. if iconPanel.window then
  4982. RefreshIconPanel()
  4983. end
  4984. return
  4985. end
  4986.  
  4987. local showTag = options.showFactoryTag.value
  4988. for i = 1, PICK_COUNT do
  4989. local row = rows[i]
  4990. local pick = picks[i]
  4991. if pick then
  4992. row.icon.file = iconFile[pick.defID]
  4993. row.icon:Invalidate()
  4994. -- The factory goes inline after the name, dimmed. A second right-aligned label
  4995. -- would have sat on top of the weight bar.
  4996. local caption = unitName[pick.defID]
  4997. if showTag then
  4998. caption = caption .. TAG_COLOR .. " [" .. FactoryTag(pick.facDefID) .. "]"
  4999. end
  5000. row.name:SetCaption(caption)
  5001. row.score:SetCaption(string.format("%.3f", pick.score))
  5002. row.bar:SetValue(pick.barFill)
  5003. row.ctrl.tooltip = PickTooltip(pick)
  5004.  
  5005. row.ctrl:Show()
  5006. else
  5007. row.ctrl:Hide()
  5008. end
  5009. end
  5010.  
  5011. if picks[1] then
  5012. emptyLabel:Hide()
  5013. else
  5014. emptyLabel:SetCaption(emptyReason or "No enemy intel yet")
  5015. emptyLabel:Show()
  5016. end
  5017.  
  5018. -- Say which of the two automatic modes is actually doing something right now, so the
  5019. -- panel is not silently queueing units the player has forgotten they asked for, and
  5020. -- name the front when the ranking has been narrowed to one, since a pick that looks
  5021. -- wrong against the enemy side can be exactly right against the half of it you face.
  5022. local caption = "Best builds"
  5023. if focusActive and focusUnits then
  5024. -- Naming a player here would be a lie: this ranking answers a handful of their units,
  5025. -- not their army. How many units are on the books says more anyway, since it is also
  5026. -- the sample size the ranking is resting on.
  5027. local n = 0
  5028. for _ in pairs(attackers) do
  5029. n = n + 1
  5030. end
  5031. caption = "Best builds vs " .. n .. " attacker" .. ((n == 1) and "" or "s")
  5032. elseif focusActive and focusMain then
  5033. local others = -1
  5034. for _ in pairs(focusTeams) do
  5035. others = others + 1
  5036. end
  5037. local name = TeamName(focusMain)
  5038. if #name > NAME_MAX then
  5039. name = name:sub(1, NAME_MAX - 1) .. "."
  5040. end
  5041. caption = "Best builds vs " .. name
  5042. if others > 0 then
  5043. caption = caption .. " +" .. others
  5044. end
  5045. end
  5046. if options.autoQueueMode.value == 'always' then
  5047. caption = caption .. " - filling factories"
  5048. elseif armed and yielded then
  5049. caption = caption .. " - excess, yours to spend"
  5050. elseif armed then
  5051. caption = caption .. " - spending excess"
  5052. end
  5053. headerLabel:SetCaption(caption)
  5054. headerLabel.tooltip = FocusTooltip() .. "\n" .. (AutoQueueState())
  5055. end
  5056.  
  5057. --------------------------------------------------------------------------------
  5058. -- The debug panel
  5059. --
  5060. -- A second window, off by default, showing the working rather than the result: who is being
  5061. -- countered and how heavily each enemy type weighs, the multiplier chain behind each of the
  5062. -- four picks, and an itemised account of what the top pick is being charged for.
  5063. --
  5064. -- Separate from the panel above rather than an expansion of it, for three reasons. It is
  5065. -- read while tuning the sliders and closed again afterwards, so it wants its own show/hide
  5066. -- and its own docked position. It wants a width the four-row panel would look absurd at. And
  5067. -- it is the only window here that is read rather than glanced at, so it is the only one that
  5068. -- keeps a background instead of floating transparently over the map.
  5069. --------------------------------------------------------------------------------
  5070.  
  5071. local dbgWindow, dbgScroll, dbgHolder
  5072. local dbgLines = {} -- pooled labels, one per line, grown to the high-water mark and reused
  5073.  
  5074. local DBG_W = 470 -- text column width; the window is this plus padding and scrollbar
  5075. local DBG_LINE_H = 15
  5076. local DBG_PAD = 6
  5077. local DBG_MAX_ARMY = 12 -- enemy types listed in full before the tail is summarised
  5078. local DBG_MAX_LIST = 3 -- units named inside a single line of reasoning
  5079.  
  5080. -- Inline colour escapes rather than a font per line, because the panel is one pool of
  5081. -- identical labels and each line carries its own colour -- which is what lets a section be
  5082. -- reordered, lengthened or dropped without touching the layout. Components must be 1-255,
  5083. -- so a zero byte is written 001; none of these needs one.
  5084. local DBG_COLOR = {
  5085. head = "\255\255\215\100",
  5086. sub = "\255\150\200\255",
  5087. text = "\255\230\235\245",
  5088. dim = "\255\150\160\180",
  5089. good = "\255\130\220\140",
  5090. bad = "\255\240\140\125",
  5091. }
  5092.  
  5093. local FONT_DBG = {
  5094. size = 12,
  5095. color = {1, 1, 1, 1},
  5096. outline = true,
  5097. outlineWidth = 3,
  5098. outlineWeight = 6,
  5099. outlineColor = {0, 0, 0, 1},
  5100. }
  5101.  
  5102. -- The whole panel as a flat list of coloured lines. Kept apart from the Chili plumbing
  5103. -- below because this is the part worth reading, and the part that has to change whenever the
  5104. -- scoring does.
  5105. --
  5106. -- No column past the first is aligned, and that is deliberate: Zero-K ships no monospaced
  5107. -- font, so padding a name to a fixed width buys ragged columns rather than straight ones.
  5108. -- The one number that does line up is the leading percentage, which is what the eye runs
  5109. -- down.
  5110. local function DebugText()
  5111. local out = {}
  5112. local C = DBG_COLOR
  5113.  
  5114. local function Add(colour, fmt, ...)
  5115. out[#out + 1] = colour .. ((select('#', ...) > 0) and string.format(fmt, ...) or fmt)
  5116. end
  5117. local function Pct(x)
  5118. -- A share that is not zero must not print as zero, and one that is not the whole
  5119. -- army must not print as the whole army. On a panel of small numbers both roundings
  5120. -- read as a bug in the ranking rather than as a rounding, and the second one hides
  5121. -- exactly the case worth seeing: a lopsided army with a tail that still counts.
  5122. if x ~= 0 and math.abs(x) < 0.005 then
  5123. return (x > 0) and "<1%" or "<-1%"
  5124. end
  5125. if x > 0 and x < 1 and x >= 0.995 then
  5126. return ">99%"
  5127. end
  5128. return string.format("%.0f%%", x * 100)
  5129. end
  5130. -- Names a few entries of a sorted list and counts the rest, so a long tail shows up as a
  5131. -- quantity even where it is not worth listing. `from` is there for the lines that have
  5132. -- already named the head of the list and want what follows it.
  5133. local function Names(list, n, from)
  5134. from = from or 1
  5135. local parts = {}
  5136. for i = from, math.min(from + n - 1, #list) do
  5137. parts[#parts + 1] = unitName[list[i].defID]
  5138. end
  5139. if #parts == 0 then
  5140. return "nothing"
  5141. end
  5142. local s = table.concat(parts, ", ")
  5143. local rest = #list - (from - 1) - #parts
  5144. if rest > 0 then
  5145. s = s .. string.format(" +%d more", rest)
  5146. end
  5147. return s
  5148. end
  5149.  
  5150. ----------------------------------------------------------------------------
  5151. -- What is being countered
  5152. ----------------------------------------------------------------------------
  5153.  
  5154. Add(C.head, "COUNTERING")
  5155.  
  5156. -- Whose army, in the main panel's own words. Borrowed rather than restated so the two
  5157. -- cannot drift apart, and it already explains every focus mode and every fallback.
  5158. for line in (FocusTooltip() .. "\n"):gmatch("(.-)\n") do
  5159. if line ~= "" then
  5160. Add(C.dim, " " .. line)
  5161. end
  5162. end
  5163.  
  5164. local t = trace
  5165. if not t then
  5166. Add(C.bad, " no ranking to show: %s", emptyReason or "waiting for the first one")
  5167. return out
  5168. end
  5169.  
  5170. Add(C.dim, " threat = how many there are x what each costs, buildings counted at half;")
  5171. Add(C.dim, " share = that unit type as a fraction of the threat total, which is what")
  5172. Add(C.dim, " every matchup below is weighted by")
  5173. Add(C.text, " %d enemy unit type%s carrying %d metal of threat",
  5174. #t.army, (#t.army == 1) and "" or "s", t.totalThreat)
  5175. -- What the exclusions took out, as a quantity rather than as a list of settings. A
  5176. -- ranking ignoring half the metal on the field is worth being told about, and each of
  5177. -- these is a deliberate choice that is easy to forget having made.
  5178. for i = 1, #t.dropped do
  5179. local d = t.dropped[i]
  5180. Add(C.dim, " %s excluded: %d type%s, %d metal, not being answered",
  5181. d.label, d.types, (d.types == 1) and "" or "s", d.metal)
  5182. end
  5183. Add(C.dim, " enemy anti-air is %s of their metal; %d buildable units were considered",
  5184. Pct(t.enemyAAShare), t.candidateCount)
  5185.  
  5186. for i = 1, math.min(#t.army, DBG_MAX_ARMY) do
  5187. local a = t.army[i]
  5188. local note = role[a.defID]
  5189. if isStatic[a.defID] then
  5190. note = note .. ", static so counted at half"
  5191. end
  5192. Add(C.text, " %5s %s - %d threat (%s)",
  5193. Pct(a.share), unitName[a.defID], a.threat, note)
  5194. end
  5195. if #t.army > DBG_MAX_ARMY then
  5196. local rest = 0
  5197. for i = DBG_MAX_ARMY + 1, #t.army do
  5198. rest = rest + t.army[i].share
  5199. end
  5200. Add(C.dim, " and %d more type%s, %s of the threat between them",
  5201. #t.army - DBG_MAX_ARMY, (#t.army - DBG_MAX_ARMY == 1) and "" or "s", Pct(rest))
  5202. end
  5203.  
  5204. ----------------------------------------------------------------------------
  5205. -- How the four picks were arrived at
  5206. ----------------------------------------------------------------------------
  5207.  
  5208. Add(C.dim, " ")
  5209. Add(C.head, "TOP 4 CALCULATION")
  5210.  
  5211. local chain = "matchup"
  5212. if t.counterStrength > 0 then chain = chain .. " x counter" end
  5213. if t.qualityWeight > 0 then chain = chain .. " x tier" end
  5214. if t.availWeight > 0 then chain = chain .. " x availability" end
  5215. if t.dampOwnRoles then chain = chain .. " x own roles" end
  5216. Add(C.dim, " score = %s", chain)
  5217. Add(C.dim, " matchup the threat-weighted mean of the counter matrix over their whole")
  5218. Add(C.dim, " army; 1.000 is an even fight, above is counter-picking them")
  5219. Add(C.dim, " won / lost / unreachable how far from even each pairing lands, weighted by")
  5220. Add(C.dim, " share, so the three add back to the matchup exactly")
  5221. Add(C.dim, " x terms multipliers on that mean; 1.000 changes nothing, lower is a cost")
  5222. Add(C.dim, " score all of the above multiplied together, in matchup units, and")
  5223. Add(C.dim, " what the panel prints beside each pick")
  5224. Add(C.dim, " the bar on the panel, each score against the best of the four; the top")
  5225. Add(C.dim, " row is always full, so the bar compares and the number measures")
  5226.  
  5227. for i = 1, #t.picks do
  5228. local e = t.picks[i]
  5229. Add(C.sub, "%d. %s - score %.3f", i, unitName[e.defID], e.score)
  5230. -- base = 1 + won - lost - unreachable exactly, so the three terms take the average
  5231. -- apart rather than commenting on it.
  5232. Add(C.text, " matchup %.3f = 1.000 + %.3f won - %.3f lost - %.3f unreachable",
  5233. e.base, e.gain, e.weak, e.blind)
  5234. if t.counterStrength > 0 then
  5235. local worst = "nothing above it"
  5236. if e.counterUnit then
  5237. worst = "worst " .. unitName[e.counterUnit]
  5238. elseif e.counterRole then
  5239. worst = "worst " .. (ROLE_PLURAL[e.counterRole] or e.counterRole)
  5240. end
  5241. Add(C.text, " counter x%.3f - %s", e.counterDamp, worst)
  5242. end
  5243. if t.qualityWeight > 0 then
  5244. Add(C.text, " tier x%.3f - %s", e.mQuality,
  5245. e.tier and ("tier " .. e.tier) or "unrated, so untouched")
  5246. end
  5247. if t.availWeight > 0 then
  5248. Add(C.text, " avail x%.3f - %d metal, %.0fs to afford + %.0fs to build",
  5249. e.mAvail, cost[e.defID], e.affordWait or 0, e.prodTime or 0)
  5250. end
  5251. if t.dampOwnRoles then
  5252. Add(C.text, " own roles x%.3f - your army is %s %s",
  5253. e.mOwn, Pct((1 - e.mOwn) / 0.6), ROLE_PLURAL[role[e.defID]] or role[e.defID])
  5254. end
  5255. Add(C.dim, " biggest contributors to that mean: %s", Names(e.matchups, DBG_MAX_LIST))
  5256. end
  5257.  
  5258. ----------------------------------------------------------------------------
  5259. -- What is holding the top pick back
  5260. ----------------------------------------------------------------------------
  5261.  
  5262. local top = t.picks[1]
  5263. if not top then
  5264. return out
  5265. end
  5266.  
  5267. Add(C.dim, " ")
  5268. Add(C.head, "DRAGGING DOWN " .. (unitName[top.defID] or "?"):upper() .. ": BY TERM")
  5269. Add(C.dim, " each figure is the share of the score that term took off; the terms")
  5270. Add(C.dim, " multiply rather than add, so they will not sum to the total")
  5271.  
  5272. local eq = string.format(" %.3f = %.3f matchup", top.score, top.base)
  5273. if t.counterStrength > 0 then eq = eq .. string.format(" x %.3f counter", top.counterDamp) end
  5274. if t.qualityWeight > 0 then eq = eq .. string.format(" x %.3f tier", top.mQuality) end
  5275. if t.availWeight > 0 then eq = eq .. string.format(" x %.3f avail", top.mAvail) end
  5276. if t.dampOwnRoles then eq = eq .. string.format(" x %.3f own", top.mOwn) end
  5277. Add(C.text, eq)
  5278.  
  5279. -- Every cost the pick was charged, in one order, so the largest is the one worth arguing
  5280. -- with. The multipliers are read as the fraction of the score each removed; the two
  5281. -- matchup losses are read as the fraction of a clean sweep of the army they cost, which
  5282. -- puts all five on the same scale rather than mixing a multiplier with a raw difference.
  5283. local clean = top.base + top.weak + top.blind
  5284. local drag = {}
  5285. local function Charge(loss, label, detail)
  5286. if loss > 0.005 then
  5287. drag[#drag + 1] = {loss = loss, label = label, detail = detail}
  5288. end
  5289. end
  5290.  
  5291. local counterDetail
  5292. if t.matrixCounter then
  5293. local worst = top.pressureOn[1]
  5294. counterDetail = string.format("%.2f counter pressure over the army", top.pressure)
  5295. if worst then
  5296. counterDetail = counterDetail .. string.format("; %s carries %.2f of it",
  5297. unitName[worst.defID], worst.bad)
  5298. if #top.pressureOn > 1 then
  5299. counterDetail = counterDetail .. string.format(", then %s",
  5300. Names(top.pressureOn, DBG_MAX_LIST, 2))
  5301. end
  5302. end
  5303. elseif top.counterRole then
  5304. counterDetail = string.format("the enemy has enough %s to screen this out",
  5305. ROLE_PLURAL[top.counterRole] or top.counterRole)
  5306. end
  5307. Charge(1 - top.counterDamp, "counter presence", counterDetail)
  5308.  
  5309. Charge(1 - top.mAvail, "availability", string.format(
  5310. "%d metal: %.0fs to afford, %.0fs to build, against a %ds horizon",
  5311. cost[top.defID], top.affordWait or 0, top.prodTime or 0, t.horizon))
  5312.  
  5313. Charge(1 - top.mOwn, "own roles", string.format("your army is already %s %s",
  5314. Pct((1 - top.mOwn) / 0.6), ROLE_PLURAL[role[top.defID]] or role[top.defID]))
  5315.  
  5316. Charge(1 - top.mQuality, "tier", top.tier and ("rated " .. top.tier) or "unrated")
  5317.  
  5318. if clean > 0 then
  5319. Charge(top.blind / clean, "no weapon for part of their army", string.format(
  5320. "%s of their metal it cannot shoot at all: %s",
  5321. Pct(top.blind), Names(top.blindOn, DBG_MAX_LIST)))
  5322.  
  5323. -- Which pairings the "lost" term is actually made of, worst first. Weighted by share,
  5324. -- because a hopeless matchup against 2% of the army is not what is holding a pick down.
  5325. local losers = {}
  5326. for i = 1, #top.matchups do
  5327. local m = top.matchups[i]
  5328. if m.pair > 0 and m.pair < 1 then
  5329. losers[#losers + 1] = {
  5330. defID = m.defID, pair = m.pair, share = m.share,
  5331. loss = m.share * (1 - m.pair),
  5332. }
  5333. end
  5334. end
  5335. table.sort(losers, function(a, b) return a.loss > b.loss end)
  5336. local detail = "nothing in particular"
  5337. if losers[1] then
  5338. detail = string.format("%s at %.2f (%s of their army)",
  5339. unitName[losers[1].defID], losers[1].pair, Pct(losers[1].share))
  5340. if losers[2] then
  5341. detail = detail .. string.format(", then %s at %.2f",
  5342. unitName[losers[2].defID], losers[2].pair)
  5343. end
  5344. end
  5345. Charge(top.weak / clean, "matchups it loses", detail)
  5346. end
  5347.  
  5348. table.sort(drag, function(a, b) return a.loss > b.loss end)
  5349.  
  5350. if #drag == 0 then
  5351. Add(C.good, " nothing is holding it back -- every term is at or above 1.")
  5352. end
  5353. for i = 1, #drag do
  5354. local d = drag[i]
  5355. Add(C.bad, " -%-5s %s", Pct(d.loss), d.label)
  5356. if d.detail then
  5357. Add(C.dim, " %s", d.detail)
  5358. end
  5359. end
  5360.  
  5361. -- The ceiling, so the costs above have something to be a fraction of: what this pick
  5362. -- would score against this army if it beat everything in it and nothing charged it.
  5363. Add(C.dim, " a clean sweep of this army would score %.3f, against %.3f now",
  5364. clean * math.max(top.mQuality, 1), top.score)
  5365.  
  5366. local second = t.picks[2]
  5367. if second and second.score > 0 then
  5368. Add(C.dim, " ahead of %s by %.3f, which is %s more",
  5369. unitName[second.defID], top.score - second.score,
  5370. Pct(top.score / second.score - 1))
  5371. end
  5372.  
  5373. ----------------------------------------------------------------------------
  5374. -- What is holding the top pick back, enemy by enemy
  5375. ----------------------------------------------------------------------------
  5376.  
  5377. if top.perUnit and #top.perUnit > 0 then
  5378. Add(C.dim, " ")
  5379. Add(C.head, "DRAGGING DOWN " .. (unitName[top.defID] or "?"):upper() .. ": BY UNIT")
  5380. Add(C.dim, " each figure is what the score would gain if that unit type left the")
  5381. Add(C.dim, " field: the matchup average and the counter damping both rebuilt over")
  5382. Add(C.dim, " the army without it. One number per enemy, on the score's own scale.")
  5383.  
  5384. local drags, helps = {}, {}
  5385. for i = 1, #top.perUnit do
  5386. local u = top.perUnit[i]
  5387. -- Rounded to the same place it is printed at, so nothing appears on a list
  5388. -- claiming to have moved the score by 0%.
  5389. if u.drag / top.score >= 0.005 then
  5390. drags[#drags + 1] = u
  5391. elseif u.drag / top.score <= -0.005 then
  5392. helps[#helps + 1] = u
  5393. end
  5394. end
  5395.  
  5396. if #drags == 0 then
  5397. Add(C.good, " no enemy unit type is holding it back on its own.")
  5398. end
  5399. for i = 1, #drags do
  5400. local u = drags[i]
  5401. Add(C.bad, " +%-5s %s", Pct(u.drag / top.score), unitName[u.defID])
  5402. local how
  5403. if u.pair <= 0 then
  5404. how = "cannot be shot at all"
  5405. elseif u.pair < 1 then
  5406. how = string.format("matrix %.2f against it", u.pair)
  5407. else
  5408. how = string.format("matrix %.2f, so the loss is its weight, not the matchup",
  5409. u.pair)
  5410. end
  5411. -- Only once it survives the rounding it is printed at: "0.00 of the counter
  5412. -- pressure" is a claim that something is happening, made about nothing.
  5413. if u.bad >= 0.005 then
  5414. how = how .. string.format("; %.2f of the counter pressure", u.bad)
  5415. end
  5416. Add(C.dim, " %s of their army, %s", Pct(u.share), how)
  5417. end
  5418.  
  5419. -- The other direction, because a pick sitting where it is on the back of one enemy
  5420. -- unit type is exactly as fragile as one held down by one, and reads the same on the
  5421. -- main panel.
  5422. for i = 1, math.min(2, #helps) do
  5423. local u = helps[#helps + 1 - i]
  5424. Add(C.good, " %-6s %s is carrying it: %s of their army, matrix %.2f",
  5425. Pct(u.drag / top.score), unitName[u.defID], Pct(u.share), u.pair)
  5426. end
  5427. end
  5428.  
  5429. return out
  5430. end
  5431.  
  5432. local function RefreshDebug()
  5433. if not (dbgWindow and dbgHolder) then
  5434. return
  5435. end
  5436.  
  5437. local lines = DebugText()
  5438. for i = 1, #lines do
  5439. local lbl = dbgLines[i]
  5440. if not lbl then
  5441. -- autosize off, as on the main panel: an autosizing label rewrites its own x and
  5442. -- width from the caption on every SetCaption, and these captions change every
  5443. -- refresh. A fixed box lets DrawInBox do the placing instead.
  5444. lbl = Chili.Label:New{
  5445. parent = dbgHolder,
  5446. x = DBG_PAD, y = 0,
  5447. width = DBG_W, height = DBG_LINE_H,
  5448. autosize = false,
  5449. caption = "",
  5450. font = FontCopy(FONT_DBG),
  5451. }
  5452. dbgLines[i] = lbl
  5453. end
  5454. lbl:SetPos(DBG_PAD, DBG_PAD + (i - 1) * DBG_LINE_H)
  5455. lbl:SetCaption(lines[i])
  5456. lbl:Show()
  5457. end
  5458. -- Kept rather than disposed: the line count swings with the enemy army and with which
  5459. -- terms are switched on, and rebuilding a label pool every second to save a few hidden
  5460. -- controls is a worse trade than holding the high-water mark.
  5461. for i = #lines + 1, #dbgLines do
  5462. dbgLines[i]:Hide()
  5463. end
  5464.  
  5465. -- The holder is what the scroll panel scrolls; the window keeps whatever size it was
  5466. -- dragged to in tweak mode.
  5467. dbgHolder:SetPos(nil, nil, nil, DBG_PAD * 2 + #lines * DBG_LINE_H)
  5468. end
  5469.  
  5470. local function CreateDebugWindow()
  5471. dbgWindow = Chili.Window:New{
  5472. parent = Chili.Screen0,
  5473. name = "BuildProposerDebug",
  5474. classname = "main_window_small_very_flat",
  5475. dockable = true,
  5476. dockableSavePositionOnly = true,
  5477. parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
  5478. x = 1,
  5479. y = 46,
  5480. -- Room for the scrollbar beside the text column.
  5481. clientWidth = DBG_W + DBG_PAD * 2 + 16,
  5482. clientHeight = 460,
  5483. draggable = false,
  5484. resizable = false,
  5485. tweakDraggable = true,
  5486. tweakResizable = true,
  5487. padding = {6, 6, 6, 6},
  5488. -- The one window here that is read line by line, so the one that does not float
  5489. -- transparently: a wall of small numbers over the map is unreadable without a ground
  5490. -- behind it.
  5491. color = {0, 0, 0, 0},
  5492. backgroundColor = {0.05, 0.06, 0.09, 0.85},
  5493. borderColor = {0.40, 0.45, 0.55, 0.55},
  5494. }
  5495.  
  5496. dbgScroll = Chili.ScrollPanel:New{
  5497. parent = dbgWindow,
  5498. x = 0, y = 0, right = 0, bottom = 0,
  5499. padding = {0, 0, 0, 0},
  5500. borderColor = {0, 0, 0, 0},
  5501. backgroundColor = {0, 0, 0, 0},
  5502. horizontalScrollbar = false,
  5503. }
  5504.  
  5505. dbgHolder = Chili.Control:New{
  5506. parent = dbgScroll,
  5507. x = 0, y = 0, right = 0,
  5508. height = 1,
  5509. padding = {0, 0, 0, 0},
  5510. }
  5511. end
  5512.  
  5513. -- Show/hide only, like the panel above, so the docked position survives a toggle. Unlike the
  5514. -- panel above this one also has to ask for a ranking: nothing is traced while it is closed,
  5515. -- so on opening there is no working to show until the next recompute, and waiting a whole
  5516. -- update interval to find that out is how a debug panel gets a reputation for being broken.
  5517. function SetDebugVisible(visible)
  5518. if not dbgWindow then
  5519. return
  5520. end
  5521. if visible then
  5522. dbgWindow:Show()
  5523. nextRefresh = 0 -- recompute now, with tracing on this time
  5524. dirty = true
  5525. else
  5526. dbgWindow:Hide()
  5527. end
  5528. end
  5529.  
  5530. --------------------------------------------------------------------------------
  5531. -- Callins
  5532. --------------------------------------------------------------------------------
  5533.  
  5534. local function RefreshTeamState()
  5535. myTeamID = spGetMyTeamID()
  5536. myAllyTeamID = spGetMyAllyTeamID()
  5537. amSpectating = select(1, Spring.GetSpectatingState())
  5538. RebuildEnemyTeams()
  5539. end
  5540.  
  5541. function M:Initialize()
  5542. Chili = WG.Chili
  5543. if not Chili then
  5544. Spring.Echo("Build Proposer: WG.Chili unavailable, removing widget.")
  5545. widgetHandler:RemoveWidget(widget)
  5546. return
  5547. end
  5548.  
  5549. RefreshTeamState()
  5550.  
  5551. -- Sample the map once to find how much of it is under water. Ships and submarines are
  5552. -- not worth proposing on a map that has nowhere to float them.
  5553. local sx, sz = Game.mapSizeX, Game.mapSizeZ
  5554. local step = 256
  5555. local below, total = 0, 0
  5556. for x = 0, sx, step do
  5557. for z = 0, sz, step do
  5558. total = total + 1
  5559. if (spGetGroundHeight(x, z) or 0) < 0 then
  5560. below = below + 1
  5561. end
  5562. end
  5563. end
  5564. waterFraction = (total > 0) and (below / total) or 0
  5565.  
  5566. -- Published so the Engagement Predictor scores a fight with the same archetype
  5567. -- classification and counter matrix that ranks the build here, rather than carrying a
  5568. -- second copy that drifts apart every time this one is tuned. The scoring functions take
  5569. -- no weights and blend nothing: the flat matrix holds one value per ordered pair, and a
  5570. -- row that does not name the target falls back to its column for that class, so every
  5571. -- pairing has an answer without a consumer having to ask for one.
  5572. WG.CounterMatrix = {
  5573. -- 2 adds UNIT_TIER / TIER_VALUE / Quality. Consumers should treat Quality as
  5574. -- optional and fall back to 1 so an older copy of this widget still works.
  5575. -- 3 adds matrixName and the commander rows behind it.
  5576. -- 4 adds waterFraction, so a consumer deciding whether to propose something that
  5577. -- floats does not have to sample the map a second time.
  5578. version = 4,
  5579.  
  5580. -- Per-unitDef, built once at load.
  5581. role = role, cost = cost, health = health, range = range, speed = speed,
  5582. dpsGround = dpsGround, dpsAir = dpsAir, dpsWater = dpsWater,
  5583. isAir = isAir, isStatic = isStatic, isArmed = isArmed, isAA = isAA, isSub = isSub,
  5584. isFactory = isFactory, buildSpeed = buildSpeed,
  5585. defName = defName, unitName = unitName, iconFile = iconFile,
  5586.  
  5587. -- Share of the map under water, sampled once above, and the share below which there
  5588. -- is nowhere worth floating a ship. Both, rather than the verdict: a consumer that
  5589. -- wants a different threshold should not have to re-sample the map to get one.
  5590. waterFraction = waterFraction, WATER_THRESHOLD = WATER_THRESHOLD,
  5591. -- The key to index MATRIX by. Same as defName for everything except a commander,
  5592. -- which answers to one of four chassis rows rather than to its own unitDef.
  5593. matrixName = matrixName,
  5594.  
  5595. -- The matrix itself: MATRIX[matrixName[my]][matrixName[enemy]] is the whole answer,
  5596. -- with MATRIX[...]["#" .. col] as the fallback for a target it does not name.
  5597. MATRIX = MATRIX, ROLE_PLURAL = ROLE_PLURAL, COUNTERED_BY = COUNTERED_BY,
  5598.  
  5599. -- Scoring. ColumnArch(enemyDef); MatrixValue(myDef, enemyDef, col);
  5600. -- PairScore(myDef, enemyDef, enemyAAShare).
  5601. ColumnArch = ColumnArch, MatrixValue = MatrixValue, PairScore = PairScore,
  5602.  
  5603. -- The other side of the same question. CanEngage(shooter, target) -> bool;
  5604. -- CounterPressure(myDef, enemyDef) -> how hard that one enemy holds myDef back,
  5605. -- in log space and never below 0. Weight it by the enemy unit's share of the army
  5606. -- and sum over the army; 1 - exp(-sum) is the severity this widget damps by.
  5607. CanEngage = CanEngage, CounterPressure = CounterPressure,
  5608.  
  5609. -- Tier list. Quality(defID) -> multiplier, 1.0 for anything unrated. Orthogonal to
  5610. -- the matrix: it says how good a unit is, not what it is good against, so a consumer
  5611. -- applies it per unit rather than per pairing.
  5612. UNIT_TIER = UNIT_TIER, TIER_VALUE = TIER_VALUE, Quality = Quality,
  5613. }
  5614.  
  5615. CreateWindow()
  5616. CreateIconWindow()
  5617. SetPanelVisible(options.showPanel.value)
  5618. CreateDebugWindow()
  5619. SetDebugVisible(options.debugPanel.value)
  5620. dirty = true
  5621. end
  5622.  
  5623. function M:Shutdown()
  5624. WG.CounterMatrix = nil
  5625. -- Leaving our orders behind would be worse than never having placed them.
  5626. RetractAll()
  5627. if window_main then
  5628. window_main:Dispose()
  5629. window_main = nil
  5630. end
  5631. if iconPanel.window then
  5632. iconPanel.window:Dispose()
  5633. iconPanel = {}
  5634. end
  5635. if dbgWindow then
  5636. dbgWindow:Dispose()
  5637. dbgWindow = nil
  5638. dbgHolder, dbgScroll = nil, nil
  5639. dbgLines = {} -- the pool went with the window
  5640. end
  5641. end
  5642.  
  5643. function M:PlayerChanged()
  5644. local prevTeam = myTeamID
  5645. RefreshTeamState()
  5646. if myTeamID ~= prevTeam then
  5647. -- Now looking from a different seat: those orders are not ours to manage any more.
  5648. autoOrders = {}
  5649. pendingAuto = nil
  5650. ourTags = {}
  5651. seenTags = {}
  5652. armed = false
  5653. yielded = false
  5654. playerTags = {}
  5655. -- Whoever the previous seat was fighting is not our fight any more.
  5656. engagement = {}
  5657. harm = {}
  5658. lastFought = {}
  5659. attackers = {}
  5660. focusTeams, focusWeights, focusUnits, focusMain, focusNote = nil, nil, nil, nil, nil
  5661. focusActive = false
  5662. dirty = true
  5663. end
  5664. end
  5665.  
  5666. function M:Update()
  5667. local now = os.clock()
  5668. clockNow = now
  5669.  
  5670. -- Runs unconditionally: an order still in flight has to be claimed before it can ever
  5671. -- be pulled back off the queue.
  5672. ResolvePending(now)
  5673. UpdateArmed()
  5674.  
  5675. if now >= nextRefresh then
  5676. nextRefresh = now + options.updateRate.value
  5677. DecayEngagement(now)
  5678. UpdateFocus()
  5679. Recompute()
  5680. dirty = true
  5681. end
  5682.  
  5683. UpdateAutoQueue(now)
  5684.  
  5685. -- Nothing to lay out while hidden, and `dirty` is left standing so the first refresh
  5686. -- after unhiding draws the ranking as it stands then rather than as it was. The two
  5687. -- windows hide independently, so either one being open is reason enough to redraw.
  5688. if dirty and (options.showPanel.value or options.debugPanel.value) then
  5689. dirty = false
  5690. if options.showPanel.value then
  5691. RefreshLayout()
  5692. end
  5693. if options.debugPanel.value then
  5694. RefreshDebug()
  5695. end
  5696. end
  5697. end
  5698.  
  5699. RegisterModule{
  5700. callins = M,
  5701. prefix = 'bp_',
  5702. optionsPath = options_path,
  5703. options = options,
  5704. optionsOrder = options_order,
  5705. }
  5706. end
  5707.  
  5708. --------------------------------------------------------------------------------
  5709. -- Fac Switcher
  5710. --
  5711. -- Build Proposer answers "what should this factory make next". This answers the question
  5712. -- one level up: given what the enemy has, which factory should be on the field at all.
  5713. --
  5714. -- A factory is not a unit, so it is not ranked like one. It is a roster, and what matters
  5715. -- is whether that roster has *an* answer to each thing the enemy owns -- not how good its
  5716. -- average unit is. So each enemy unit type is scored against the best answer the factory
  5717. -- could build for it, and those per-enemy answers are then averaged over the enemy army by
  5718. -- threat. A factory with one crushing answer and nothing for the other half of the field
  5719. -- ranks below one that covers everything adequately, which is the whole reason the average
  5720. -- is a power mean rather than an arithmetic one: see `holeWeight`.
  5721. --
  5722. -- It owns no scoring of its own. The archetypes, the matrix and PairScore all arrive
  5723. -- through WG.CounterMatrix and the enemy army through WG.EnemyIntel, exactly as
  5724. -- Engagement Predictor takes them, so a factory ranking and a unit ranking can never
  5725. -- disagree about what counters what.
  5726. --------------------------------------------------------------------------------
  5727.  
  5728. do
  5729. local M = {} -- this module's callins, collected by RegisterModule below
  5730. local options_path, options_order, options -- were file-scope globals
  5731.  
  5732. --------------------------------------------------------------------------------
  5733. -- Includes / static per-unitDef data
  5734. --
  5735. -- Only what is needed to draw a row, price a factory and know what it builds. Everything
  5736. -- that decides a matchup comes from WG.CounterMatrix at refresh time.
  5737. --------------------------------------------------------------------------------
  5738.  
  5739. local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
  5740. local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
  5741.  
  5742. local iconFormat = ".dds"
  5743. do
  5744. -- chilitip_conf.lua returns: ignoreweapon, iconFormat
  5745. local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
  5746. if ok and fmt then
  5747. iconFormat = fmt
  5748. end
  5749. end
  5750.  
  5751. local spGetTeamUnits = Spring.GetTeamUnits
  5752. local spGetUnitDefID = Spring.GetUnitDefID
  5753. local spGetMyTeamID = Spring.GetMyTeamID
  5754. local spGetCmdDescIndex = Spring.GetCmdDescIndex
  5755. local spSetActiveCommand = Spring.SetActiveCommand
  5756. local spGetModKeyState = Spring.GetModKeyState
  5757. local spEcho = Spring.Echo
  5758.  
  5759. local floor, max, min, exp, log = math.floor, math.max, math.min, math.exp, math.log
  5760.  
  5761. local DEFAULT_ICON = "icons/default.dds"
  5762.  
  5763. local iconFile = {}
  5764. local unitName = {}
  5765. local cost = {}
  5766. local isStatic = {}
  5767. local needsWater = {}
  5768. -- facDefID -> the mobile units it builds, and nil for everything else. Having a roster here
  5769. -- is the whole test for being a factory worth ranking, and three things are turned away at
  5770. -- this one gate rather than at each use.
  5771. --
  5772. -- `isFactory` is an engine flag over buildings that build, so it is also true of a Caretaker
  5773. -- and of the Missile Silo; what separates them from a plant you would switch to is not the
  5774. -- flag but whether anything mobile comes out, so that is what is asked.
  5775. --
  5776. -- A factory plate builds its parent's roster from a second, cheaper pad. Ranking one is
  5777. -- ranking the factory it is attached to, so it would simply duplicate a row.
  5778. local roster = {}
  5779.  
  5780. for defID = 1, #UnitDefs do
  5781. local ud = UnitDefs[defID]
  5782.  
  5783. local it = ud.iconType or "default"
  5784. local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
  5785. or ("icons/" .. it .. iconFormat)
  5786. -- Zero-K's own square build texture, which the engine renders from the model, rather
  5787. -- than icons/default.dds -- that file is fully transparent, so a missing icon drew a
  5788. -- blank box with a count beside it (submarine, none, the legacy tank/kbot set).
  5789. if not VFS.FileExists(path) then
  5790. path = "#" .. defID
  5791. end
  5792. iconFile[defID] = path
  5793.  
  5794. unitName[defID] = ud.humanName or ud.name or "?"
  5795. cost[defID] = ud.metalCost or 0
  5796. isStatic[defID] = (ud.speed or 0) == 0
  5797. needsWater[defID] = (ud.minWaterDepth or 0) > 0
  5798.  
  5799. local cp = ud.customParams
  5800. local isPlate = (cp and cp.child_of_factory) and true or false
  5801.  
  5802. local opts = ud.isFactory and not isPlate and ud.buildOptions
  5803. if opts then
  5804. local list, n = {}, 0
  5805. for i = 1, #opts do
  5806. local o = opts[i]
  5807. if (UnitDefs[o].speed or 0) > 0 then
  5808. n = n + 1
  5809. list[n] = o
  5810. end
  5811. end
  5812. if n > 0 then
  5813. roster[defID] = list
  5814. end
  5815. end
  5816. end
  5817.  
  5818. -- "factorycloak" -> "Cloak", "striderhub" -> "Strider". Short enough for a header line,
  5819. -- where the full "Cloakbot Factory" would not fit beside the verdict.
  5820. local function FacTag(facDefID)
  5821. local n = UnitDefs[facDefID] and UnitDefs[facDefID].name or "?"
  5822. local tag = n:match("^factory(.+)$") or n:match("^(.+)hub$") or n
  5823. return tag:sub(1, 1):upper() .. tag:sub(2)
  5824. end
  5825.  
  5826. --------------------------------------------------------------------------------
  5827. -- State (declared before `options` so the option closures can capture them)
  5828. --------------------------------------------------------------------------------
  5829.  
  5830. local PICK_COUNT = 3
  5831.  
  5832. -- The lowest value in the counter matrix. A roster with nothing at all for some enemy type
  5833. -- scores that type at the floor rather than at zero: "the worst matchup in the table" is a
  5834. -- fair reading of having no answer, and it keeps the geometric mean below from collapsing
  5835. -- the whole factory to nothing over one unit it cannot touch.
  5836. local MIN_ANSWER = 0.05
  5837.  
  5838. -- Enemy buildings can usually be walked around, so they weigh less than their metal. Same
  5839. -- figure Build Proposer uses, deliberately: the two panels rank against one army and should
  5840. -- not privately disagree about how much of it is worth answering.
  5841. local STATIC_THREAT_WEIGHT = 0.5
  5842.  
  5843. local WATER_THRESHOLD = 0.12 -- fallback only; WG.CounterMatrix 4 publishes the real one
  5844.  
  5845. -- What an answer may cost before it starts being charged for: a quarter of the enemy army,
  5846. -- or 750 metal, whichever is more. The floor is set above the ordinary unit range on
  5847. -- purpose. This term exists to rule out answering a skirmish with a Detriment, not to pick
  5848. -- between a Ronin and a Ripper, and without a floor the first Glaive anyone scouts makes
  5849. -- every unit in the game look like an extravagance.
  5850. local COMMIT_FREE = 0.25
  5851. local COMMIT_REF_MIN = 3000
  5852.  
  5853. local picks = {} -- top PICK_COUNT, best first
  5854. local ownedBest = nil -- highest-ranked factory we already own, for the switch verdict
  5855. local emptyReason = nil
  5856. local dirty = true
  5857. local nextRefresh = 0
  5858.  
  5859. local myTeamID = spGetMyTeamID()
  5860. local amSpectating = false
  5861.  
  5862. local SetPanelVisible -- forward declaration, defined below
  5863.  
  5864. --------------------------------------------------------------------------------
  5865. -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
  5866. --------------------------------------------------------------------------------
  5867.  
  5868. options_path = 'Settings/HUD Panels/Fac Switcher'
  5869. options_order = {'showPanel', 'updateRate', 'holeWeight', 'commitWeight', 'qualityWeight',
  5870. 'showOwned',
  5871. 'mobileOnly', 'ignoreCommanders', 'ignoreCons', 'ignoreAA', 'ignoreAir',
  5872. 'ignoreEco'}
  5873. options = {
  5874. showPanel = {
  5875. name = 'Show Fac Switcher panel',
  5876. desc = 'Hide the factory ranking without unloading the widget. Nothing here outlives '
  5877. .. 'the panel -- the ranking is recomputed from live intel every tick and '
  5878. .. 'published to no one -- so while hidden it stops scoring entirely, and picks '
  5879. .. 'the current enemy army back up the moment it is shown.',
  5880. type = 'bool',
  5881. value = true,
  5882. OnChange = function(self)
  5883. SetPanelVisible(self.value)
  5884. end,
  5885. },
  5886. updateRate = {
  5887. name = 'Fac Switcher update interval (s)',
  5888. desc = 'How often the ranking is recomputed. A factory choice moves over minutes, so '
  5889. .. 'there is nothing to gain from a fast refresh here.',
  5890. type = 'number',
  5891. min = 0.5, max = 10, step = 0.5, value = 2,
  5892. },
  5893. holeWeight = {
  5894. name = 'Punish gaps in the roster',
  5895. desc = 'How much a factory is judged by what it has no answer to, rather than by its '
  5896. .. 'answers on average. At 0 a crushing counter to half the enemy army makes up '
  5897. .. 'for having nothing for the other half. At 0.5 the answers are multiplied '
  5898. .. 'rather than added, so a gap drags the whole factory down. At 1 the worst '
  5899. .. 'matchup in the roster very nearly decides the ranking on its own.',
  5900. type = 'number',
  5901. min = 0, max = 1, step = 0.05, value = 0.5,
  5902. OnChange = function() dirty = true end,
  5903. },
  5904. commitWeight = {
  5905. name = 'Punish answers you could not field',
  5906. desc = 'How hard an answer is held back for costing more than the fight is worth. The '
  5907. .. 'counter matrix rates a matchup by class and says nothing about price, so '
  5908. .. 'without this a Detriment is the answer to a Glaive swarm and the Strider Hub '
  5909. .. 'wins every ranking. At 0 the matrix is taken at its word.',
  5910. type = 'number',
  5911. min = 0, max = 2, step = 0.05, value = 1,
  5912. OnChange = function() dirty = true end,
  5913. },
  5914. qualityWeight = {
  5915. name = 'Weight of the unit tier list',
  5916. desc = 'How much a factory is credited for answering with a strong unit rather than '
  5917. .. 'merely a correct one. Applied while choosing which unit answers each enemy '
  5918. .. 'type, so it can change the answer and not only the score.',
  5919. type = 'number',
  5920. min = 0, max = 2, step = 0.05, value = 0.5,
  5921. OnChange = function() dirty = true end,
  5922. },
  5923. showOwned = {
  5924. name = 'Include factories you own',
  5925. desc = 'Keep the factories already on the field in the ranking, so the panel can say '
  5926. .. 'your current plant is still the right one. Turn off to see only what you '
  5927. .. 'would be switching to.',
  5928. type = 'bool',
  5929. value = true,
  5930. OnChange = function()
  5931. dirty = true
  5932. end,
  5933. },
  5934. -- The same six filters as Build Proposer, with the same tests, in the same order, but
  5935. -- set separately: which enemy units a factory has to answer is not necessarily the
  5936. -- same question as which ones the next unit has to.
  5937. mobileOnly = {
  5938. name = 'Fac Switcher: counter mobile units only',
  5939. desc = 'Leave enemy buildings out of the army the factories are ranked against. Static anti-air still counts against air factories either way.',
  5940. type = 'bool',
  5941. value = true,
  5942. OnChange = function() dirty = true end,
  5943. },
  5944. ignoreCommanders = {
  5945. name = 'Fac Switcher: ignore enemy commanders',
  5946. desc = 'Leave enemy commanders out of the army the factories are ranked against.',
  5947. type = 'bool',
  5948. value = true,
  5949. OnChange = function() dirty = true end,
  5950. },
  5951. ignoreCons = {
  5952. name = 'Fac Switcher: ignore enemy constructors',
  5953. desc = 'Leave enemy constructors out of the army the factories are ranked against.',
  5954. type = 'bool',
  5955. value = true,
  5956. OnChange = function() dirty = true end,
  5957. },
  5958. ignoreAA = {
  5959. name = 'Fac Switcher: ignore enemy anti-air',
  5960. desc = 'Leave enemy anti-air (the units that only shoot aircraft) out of the army the factories are ranked against. Whether an air factory is viable is still judged against all of it.',
  5961. type = 'bool',
  5962. value = false,
  5963. OnChange = function() dirty = true end,
  5964. },
  5965. ignoreAir = {
  5966. name = 'Fac Switcher: ignore enemy air',
  5967. desc = 'Leave enemy aircraft out of the army the factories are ranked against.',
  5968. type = 'bool',
  5969. value = false,
  5970. OnChange = function() dirty = true end,
  5971. },
  5972. ignoreEco = {
  5973. name = 'Fac Switcher: ignore enemy economy buildings',
  5974. desc = 'Leave unarmed enemy buildings out of the army the factories are ranked against, while their turrets still count.',
  5975. type = 'bool',
  5976. value = true,
  5977. OnChange = function() dirty = true end,
  5978. },
  5979. }
  5980.  
  5981. --------------------------------------------------------------------------------
  5982. -- The ranking
  5983. --------------------------------------------------------------------------------
  5984.  
  5985. -- Every factory we could put down, plus every one already standing. The first comes off our
  5986. -- own builders rather than off a fixed list, so a mission or mod that restricts what a
  5987. -- constructor may build restricts this too. The second is unioned in because a plant we
  5988. -- own is the thing every other row is being compared against, and dropping it out of the
  5989. -- ranking would leave the panel proposing a switch with nothing to switch away from.
  5990. local function GatherFactories()
  5991. local buildable, owned = {}, {}
  5992. local units = spGetTeamUnits(myTeamID)
  5993. if not units then
  5994. return buildable, owned
  5995. end
  5996.  
  5997. for i = 1, #units do
  5998. local unitID = units[i]
  5999. local defID = spGetUnitDefID(unitID)
  6000. if defID then
  6001. if roster[defID] then
  6002. owned[defID] = true
  6003. end
  6004. -- A finished plate that is linked to a working factory -- ours or a teammate's --
  6005. -- builds that factory's whole roster, so for choosing a factory it is as good as
  6006. -- owning one. The link is Zero-K's own verdict: unit_factory_plate publishes
  6007. -- `nofactory` = 0 while the plate has a live, unstunned parent in range.
  6008. -- "plate" rather than true, so the row can say which it is; a factory we really
  6009. -- own overwrites it whichever order the two come up in.
  6010. local cp = UnitDefs[defID].customParams
  6011. local parent = cp and cp.child_of_factory and UnitDefNames[cp.child_of_factory]
  6012. if parent and roster[parent.id] and not owned[parent.id]
  6013. and Spring.GetUnitRulesParam(unitID, "nofactory") == 0
  6014. and not Spring.GetUnitIsBeingBuilt(unitID) then
  6015. owned[parent.id] = "plate"
  6016. end
  6017. local opts = UnitDefs[defID].buildOptions
  6018. if opts then
  6019. for j = 1, #opts do
  6020. local o = opts[j]
  6021. if roster[o] then
  6022. buildable[o] = true
  6023. end
  6024. end
  6025. end
  6026. end
  6027. end
  6028.  
  6029. for defID in pairs(owned) do
  6030. buildable[defID] = true
  6031. end
  6032. return buildable, owned
  6033. end
  6034.  
  6035. local function Recompute()
  6036. picks, ownedBest, emptyReason = {}, nil, nil
  6037.  
  6038. -- Read at refresh time rather than cached at Initialize, the same way Engagement
  6039. -- Predictor reads them: load order is not guaranteed and either producer can be
  6040. -- toggled mid-game.
  6041. local cm = WG.CounterMatrix
  6042. if not cm then
  6043. emptyReason = "Build Proposer is not running"
  6044. return
  6045. end
  6046. local intel = WG.EnemyIntel
  6047. if not intel then
  6048. emptyReason = "Enemy Intel is not running"
  6049. return
  6050. end
  6051.  
  6052. -- Deliberately the whole enemy side, with none of Build Proposer's per-opponent focus
  6053. -- or exclusions. A factory is a commitment for the rest of the game and gets built
  6054. -- once; narrowing it to the two raiders that happen to be shooting at you now is how
  6055. -- you end up with a plant that answers this skirmish and nothing after it.
  6056. local counts = intel.GetEffectiveCounts()
  6057. if not counts or not next(counts) then
  6058. emptyReason = "No enemy intel yet"
  6059. return
  6060. end
  6061.  
  6062. local buildable, owned = GatherFactories()
  6063. if not next(buildable) then
  6064. emptyReason = "Nothing here can build a factory"
  6065. return
  6066. end
  6067.  
  6068. local PairScore = cm.PairScore
  6069. local Quality = cm.Quality -- optional: absent from WG.CounterMatrix version 1
  6070. local cmIsAA, cmDpsAir = cm.isAA, cm.dpsAir
  6071.  
  6072. -- Whether an air factory is viable is judged against everything the enemy owns, static
  6073. -- anti-air included: a Razor shoots your gunships whether or not you meant to attack it.
  6074. local aaThreat, allThreat = 0, 0
  6075. for defID, n in pairs(counts) do
  6076. local t = n * cost[defID]
  6077. if t > 0 then
  6078. allThreat = allThreat + t
  6079. if cmIsAA[defID] or (cmDpsAir[defID] or 0) > 0 then
  6080. aaThreat = aaThreat + t
  6081. end
  6082. end
  6083. end
  6084. local enemyAAShare = (allThreat > 0) and min(aaThreat / allThreat, 0.95) or 0
  6085.  
  6086. -- Build Proposer's exclusions, read through the matrix it publishes so the two panels
  6087. -- cannot disagree about what counts as a constructor. The first test that matches names
  6088. -- the reason, broad before narrow, exactly as there. A field missing from an older
  6089. -- matrix just means that filter never matches.
  6090. local cmRole, cmIsAir, cmIsArmed = cm.role or {}, cm.isAir or {}, cm.isArmed or {}
  6091. local exclusions = {
  6092. {key = 'mobileOnly', label = "buildings",
  6093. test = function(d) return isStatic[d] end},
  6094. {key = 'ignoreEco', label = "economy buildings",
  6095. test = function(d) return isStatic[d] and not cmIsArmed[d] end},
  6096. {key = 'ignoreCommanders', label = "commanders",
  6097. test = function(d) return cmRole[d] == "commander" end},
  6098. {key = 'ignoreCons', label = "constructors",
  6099. test = function(d) return cmRole[d] == "con" and not isStatic[d] end},
  6100. {key = 'ignoreAA', label = "anti-air",
  6101. test = function(d) return cmRole[d] == "aa" end},
  6102. {key = 'ignoreAir', label = "aircraft",
  6103. test = function(d) return cmIsAir[d] end},
  6104. }
  6105. local active = {}
  6106. for i = 1, #exclusions do
  6107. if options[exclusions[i].key].value then
  6108. active[#active + 1] = exclusions[i]
  6109. end
  6110. end
  6111.  
  6112. -- Threat weighting: what fraction of the enemy's committed metal each unit type is.
  6113. local threat, totalThreat = {}, 0
  6114. local droppedLabels = {}
  6115. for defID, n in pairs(counts) do
  6116. local out
  6117. for i = 1, #active do
  6118. if active[i].test(defID) then
  6119. out = active[i].label
  6120. break
  6121. end
  6122. end
  6123. if out then
  6124. droppedLabels[out] = true
  6125. else
  6126. local t = n * cost[defID]
  6127. if isStatic[defID] then
  6128. t = t * STATIC_THREAT_WEIGHT
  6129. end
  6130. if t > 0 then
  6131. threat[defID] = t
  6132. totalThreat = totalThreat + t
  6133. end
  6134. end
  6135. end
  6136. if totalThreat <= 0 then
  6137. -- Name the filters that emptied it: "no enemy intel yet" would be a lie about a
  6138. -- setting the player can move.
  6139. local names = {}
  6140. for label in pairs(droppedLabels) do
  6141. names[#names + 1] = label
  6142. end
  6143. table.sort(names)
  6144. emptyReason = (#names > 0) and ("Only " .. table.concat(names, ", ") .. " seen")
  6145. or "No enemy intel yet"
  6146. return
  6147. end
  6148.  
  6149. -- What one unit may cost before answering with it counts as over-committing. Measured
  6150. -- against the enemy army rather than against our bank, deliberately: a factory is a
  6151. -- decision for the next several minutes, and one that flipped every time the metal bar
  6152. -- filled would be telling you about your storage rather than about the enemy.
  6153. local commitWeight = options.commitWeight.value
  6154. local commitFree = max(totalThreat, COMMIT_REF_MIN) * COMMIT_FREE
  6155.  
  6156. -- No published figure means an older Build Proposer that never sampled the map, and 0
  6157. -- reads as a dry one: a ranking that leaves ships out where they were viable is merely
  6158. -- narrow, one that proposes them on dry land is wrong.
  6159. local dryMap = (cm.waterFraction or 0) < (cm.WATER_THRESHOLD or WATER_THRESHOLD)
  6160. local qualityWeight = options.qualityWeight.value
  6161. local includeOwned = options.showOwned.value
  6162.  
  6163. -- The per-enemy answers are combined with a power mean, and `holeWeight` is its
  6164. -- exponent read backwards: 0 gives the arithmetic mean, 0.5 the geometric mean, 1 the
  6165. -- harmonic one. The exponent is what decides whether a gap can be paid for with a
  6166. -- strength elsewhere -- at p = 1 it can, and the further below that it goes the more
  6167. -- the smallest answer in the set drags the result down towards itself.
  6168. local p = 1 - 2 * options.holeWeight.value
  6169. local geometric = (p > -1e-6 and p < 1e-6)
  6170.  
  6171. local scored = {}
  6172. for facDefID in pairs(buildable) do
  6173. -- A Shipyard cannot be put down on dry land, so on a dry map it is not a choice at
  6174. -- all -- and the per-unit gate below cannot say that for it, because the Shipyard
  6175. -- does build one thing that floats in a puddle. The two gates answer different
  6176. -- questions and the Strider Hub is why both are needed: it stands on land, so it is
  6177. -- never turned away here, but three of the eleven things it builds are ships and
  6178. -- those drop out below. A factory we already own is exempt: whatever this map
  6179. -- samples as, the water it is standing in is evidently real.
  6180. local unplaceable = needsWater[facDefID] and dryMap and not owned[facDefID]
  6181. if (includeOwned or not owned[facDefID]) and not unplaceable then
  6182. -- The roster minus what this map has nowhere to put, filtered once per factory
  6183. -- rather than once per enemy unit type. Nothing else needs excluding: a
  6184. -- constructor or a jammer has no weapon, so PairScore already scores it at
  6185. -- nothing and it can never come out of the max below.
  6186. --
  6187. -- Each survivor carries the multiplier for what it costs to field, worked out
  6188. -- here rather than inside the enemy loop below: it is a fact about the unit and
  6189. -- the size of the fight, and asking it again for every enemy type would be
  6190. -- asking the same question thirty times over.
  6191. local list = roster[facDefID]
  6192. local pool, poolMult, poolN = {}, {}, 0
  6193. for i = 1, #list do
  6194. local d = list[i]
  6195. if not (needsWater[d] and dryMap) then
  6196. poolN = poolN + 1
  6197. pool[poolN] = d
  6198. local m = 1
  6199. if commitWeight > 0 and cost[d] > commitFree then
  6200. m = (commitFree / cost[d]) ^ commitWeight
  6201. end
  6202. poolMult[poolN] = m
  6203. end
  6204. end
  6205.  
  6206. if poolN > 0 then
  6207. local acc = 0
  6208. local answers = {} -- one row per enemy type, for the tooltip
  6209. local worstDef, worstGap, worstAnswer, worstValue = nil, 0, nil, 1
  6210.  
  6211. for eDefID, t in pairs(threat) do
  6212. local w = t / totalThreat
  6213.  
  6214. -- The best this factory could put in front of that enemy type. Tier and
  6215. -- the cost of fielding it are applied here rather than to the finished
  6216. -- score, because both are part of choosing the answer: a factory whose
  6217. -- only correct answer is a weak unit, or one it could never afford to
  6218. -- mass, has answered worse rather than merely scored worse.
  6219. local bestV, bestD = 0, nil
  6220. for i = 1, poolN do
  6221. local d = pool[i]
  6222. local v = PairScore(d, eDefID, enemyAAShare)
  6223. if v > 0 then
  6224. if Quality and qualityWeight > 0 then
  6225. v = v * Quality(d) ^ qualityWeight
  6226. end
  6227. v = v * poolMult[i]
  6228. end
  6229. if v > bestV then
  6230. bestV, bestD = v, d
  6231. end
  6232. end
  6233.  
  6234. local answered = bestD -- nil when nothing in the roster can engage it
  6235. if bestV < MIN_ANSWER then
  6236. bestV = MIN_ANSWER
  6237. end
  6238.  
  6239. acc = acc + w * (geometric and log(bestV) or bestV ^ p)
  6240. answers[#answers + 1] = {
  6241. defID = eDefID, by = answered, value = bestV, contrib = w * bestV,
  6242. }
  6243.  
  6244. -- The hole that costs most: how badly this type is answered, weighted by
  6245. -- how much of the enemy army it is. An unanswerable unit nobody fields
  6246. -- is not what loses the game.
  6247. if bestV < 1 then
  6248. local gap = w * -log(bestV)
  6249. if gap > worstGap then
  6250. worstGap, worstDef = gap, eDefID
  6251. worstAnswer, worstValue = answered, bestV
  6252. end
  6253. end
  6254. end
  6255.  
  6256. local coverage = geometric and exp(acc) or acc ^ (1 / p)
  6257. local score = coverage
  6258.  
  6259. -- Built for every factory and read for three, the same way Build Proposer
  6260. -- gathers its contributors: a dozen sorts of thirty entries once every two
  6261. -- seconds is not worth the bookkeeping of an inline top-three.
  6262. table.sort(answers, function(a, b) return a.contrib > b.contrib end)
  6263.  
  6264. scored[#scored + 1] = {
  6265. defID = facDefID,
  6266. score = score,
  6267. coverage = coverage,
  6268. owned = owned[facDefID] or false,
  6269. answers = answers,
  6270. worstDef = worstDef,
  6271. worstAnswer = worstAnswer,
  6272. worstValue = worstValue,
  6273. }
  6274. end
  6275. end
  6276. end
  6277.  
  6278. if not scored[1] then
  6279. emptyReason = includeOwned and "No factory answers this army"
  6280. or "Every factory here is already built"
  6281. return
  6282. end
  6283. table.sort(scored, function(a, b) return a.score > b.score end)
  6284.  
  6285. -- The incumbent, whether or not it made the top three: it is what the verdict in the
  6286. -- header is measured against.
  6287. for i = 1, #scored do
  6288. if scored[i].owned then
  6289. ownedBest = scored[i]
  6290. break
  6291. end
  6292. end
  6293.  
  6294. local best = scored[1].score
  6295. for i = 1, min(PICK_COUNT, #scored) do
  6296. local pick = scored[i]
  6297. pick.barFill = (best > 0) and (pick.score / best) or 0
  6298. picks[i] = pick
  6299. end
  6300. end
  6301.  
  6302. --------------------------------------------------------------------------------
  6303. -- Chili UI
  6304. --------------------------------------------------------------------------------
  6305.  
  6306. local Chili
  6307. local window_main, holder, headerLabel, emptyLabel
  6308. local rows = {}
  6309.  
  6310. local PAD = 4
  6311. local ICON = 26
  6312. local ROW_H = 34
  6313. local HEADER_H = 18
  6314. local WIN_W = 260
  6315. local TEXT_X = ICON + 8
  6316. local BAR_H = 6
  6317.  
  6318. local FONT_MAIN = {
  6319. size = 13,
  6320. color = {1, 1, 1, 1},
  6321. outline = true,
  6322. outlineWidth = 3,
  6323. outlineWeight = 6,
  6324. outlineColor = {0, 0, 0, 1},
  6325. }
  6326. local FONT_DIM = {
  6327. size = 11,
  6328. color = {0.70, 0.75, 0.85, 1},
  6329. outline = true,
  6330. outlineWidth = 3,
  6331. outlineWeight = 6,
  6332. outlineColor = {0, 0, 0, 1},
  6333. }
  6334. local FONT_HEADER = {
  6335. size = 13,
  6336. color = {1.0, 0.85, 0.40, 1},
  6337. outline = true,
  6338. outlineWidth = 3,
  6339. outlineWeight = 6,
  6340. outlineColor = {0, 0, 0, 1},
  6341. }
  6342.  
  6343. -- Inline font colour escape, the standard Spring form. Components must be 1-255; a zero
  6344. -- byte would terminate the string, which is why Zero-K writes 001 rather than 000.
  6345. local TAG_COLOR = "\255\160\170\190"
  6346.  
  6347. -- Chili's Object:New turns the table you hand it into the instance itself, so a font table
  6348. -- cannot be shared between controls. Every control gets its own copy.
  6349. local function FontCopy(spec)
  6350. local out = {}
  6351. for k, v in pairs(spec) do
  6352. if type(v) == "table" then
  6353. local sub = {}
  6354. for i, x in pairs(v) do
  6355. sub[i] = x
  6356. end
  6357. out[k] = sub
  6358. else
  6359. out[k] = v
  6360. end
  6361. end
  6362. return out
  6363. end
  6364.  
  6365. -- Clicking a row picks the factory's build command up onto the cursor, which is as far as
  6366. -- a panel should go: where a factory is placed decides half of what it is worth, and that
  6367. -- is the player's call rather than this widget's.
  6368. local function RowClicked(self, _, _, mouseButton)
  6369. if mouseButton ~= 1 then
  6370. return
  6371. end
  6372. local pick = picks[self.pickIndex]
  6373. if not pick or amSpectating then
  6374. return
  6375. end
  6376. local index = spGetCmdDescIndex(-pick.defID)
  6377. if not index then
  6378. -- The command only exists while something that can build it is selected, so this is
  6379. -- the ordinary case of clicking with an army selected rather than an error.
  6380. spEcho("Fac Switcher: select a constructor to place the "
  6381. .. unitName[pick.defID] .. ".")
  6382. return
  6383. end
  6384. local alt, ctrl, meta, shift = spGetModKeyState()
  6385. spSetActiveCommand(index, 1, true, false, alt, ctrl, meta, shift)
  6386. end
  6387.  
  6388. local function CreateWindow()
  6389. window_main = Chili.Window:New{
  6390. parent = Chili.Screen0,
  6391. name = "FacSwitcher",
  6392. classname = "main_window_small_very_flat",
  6393. -- Fully transparent, matching the other panels: the rows float over the map.
  6394. color = {0, 0, 0, 0},
  6395. backgroundColor = {0, 0, 0, 0},
  6396. borderColor = {0, 0, 0, 0},
  6397. dockable = true,
  6398. dockableSavePositionOnly = true,
  6399. parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
  6400. right = 0,
  6401. y = 43,
  6402. clientWidth = WIN_W,
  6403. clientHeight = HEADER_H + PICK_COUNT * ROW_H + PAD * 2,
  6404. draggable = false,
  6405. resizable = false,
  6406. tweakDraggable = true,
  6407. tweakResizable = true,
  6408. padding = {6, 6, 6, 6},
  6409. }
  6410.  
  6411. holder = Chili.Control:New{
  6412. parent = window_main,
  6413. x = 0, y = 0, right = 0, bottom = 0,
  6414. padding = {0, 0, 0, 0},
  6415. }
  6416.  
  6417. headerLabel = Chili.Label:New{
  6418. parent = holder,
  6419. x = 2, y = 0,
  6420. caption = "Best factories",
  6421. font = FontCopy(FONT_HEADER),
  6422. }
  6423.  
  6424. emptyLabel = Chili.Label:New{
  6425. parent = holder,
  6426. x = 2, y = HEADER_H + 4,
  6427. caption = "No enemy intel yet",
  6428. font = FontCopy(FONT_DIM),
  6429. }
  6430.  
  6431. local barW = WIN_W - TEXT_X - PAD * 2 - 4
  6432. for i = 1, PICK_COUNT do
  6433. local y = HEADER_H + (i - 1) * ROW_H
  6434. local btn = Chili.Button:New{
  6435. parent = holder,
  6436. x = PAD, y = y,
  6437. width = WIN_W - PAD * 2,
  6438. height = ROW_H - 2,
  6439. caption = "",
  6440. padding = {0, 0, 0, 0},
  6441. backgroundColor = {0, 0, 0, 0},
  6442. borderColor = {0, 0, 0, 0},
  6443. focusColor = {1, 1, 1, 0.10},
  6444. OnClick = {RowClicked},
  6445. }
  6446. btn.pickIndex = i
  6447.  
  6448. rows[i] = {
  6449. ctrl = btn,
  6450. -- Chili's Image inherits Button and swallows the hit, so the icon needs its own
  6451. -- copy of the handler or clicking the picture would do nothing.
  6452. icon = Chili.Image:New{
  6453. parent = btn,
  6454. x = 0, y = 2,
  6455. width = ICON, height = ICON,
  6456. keepAspect = true,
  6457. file = DEFAULT_ICON,
  6458. pickIndex = i,
  6459. OnClick = {RowClicked},
  6460. },
  6461. name = Chili.Label:New{
  6462. parent = btn,
  6463. x = TEXT_X, y = 0,
  6464. caption = "",
  6465. font = FontCopy(FONT_MAIN),
  6466. },
  6467. -- autosize is on by default, and for a right-aligned label it rewrites both x
  6468. -- and width from the text on every SetCaption, so the label would walk left a
  6469. -- little further each refresh. Fixed box instead: DrawInBox does the aligning.
  6470. score = Chili.Label:New{
  6471. parent = btn,
  6472. x = TEXT_X, y = 0,
  6473. width = barW, height = 16,
  6474. autosize = false,
  6475. align = "right",
  6476. caption = "",
  6477. font = FontCopy(FONT_MAIN),
  6478. },
  6479. bar = Chili.Progressbar:New{
  6480. parent = btn,
  6481. x = TEXT_X, y = 17,
  6482. width = barW, height = BAR_H,
  6483. min = 0, max = 1, value = 0,
  6484. caption = "",
  6485. color = {0.45, 0.75, 1.00, 0.9},
  6486. backgroundColor = {0.1, 0.1, 0.1, 0.5},
  6487. },
  6488. }
  6489. btn:Hide()
  6490. end
  6491. end
  6492.  
  6493. -- Show/hide only. The window is kept alive so its docked position, and anything the
  6494. -- tweak-mode layout did to it, survive being toggled off and on again.
  6495. function SetPanelVisible(visible)
  6496. if not window_main then
  6497. return
  6498. end
  6499. if visible then
  6500. window_main:Show()
  6501. dirty = true -- the ranking went stale while hidden; redraw before it is looked at
  6502. else
  6503. window_main:Hide()
  6504. end
  6505. end
  6506.  
  6507. -- Which of the factory's units answer which enemy units, and what it has no answer to. The
  6508. -- score is a single number over a whole roster, so without this the panel would be
  6509. -- asserting a factory choice with nothing to argue from.
  6510. local function RowTooltip(pick)
  6511. local tip = unitName[pick.defID] .. " - " .. floor(cost[pick.defID] + 0.5) .. " metal"
  6512. if pick.owned == "plate" then
  6513. tip = tip .. " (you have its plate, linked to a working factory)"
  6514. elseif pick.owned then
  6515. tip = tip .. " (already built)"
  6516. end
  6517.  
  6518. -- Only the matchups this factory actually wins, heaviest first, and never the one named
  6519. -- as the weakness below. Listing by threat alone put the biggest enemy type on both
  6520. -- lines, which read as the panel contradicting itself.
  6521. local shown = 0
  6522. for i = 1, #pick.answers do
  6523. local a = pick.answers[i]
  6524. if a.by and a.value > 1 and a.defID ~= pick.worstDef then
  6525. if shown == 0 then
  6526. tip = tip .. "\nAnswers:"
  6527. end
  6528. tip = tip .. "\n " .. unitName[a.defID] .. " -> " .. unitName[a.by]
  6529. shown = shown + 1
  6530. if shown >= 3 then
  6531. break
  6532. end
  6533. end
  6534. end
  6535.  
  6536. if pick.worstDef then
  6537. if pick.worstAnswer then
  6538. tip = tip .. string.format("\nWeakest against %s: the best it has is %s (x%.2f)",
  6539. unitName[pick.worstDef], unitName[pick.worstAnswer], pick.worstValue)
  6540. else
  6541. tip = tip .. "\nNothing it builds can touch " .. unitName[pick.worstDef]
  6542. end
  6543. end
  6544.  
  6545. return tip .. "\nClick to place it, with a constructor selected."
  6546. end
  6547.  
  6548. local function RefreshLayout()
  6549. if not (window_main and holder) then
  6550. return
  6551. end
  6552.  
  6553. for i = 1, PICK_COUNT do
  6554. local row = rows[i]
  6555. local pick = picks[i]
  6556. if pick then
  6557. row.icon.file = iconFile[pick.defID]
  6558. row.icon:Invalidate()
  6559.  
  6560. local caption = unitName[pick.defID]
  6561. if pick.owned then
  6562. -- Inline and dimmed, after the name. A second right-aligned label would
  6563. -- have sat on top of the score.
  6564. caption = caption .. TAG_COLOR .. (pick.owned == "plate" and " [plate]" or " [built]")
  6565. end
  6566. row.name:SetCaption(caption)
  6567. row.score:SetCaption(string.format("%.3f", pick.score))
  6568. row.bar:SetValue(pick.barFill)
  6569. row.ctrl.tooltip = RowTooltip(pick)
  6570. row.ctrl:Show()
  6571. else
  6572. row.ctrl:Hide()
  6573. end
  6574. end
  6575.  
  6576. if picks[1] then
  6577. emptyLabel:Hide()
  6578. else
  6579. emptyLabel:SetCaption(emptyReason or "No enemy intel yet")
  6580. emptyLabel:Show()
  6581. end
  6582.  
  6583. -- The header carries the verdict rather than repeating the list: the ranking is only
  6584. -- interesting relative to the plant you already have, and "your factory is still the
  6585. -- right one" is an answer worth stating out loud rather than leaving to be inferred
  6586. -- from which row happens to be on top.
  6587. local caption = "Best factories"
  6588. local top = picks[1]
  6589. if top then
  6590. if top.owned then
  6591. caption = "Factories - stay " .. FacTag(top.defID)
  6592. elseif ownedBest and ownedBest.score > 0 then
  6593. caption = string.format("Factories - switch to %s +%d%%",
  6594. FacTag(top.defID),
  6595. floor((top.score / ownedBest.score - 1) * 100 + 0.5))
  6596. else
  6597. caption = "Factories - open with " .. FacTag(top.defID)
  6598. end
  6599. end
  6600. headerLabel:SetCaption(caption)
  6601. headerLabel.tooltip =
  6602. "Factories ranked by how well the units they build answer every enemy unit "
  6603. .. "Enemy Intel remembers.\nEach enemy type is scored against the best answer the "
  6604. .. "factory could build for it, and those are averaged over the enemy army by "
  6605. .. "metal.\nSettings/HUD Panels/Combat Advisor decides how hard a gap in the "
  6606. .. "roster is punished."
  6607. end
  6608.  
  6609. --------------------------------------------------------------------------------
  6610. -- Callins
  6611. --------------------------------------------------------------------------------
  6612.  
  6613. function M:Initialize()
  6614. Chili = WG.Chili
  6615. if not Chili then
  6616. Spring.Echo("Fac Switcher: WG.Chili unavailable, removing widget.")
  6617. widgetHandler:RemoveWidget(widget)
  6618. return
  6619. end
  6620. myTeamID = spGetMyTeamID()
  6621. amSpectating = select(1, Spring.GetSpectatingState())
  6622. CreateWindow()
  6623. SetPanelVisible(options.showPanel.value)
  6624. dirty = true
  6625. end
  6626.  
  6627. function M:Shutdown()
  6628. if window_main then
  6629. window_main:Dispose()
  6630. window_main = nil
  6631. end
  6632. rows = {}
  6633. end
  6634.  
  6635. function M:PlayerChanged()
  6636. myTeamID = spGetMyTeamID()
  6637. amSpectating = select(1, Spring.GetSpectatingState())
  6638. dirty = true
  6639. end
  6640.  
  6641. function M:Update()
  6642. if not options.showPanel.value then
  6643. -- Scoring exists only to fill this panel and is published to no one, so while it is
  6644. -- hidden there is nothing worth computing. `dirty` is left standing so the army on
  6645. -- the field when it comes back is ranked fresh rather than shown as it was.
  6646. return
  6647. end
  6648.  
  6649. local now = os.clock()
  6650. if not dirty and now < nextRefresh then
  6651. return
  6652. end
  6653. dirty = false
  6654. nextRefresh = now + options.updateRate.value
  6655.  
  6656. Recompute()
  6657. RefreshLayout()
  6658. end
  6659.  
  6660. RegisterModule{
  6661. callins = M,
  6662. prefix = 'fs_',
  6663. optionsPath = options_path,
  6664. options = options,
  6665. optionsOrder = options_order,
  6666. }
  6667. end
  6668.  
  6669. --------------------------------------------------------------------------------
  6670. -- Engagement Predictor
  6671. --
  6672. -- Was gui_engagement_predictor.lua.
  6673. --------------------------------------------------------------------------------
  6674.  
  6675. do
  6676. local M = {} -- this module's callins, collected by RegisterModule below
  6677. local options_path, options_order, options -- were file-scope globals
  6678.  
  6679. --------------------------------------------------------------------------------
  6680. -- Includes / static per-unitDef data
  6681. --
  6682. -- The archetypes, the counter matrix and the scoring functions all live in
  6683. -- gui_build_proposer.lua and arrive through WG.CounterMatrix. What is built here is only
  6684. -- what is needed to draw a row and to price a unit, so the panel still has something to
  6685. -- say when that widget is switched off.
  6686. --------------------------------------------------------------------------------
  6687.  
  6688. local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
  6689. local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
  6690.  
  6691. local iconFormat = ".dds"
  6692. do
  6693. -- chilitip_conf.lua returns: ignoreweapon, iconFormat
  6694. local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
  6695. if ok and fmt then
  6696. iconFormat = fmt
  6697. end
  6698. end
  6699.  
  6700. local spGetSelectedUnits = Spring.GetSelectedUnits
  6701. local spGetUnitDefID = Spring.GetUnitDefID
  6702. local spGetUnitPosition = Spring.GetUnitPosition
  6703. local spGetUnitHealth = Spring.GetUnitHealth
  6704. local spGetUnitTeam = Spring.GetUnitTeam
  6705. local spGetUnitAllyTeam = Spring.GetUnitAllyTeam
  6706. local spGetUnitsInRectangle = Spring.GetUnitsInRectangle
  6707. local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
  6708. local spGetMyTeamID = Spring.GetMyTeamID
  6709. local spGetGaiaTeamID = Spring.GetGaiaTeamID
  6710. local spEcho = Spring.Echo
  6711.  
  6712. local floor, ceil, max, min, abs, huge =
  6713. math.floor, math.ceil, math.max, math.min, math.abs, math.huge
  6714.  
  6715. -- Zero-K loads this helper into the widget environment too. It resolves commander and
  6716. -- Tech-K cost scaling, neither of which the unitDef knows about, so it is worth preferring
  6717. -- wherever there is a live unitID to ask about.
  6718. local spuGetUnitCost = Spring.Utilities and Spring.Utilities.GetUnitCost
  6719.  
  6720. local DEFAULT_ICON = "icons/default.dds"
  6721.  
  6722. -- The key every radar contact that could not be resolved to a real type is filed under.
  6723. -- Negative so it can never collide with a unitDefID, which forces every loop over the two
  6724. -- force tables to decide what to do with it rather than silently indexing a nil.
  6725. local BLIP_KEY = -1
  6726.  
  6727. local cost = {}
  6728. local unitName = {}
  6729. local iconFile = {}
  6730. local isStatic = {}
  6731. local isCon = {} -- build power and no weapon at all, commanders aside
  6732. local isAA = {} -- every weapon that does damage can only aim at aircraft
  6733. local skipDef = {} -- drones and anything flagged dontcount: already priced into a parent
  6734.  
  6735. -- customparams arrive stringified (see gamedata/unitdefs_post.lua), so a flag that is
  6736. -- present but off is the string "false", which is perfectly truthy in Lua.
  6737. local function Flag(cp, key)
  6738. local v = cp and cp[key]
  6739. return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
  6740. end
  6741.  
  6742. local AIR_CATEGORY = {fixedwing = true, gunship = true}
  6743.  
  6744. for defID = 1, #UnitDefs do
  6745. local ud = UnitDefs[defID]
  6746. local it = ud.iconType or "default"
  6747. local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
  6748. or ("icons/" .. it .. iconFormat)
  6749. -- Zero-K's own square build texture, which the engine renders from the model, rather
  6750. -- than icons/default.dds -- that file is fully transparent, so a missing icon drew a
  6751. -- blank box with a count beside it (submarine, none, the legacy tank/kbot set).
  6752. if not VFS.FileExists(path) then
  6753. path = "#" .. defID
  6754. end
  6755. iconFile[defID] = path
  6756. unitName[defID] = ud.humanName or ud.name or "?"
  6757. cost[defID] = ud.metalCost or 0
  6758. isStatic[defID] = (ud.speed or 0) == 0
  6759.  
  6760. local cp = ud.customParams
  6761. skipDef[defID] = Flag(cp, "dontcount") or Flag(cp, "is_drone")
  6762.  
  6763. -- The same two tests Build Proposer classifies by, repeated here so the filters still
  6764. -- work with that module switched off. A shield counts as a weapon for the constructor
  6765. -- test, which is what keeps Funnelweb -- 40 build power -- out of it; commanders have
  6766. -- build power and guns both, but are caught by flag regardless.
  6767. local weapons = ud.weapons or {}
  6768. local anyWeapon, onlyAir = false, true
  6769. for i = 1, #weapons do
  6770. local wpn = weapons[i]
  6771. local wd = wpn.weaponDef and WeaponDefs[wpn.weaponDef]
  6772. if wd and not wd.isShield then
  6773. local dmg = tonumber(wd.customParams and wd.customParams.stats_damage)
  6774. or (wd.damages and wd.damages[0]) or 0
  6775. if dmg > 0 then
  6776. anyWeapon = true
  6777. local ot = wpn.onlyTargets
  6778. if not ot or not next(ot) then
  6779. onlyAir = false
  6780. else
  6781. for cat in pairs(ot) do
  6782. if not AIR_CATEGORY[cat] then
  6783. onlyAir = false
  6784. end
  6785. end
  6786. end
  6787. end
  6788. end
  6789. end
  6790. isAA[defID] = anyWeapon and onlyAir
  6791.  
  6792. local isComm = Flag(cp, "dynamic_comm") or Flag(cp, "commtype")
  6793. isCon[defID] = (ud.buildSpeed or 0) > 0 and not ud.isFactory and not isComm
  6794. and #weapons == 0
  6795. end
  6796.  
  6797. --------------------------------------------------------------------------------
  6798. -- State (declared before `options` so the option closures can capture them)
  6799. --------------------------------------------------------------------------------
  6800.  
  6801. local NEUTRAL = 1.0
  6802.  
  6803. local result = nil -- the last computed prediction, or nil for the idle panel
  6804. local idleText = "Select units to predict a fight."
  6805. local dirty = true
  6806. local nextRefresh = 0
  6807.  
  6808. local myTeamID = spGetMyTeamID()
  6809. local gaiaTeamID = spGetGaiaTeamID()
  6810.  
  6811. local LogDetail -- forward declaration, defined below
  6812. local SetPanelVisible -- forward declaration, defined below
  6813. local ApplyPalette -- forward declaration: the options below are built before the colours
  6814. local CommitFronts -- forward declaration: FindFronts commits an empty list itself
  6815.  
  6816. -- The rings on the map. `hitAt[unitID]` is the game time one of our side's units was last
  6817. -- hit or last landed a hit; the fronts are clusters of those, rescored once a second.
  6818. --
  6819. -- Two layers. `fronts` (and `result`, for the selection) are what was *scored*; `sel` and
  6820. -- `shown` are what is *drawn*: rings that ease towards the last score every frame and fade
  6821. -- in and out, so nothing on the map jumps when a score lands. The fronts are scored one
  6822. -- cluster per frame off `queue`, collected in `pending`, and committed to `fronts` as a
  6823. -- whole when the queue runs dry.
  6824. local rings = {
  6825. hitAt = {},
  6826. fronts = {},
  6827. next = 0,
  6828. queue = {},
  6829. qi = 1,
  6830. pending = {},
  6831. sel = nil,
  6832. shown = {},
  6833. clock = nil, -- the timer Animate last ran at
  6834. t = 0, -- wall seconds of animation, for the dashes and the pulse
  6835. MEMORY = 8, -- s a hit keeps a unit on its front
  6836. JOIN = 600, -- elmos: a hit unit this close to a front's middle belongs to that front
  6837. MAX = 6, -- fronts drawn at once, the busiest first
  6838. EASE_POS = 0.12, -- s time constant for the centre and radius
  6839. EASE_P = 0.2, -- s time constant for the chance, and so the colour and the arc
  6840. FADE = 0.35, -- s to fade a ring fully in or out
  6841. TREND = 3, -- s back the trend arrow looks
  6842. TREND_MIN = 0.05, -- how far the chance must move in that time to earn an arrow
  6843. FILL_A = 0.30, -- the inside's alpha at full: coloured, but the ground still reads
  6844. HIT_HOLD = 2, -- s after a ringed unit was hit, or hit something, it still counts as fighting
  6845. HIT_FADE = 3, -- s the fighting state then takes to fade
  6846. FLASH_PERIOD = 2, -- s for one colour -> clear -> colour cycle of the fighting flash
  6847. gt = 0, -- game seconds, as of the last Animate
  6848. marks = {}, -- units circled by nearEnemies/nearAll: {uid, enemy, radius} each
  6849. nextMark = 0, -- os.clock of the next marks sweep
  6850. MARK_EVERY = 0.25, -- s between marks sweeps; the circles follow the units every frame
  6851. }
  6852.  
  6853. --------------------------------------------------------------------------------
  6854. -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
  6855. --------------------------------------------------------------------------------
  6856.  
  6857. options_path = 'Settings/HUD Panels/Engagement Predictor'
  6858. options_order = {'showPanel', 'selRing', 'showFronts', 'plainRings',
  6859. 'fillRange', 'fillFar', 'fillFlash',
  6860. 'nearDist', 'nearEnemies', 'nearAll', 'nearOnly', 'nearReach',
  6861. 'cbPalette',
  6862. 'friendlyRadius', 'enemyRadius', 'updateRate', 'decisiveness',
  6863. 'qualityWeight',
  6864. 'includeStatic', 'includeCons', 'includeAA',
  6865. 'includeAllies', 'useBlips', 'enemiesFromSelectionOnly',
  6866. 'showBreakdown', 'showFactors', 'logDetail'}
  6867. options = {
  6868. showPanel = {
  6869. name = 'Show Engagement Predictor panel',
  6870. desc = 'Hide the on-screen readout without unloading the widget. Nothing here outlives '
  6871. .. 'the panel -- the prediction is recomputed from the live selection every tick '
  6872. .. 'and published to no one -- so while hidden it stops gathering and scoring '
  6873. .. 'entirely, and picks the current fight back up the moment it is shown.',
  6874. type = 'bool',
  6875. value = true,
  6876. OnChange = function(self)
  6877. SetPanelVisible(self.value)
  6878. end,
  6879. },
  6880. selRing = {
  6881. name = 'Ring the selected fight on the map',
  6882. desc = 'Draw a circle round your selected units, coloured by how the fight looks from '
  6883. .. 'red through amber to green. The ring is also a gauge: the bright arc runs '
  6884. .. 'clockwise from the far edge for your chance, the dim rest is theirs. Over it '
  6885. .. 'the chance, an arrow when it has moved 5 points in the last 3 seconds, the '
  6886. .. 'metal on each side, and a word: "Favourable - engage" from 65%, "Even fight" '
  6887. .. 'from 45%, "Unfavourable" from 25%, and "Retreat!" below that, where the ring '
  6888. .. 'also pulses. Works with the panel hidden.',
  6889. type = 'bool',
  6890. value = true,
  6891. },
  6892. showFronts = {
  6893. name = 'Ring every active front',
  6894. desc = 'Also ring every place your side is fighting right now, selected or not, each '
  6895. .. 'with its own predicted chance. A front is a group of your units that were hit, '
  6896. .. 'or hit something, in the last 8 seconds. Scored with the same settings as the '
  6897. .. 'panel, once a second, the busiest six at most. Drawn as slowly turning dashes, '
  6898. .. 'bright for as much of the circle as your chance, so they never read as the '
  6899. .. 'selection. Works with the panel hidden.',
  6900. type = 'bool',
  6901. value = true,
  6902. OnChange = function(self)
  6903. if not self.value then
  6904. rings.fronts, rings.hitAt, rings.shown = {}, {}, {}
  6905. rings.queue, rings.qi, rings.pending = {}, 1, {}
  6906. end
  6907. end,
  6908. },
  6909. fillRange = {
  6910. name = 'Ring fill fade distance',
  6911. desc = 'The inside of each ring is filled in its colour: fully while an enemy is at or '
  6912. .. 'inside the ring, or while the ringed units are hitting or being hit (and for 2 '
  6913. .. 'seconds after), fading as the nearest enemy gets farther away. This is how far '
  6914. .. 'outside the ring the enemy must be for the fill to have faded all the way.',
  6915. type = 'number',
  6916. min = 200, max = 2000, step = 50, value = 800,
  6917. },
  6918. fillFar = {
  6919. name = 'Ring fill with the enemy far off (%)',
  6920. desc = 'How much of the fill is left once the nearest enemy is past the fade distance and '
  6921. .. 'nothing is shooting. 0 leaves such a ring hollow; 100 keeps every ring filled.',
  6922. type = 'number',
  6923. min = 0, max = 100, step = 5, value = 0,
  6924. },
  6925. fillFlash = {
  6926. name = 'Flash the ring fill while fighting',
  6927. desc = 'While the ringed units are hitting or being hit, the fill slowly flashes from its '
  6928. .. 'colour to clear and back, once every 2 seconds, so a fight that has started '
  6929. .. 'stands out from one still closing. It fades out over a few seconds once the '
  6930. .. 'shooting stops.',
  6931. type = 'bool',
  6932. value = true,
  6933. },
  6934. nearDist = {
  6935. name = 'Near distance',
  6936. desc = 'The distance, in elmos out from a ring\'s edge, that the four options below use: '
  6937. .. 'which units get a circle, how close an enemy must be before a ring shows, and '
  6938. .. 'the radius of the faint outer circle.',
  6939. type = 'number',
  6940. min = 100, max = 3000, step = 50, value = 600,
  6941. },
  6942. nearEnemies = {
  6943. name = 'Circle enemies near a ring',
  6944. desc = 'A small circle under every enemy unit inside a ring or within the near distance '
  6945. .. 'of it, in the losing colour, so you can see exactly which enemies are close.',
  6946. type = 'bool',
  6947. value = false,
  6948. },
  6949. nearAll = {
  6950. name = 'Circle units of both sides near a ring',
  6951. desc = 'A small circle under every unit, yours and your allies\' as well as the enemy\'s, '
  6952. .. 'inside a ring or within the near distance of it: yours in the winning colour, '
  6953. .. 'theirs in the losing one. Shows who is actually in the fight.',
  6954. type = 'bool',
  6955. value = false,
  6956. },
  6957. nearOnly = {
  6958. name = 'Only ring a fight once an enemy is near',
  6959. desc = 'Keep a ring hidden until the nearest enemy is within the near distance of it, '
  6960. .. 'and fade it out again when they pull back, so fights still far apart show '
  6961. .. 'nothing. Covers the selection ring and the fronts.',
  6962. type = 'bool',
  6963. value = false,
  6964. },
  6965. nearReach = {
  6966. name = 'Show the near distance round each ring',
  6967. desc = 'A faint dashed circle round each ring at the near distance, a preview of how '
  6968. .. 'far out the options above are looking.',
  6969. type = 'bool',
  6970. value = false,
  6971. },
  6972. plainRings = {
  6973. name = 'Plain dashed rings',
  6974. desc = 'Draw every ring as a dashed line and nothing else: no coloured fill, no glow and '
  6975. .. 'no dark outline under the line, and the numbers get a black outline instead of a '
  6976. .. 'box. The dashes still '
  6977. .. 'light up for as much of the circle as your chance, and still pulse when losing; '
  6978. .. 'the selection\'s stand still and the fronts\' turn. The fill settings below do '
  6979. .. 'nothing while this is on.',
  6980. type = 'bool',
  6981. value = false,
  6982. },
  6983. cbPalette = {
  6984. name = 'Colour-blind palette (orange / grey / blue)',
  6985. desc = 'Show a fight going badly as orange, an even one as grey and a good one as blue, '
  6986. .. 'instead of red, amber and green. Covers the rings on the map and the panel.',
  6987. type = 'bool',
  6988. value = false,
  6989. OnChange = function(self)
  6990. ApplyPalette(self.value)
  6991. end,
  6992. },
  6993. friendlyRadius = {
  6994. name = 'Friendly gather radius',
  6995. desc = 'Allied units this far from any selected unit join the fight on your side. '
  6996. .. '0 counts only what you actually have selected.',
  6997. type = 'number',
  6998. min = 0, max = 3000, step = 50, value = 1500,
  6999. OnChange = function() dirty = true end,
  7000. },
  7001. enemyRadius = {
  7002. name = 'Enemy gather radius',
  7003. desc = 'Enemies this far from any unit of that friendly force are counted as being in '
  7004. .. 'the fight. Roughly the distance the fight can pull reinforcements in from.',
  7005. type = 'number',
  7006. min = 100, max = 3000, step = 50, value = 2000,
  7007. OnChange = function() dirty = true end,
  7008. },
  7009. updateRate = {
  7010. name = 'Prediction update interval (s)',
  7011. desc = 'How often the prediction is recomputed. Changing the selection always '
  7012. .. 'recomputes immediately, whatever this says.',
  7013. type = 'number',
  7014. min = 0.25, max = 5, step = 0.25, value = 0.25,
  7015. },
  7016. decisiveness = {
  7017. name = 'Decisiveness',
  7018. desc = 'How hard an edge in effective strength is pushed towards certainty. At 1 a '
  7019. .. 'fight is won in proportion to strength; at 2 a 1.5x edge reads as about 70%; '
  7020. .. 'higher swings the number harder for the same two armies. Taste, not physics.',
  7021. type = 'number',
  7022. min = 0.5, max = 5, step = 0.1, value = 2,
  7023. OnChange = function() dirty = true end,
  7024. },
  7025. qualityWeight = {
  7026. name = 'Weight: unit quality',
  7027. desc = 'How much a unit simply being good counts, on top of the matchups. The counter '
  7028. .. 'matrix will happily rate two units the same because they answer the enemy the '
  7029. .. 'same way, and the tier list published alongside it is the opinion that says '
  7030. .. 'which of them you would rather have brought. 0 predicts the fight on matchups '
  7031. .. 'and metal alone; 1 lets a tier move a side by roughly a sixth of a matchup '
  7032. .. 'step. Unrated units, and any fight scored while Build Proposer is off, are '
  7033. .. 'unaffected.',
  7034. type = 'number',
  7035. min = 0, max = 3, step = 0.1, value = 0,
  7036. OnChange = function() dirty = true end,
  7037. },
  7038. includeStatic = {
  7039. name = 'Count buildings',
  7040. desc = 'Include turrets and structures on both sides. Turn off to compare the two '
  7041. .. 'armies alone, as if the fight happened in open ground.',
  7042. type = 'bool',
  7043. value = false,
  7044. OnChange = function() dirty = true end,
  7045. },
  7046. includeCons = {
  7047. name = 'Count constructors',
  7048. desc = 'Include unarmed builders on both sides -- constructors, Athena, caretakers. '
  7049. .. 'Commanders and Funnelweb are not constructors here and always count. Turn off '
  7050. .. 'so a con escort padding the metal total does not read as fighting strength.',
  7051. type = 'bool',
  7052. value = true,
  7053. OnChange = function() dirty = true end,
  7054. },
  7055. includeAA = {
  7056. name = 'Count anti-air',
  7057. desc = 'Include units that can only shoot aircraft, on both sides: mobile AA, AA '
  7058. .. 'turrets, Raptor and Trident. Swift has a gun that reaches the ground, so it '
  7059. .. 'still counts. Turn off to score a ground fight without AA inflating a side '
  7060. .. 'it cannot help -- but it also hides the AA an air strike would fly into.',
  7061. type = 'bool',
  7062. value = true,
  7063. OnChange = function() dirty = true end,
  7064. },
  7065. includeAllies = {
  7066. name = 'Count allied units',
  7067. desc = 'Pull in your team-mates units as well as your own. Off restricts the friendly '
  7068. .. 'force to units you personally own.',
  7069. type = 'bool',
  7070. value = true,
  7071. OnChange = function() dirty = true end,
  7072. },
  7073. useBlips = {
  7074. name = 'Estimate radar contacts',
  7075. desc = 'Give each unidentified radar contact a probable unit type, drawn from the '
  7076. .. 'enemy army the Enemy Intel widget remembers minus what is already identified. '
  7077. .. 'Off ignores contacts entirely, which flatters your odds.',
  7078. type = 'bool',
  7079. value = true,
  7080. OnChange = function() dirty = true end,
  7081. },
  7082. enemiesFromSelectionOnly = {
  7083. name = 'Measure enemies from selection',
  7084. desc = 'On, the enemy radius is measured only from the units you actually selected. '
  7085. .. 'Off, it is measured from every unit of the gathered friendly force, so a '
  7086. .. 'supporting group brings its own frontage with it.',
  7087. type = 'bool',
  7088. value = true,
  7089. OnChange = function() dirty = true end,
  7090. },
  7091. showBreakdown = {
  7092. name = 'Show force breakdown',
  7093. desc = 'List both sides by archetype, with counts and metal, so the number can be '
  7094. .. 'read rather than just believed.',
  7095. type = 'bool',
  7096. value = false,
  7097. OnChange = function() dirty = true end,
  7098. },
  7099. showFactors = {
  7100. name = 'Show key factors',
  7101. desc = 'Name the two or three matchups doing the most to swing the result, plus '
  7102. .. 'anything on the field you have no weapon for at all.',
  7103. type = 'bool',
  7104. value = false,
  7105. OnChange = function() dirty = true end,
  7106. },
  7107. logDetail = {
  7108. name = 'Log this fight to infolog',
  7109. desc = 'Echo both forces, their archetypes and the scoring to infolog.txt. For '
  7110. .. 'checking that a surprising number came from where you think it did.',
  7111. type = 'button',
  7112. OnChange = function() if LogDetail then LogDetail() end end,
  7113. },
  7114. }
  7115.  
  7116. --------------------------------------------------------------------------------
  7117. -- Spatial hash
  7118. --
  7119. -- The naive gather is one engine query per selected unit, which stops being free the
  7120. -- moment a real army is selected. Instead: one rectangle query over the bounding box, then
  7121. -- an exact distance test against a hash of the source positions. Cell size is the radius,
  7122. -- so a candidate only ever has to look at the nine cells around it.
  7123. --------------------------------------------------------------------------------
  7124.  
  7125. local GRID_STRIDE = 100003 -- prime, so cx*stride+cz collides only on absurd coordinates
  7126.  
  7127. local function BuildGrid(px, pz, n, cell)
  7128. local g = {}
  7129. for i = 1, n do
  7130. local key = floor(px[i] / cell) * GRID_STRIDE + floor(pz[i] / cell)
  7131. local bucket = g[key]
  7132. if bucket then
  7133. bucket[#bucket + 1] = i
  7134. else
  7135. g[key] = {i}
  7136. end
  7137. end
  7138. return g
  7139. end
  7140.  
  7141. local function NearAny(g, px, pz, cell, x, z, r2)
  7142. local cx, cz = floor(x / cell), floor(z / cell)
  7143. for dx = -1, 1 do
  7144. local base = (cx + dx) * GRID_STRIDE
  7145. for dz = -1, 1 do
  7146. local bucket = g[base + cz + dz]
  7147. if bucket then
  7148. for i = 1, #bucket do
  7149. local j = bucket[i]
  7150. local ex, ez = px[j] - x, pz[j] - z
  7151. if ex * ex + ez * ez <= r2 then
  7152. return true
  7153. end
  7154. end
  7155. end
  7156. end
  7157. end
  7158. return false
  7159. end
  7160.  
  7161. --------------------------------------------------------------------------------
  7162. -- Gathering the two forces
  7163. --------------------------------------------------------------------------------
  7164.  
  7165. local function Keep(defID)
  7166. if defID == BLIP_KEY then
  7167. return true
  7168. end
  7169. if skipDef[defID] then
  7170. return false
  7171. end
  7172. if isStatic[defID] and not options.includeStatic.value then
  7173. return false
  7174. end
  7175. if isCon[defID] and not options.includeCons.value then
  7176. return false
  7177. end
  7178. if isAA[defID] and not options.includeAA.value then
  7179. return false
  7180. end
  7181. return true
  7182. end
  7183.  
  7184. -- Metal, discounted by how much of the unit is left. A Minotaur down to a third of its
  7185. -- health is not a third of a Minotaur in a fight, but it is a great deal less than one,
  7186. -- and counting it whole is the error that makes a losing push look winnable.
  7187. local function UnitValue(unitID, defID)
  7188. local base = (spuGetUnitCost and spuGetUnitCost(unitID, defID)) or cost[defID] or 0
  7189. local hp, maxHp = spGetUnitHealth(unitID)
  7190. if hp and maxHp and maxHp > 0 then
  7191. local frac = hp / maxHp
  7192. if frac < 0.05 then
  7193. frac = 0.05
  7194. elseif frac > 1 then
  7195. frac = 1
  7196. end
  7197. return base * frac
  7198. end
  7199. return base
  7200. end
  7201.  
  7202. -- Every enemy type we can currently put a name to, anywhere on the map. Subtracting this
  7203. -- from what Enemy Intel remembers leaves the pool the radar contacts must be drawn from.
  7204. local function IdentifiedEnemiesEverywhere(refAlly)
  7205. local out = {}
  7206. local all = spGetUnitsInRectangle(0, 0, Game.mapSizeX, Game.mapSizeZ)
  7207. for i = 1, #all do
  7208. local uid = all[i]
  7209. local did = spGetUnitDefID(uid)
  7210. if did and spGetUnitAllyTeam(uid) ~= refAlly and spGetUnitTeam(uid) ~= gaiaTeamID then
  7211. out[did] = (out[did] or 0) + 1
  7212. end
  7213. end
  7214. return out
  7215. end
  7216.  
  7217. -- unitDefID -> probability that any given radar contact is of that type, or nil when there
  7218. -- is nothing to go on.
  7219. local function BuildBlipProfile(refAlly)
  7220. local intel = WG.EnemyIntel
  7221. if not (intel and intel.GetEffectiveCounts) then
  7222. return nil
  7223. end
  7224. local believed = intel.GetEffectiveCounts()
  7225. if not believed then
  7226. return nil
  7227. end
  7228.  
  7229. local ident = IdentifiedEnemiesEverywhere(refAlly)
  7230. local profile, total = {}, 0
  7231. for did, n in pairs(believed) do
  7232. local residual = n - (ident[did] or 0)
  7233. if residual > 0 and Keep(did) then
  7234. profile[did] = residual
  7235. total = total + residual
  7236. end
  7237. end
  7238. if total <= 0 then
  7239. return nil
  7240. end
  7241. for did, n in pairs(profile) do
  7242. profile[did] = n / total
  7243. end
  7244. return profile
  7245. end
  7246.  
  7247. -- Returns two aggregates keyed by unitDefID rather than two unit lists. Collapsing to
  7248. -- distinct types here is what keeps the scoring loop below at a few dozen squared instead
  7249. -- of a few hundred squared.
  7250. -- `seed` stands in for the selection: the front overlay gathers each front from the units
  7251. -- being hit there. With no seed it is the selection, exactly as before.
  7252. local function Gather(seed)
  7253. local sel = seed or spGetSelectedUnits()
  7254. if not sel or #sel == 0 then
  7255. return nil, "Select units to predict a fight."
  7256. end
  7257.  
  7258. local refAlly = spGetUnitAllyTeam(sel[1]) or spGetMyAllyTeamID()
  7259. local ownOnly = not options.includeAllies.value
  7260.  
  7261. -- Selection positions, and the bounding box the friendly query needs.
  7262. local px, pz, n = {}, {}, 0
  7263. local minX, minZ, maxX, maxZ = huge, huge, -huge, -huge
  7264. local force, fn = {}, 0
  7265. local inForce = {}
  7266. for i = 1, #sel do
  7267. local uid = sel[i]
  7268. local x, _, z = spGetUnitPosition(uid)
  7269. if x then
  7270. n = n + 1
  7271. px[n], pz[n] = x, z
  7272. if x < minX then minX = x end
  7273. if x > maxX then maxX = x end
  7274. if z < minZ then minZ = z end
  7275. if z > maxZ then maxZ = z end
  7276. fn = fn + 1
  7277. force[fn] = uid
  7278. inForce[uid] = true
  7279. end
  7280. end
  7281. if n == 0 then
  7282. return nil, "Selection has no position on the map."
  7283. end
  7284.  
  7285. -- Where the selection stands, for the ring drawn round it on the map: the middle of the
  7286. -- selected units and the distance to the farthest of them, plus a margin so the ring
  7287. -- clears the models rather than cutting through them. Worked out here, ahead of the
  7288. -- enemy pass, because that pass measures how far the nearest enemy is from this ring.
  7289. local cx, cz = 0, 0
  7290. for i = 1, n do
  7291. cx, cz = cx + px[i], cz + pz[i]
  7292. end
  7293. cx, cz = cx / n, cz / n
  7294. local r2max = 0
  7295. for i = 1, n do
  7296. local dx, dz = px[i] - cx, pz[i] - cz
  7297. local d2 = dx * dx + dz * dz
  7298. if d2 > r2max then
  7299. r2max = d2
  7300. end
  7301. end
  7302. local ringR = r2max ^ 0.5 + 40
  7303.  
  7304. -- The last time any ringed unit was hit or landed a hit (UnitDamaged keeps the times).
  7305. -- The ringed units only, not the allies gathered below: a hit on one of those a screen
  7306. -- away is not this group fighting.
  7307. local lastHit = -huge
  7308. for i = 1, n do
  7309. local t = rings.hitAt[force[i]]
  7310. if t and t > lastHit then
  7311. lastHit = t
  7312. end
  7313. end
  7314.  
  7315. -- Friendly force: the selection, plus whatever stands close enough to join in.
  7316. local fx, fz = {}, {}
  7317. for i = 1, n do
  7318. fx[i], fz[i] = px[i], pz[i]
  7319. end
  7320. local fMinX, fMinZ, fMaxX, fMaxZ = minX, minZ, maxX, maxZ
  7321.  
  7322. local fR = options.friendlyRadius.value
  7323. if fR > 0 then
  7324. local cell = max(fR, 1)
  7325. local grid = BuildGrid(px, pz, n, cell)
  7326. local r2 = fR * fR
  7327. local cand = spGetUnitsInRectangle(minX - fR, minZ - fR, maxX + fR, maxZ + fR)
  7328. for i = 1, #cand do
  7329. local uid = cand[i]
  7330. if not inForce[uid] then
  7331. local ok = (spGetUnitAllyTeam(uid) == refAlly)
  7332. if ok and ownOnly then
  7333. ok = (spGetUnitTeam(uid) == myTeamID)
  7334. end
  7335. if ok then
  7336. local x, _, z = spGetUnitPosition(uid)
  7337. if x and NearAny(grid, px, pz, cell, x, z, r2) then
  7338. inForce[uid] = true
  7339. fn = fn + 1
  7340. force[fn] = uid
  7341. fx[fn], fz[fn] = x, z
  7342. if x < fMinX then fMinX = x end
  7343. if x > fMaxX then fMaxX = x end
  7344. if z < fMinZ then fMinZ = z end
  7345. if z > fMaxZ then fMaxZ = z end
  7346. end
  7347. end
  7348. end
  7349. end
  7350. end
  7351.  
  7352. local fByDef, fCount, Vf, fUnits = {}, {}, 0, 0
  7353. for i = 1, fn do
  7354. local uid = force[i]
  7355. local did = spGetUnitDefID(uid)
  7356. if did and Keep(did) then
  7357. local v = UnitValue(uid, did)
  7358. fByDef[did] = (fByDef[did] or 0) + v
  7359. fCount[did] = (fCount[did] or 0) + 1
  7360. Vf = Vf + v
  7361. fUnits = fUnits + 1
  7362. end
  7363. end
  7364. if fUnits == 0 or Vf <= 0 then
  7365. return nil, "Nothing in the selection counts as a force."
  7366. end
  7367.  
  7368. -- Enemy force, measured from the whole friendly force unless told otherwise.
  7369. local sx, sz, sn, bMinX, bMinZ, bMaxX, bMaxZ
  7370. if options.enemiesFromSelectionOnly.value then
  7371. sx, sz, sn = px, pz, n
  7372. bMinX, bMinZ, bMaxX, bMaxZ = minX, minZ, maxX, maxZ
  7373. else
  7374. sx, sz, sn = fx, fz, fn
  7375. bMinX, bMinZ, bMaxX, bMaxZ = fMinX, fMinZ, fMaxX, fMaxZ
  7376. end
  7377.  
  7378. local eR = options.enemyRadius.value
  7379. local cell = max(eR, 1)
  7380. local grid = BuildGrid(sx, sz, sn, cell)
  7381. local r2 = eR * eR
  7382.  
  7383. local eByDef, eCount, Ve, eUnits = {}, {}, 0, 0
  7384. local blips = 0
  7385. local gap2 = huge -- squared distance from the ring's middle to the nearest enemy counted
  7386. local cand = spGetUnitsInRectangle(bMinX - eR, bMinZ - eR, bMaxX + eR, bMaxZ + eR)
  7387. for i = 1, #cand do
  7388. local uid = cand[i]
  7389. if not inForce[uid] and spGetUnitAllyTeam(uid) ~= refAlly
  7390. and spGetUnitTeam(uid) ~= gaiaTeamID then
  7391. local x, _, z = spGetUnitPosition(uid)
  7392. if x and NearAny(grid, sx, sz, cell, x, z, r2) then
  7393. local ddx, ddz = x - cx, z - cz
  7394. local d2 = ddx * ddx + ddz * ddz
  7395. if d2 < gap2 then
  7396. gap2 = d2
  7397. end
  7398. local did = spGetUnitDefID(uid)
  7399. if did == nil then
  7400. -- On radar but not in sight: allegiance and position known, type not.
  7401. blips = blips + 1
  7402. elseif Keep(did) then
  7403. local v = UnitValue(uid, did)
  7404. eByDef[did] = (eByDef[did] or 0) + v
  7405. eCount[did] = (eCount[did] or 0) + 1
  7406. Ve = Ve + v
  7407. eUnits = eUnits + 1
  7408. end
  7409. end
  7410. end
  7411. end
  7412.  
  7413. -- Radar contacts, spread across the types they could plausibly be. Fractional counts
  7414. -- are fine: nothing downstream cares whether a weight came from whole units.
  7415. local blipsResolved = false
  7416. if blips > 0 and options.useBlips.value then
  7417. local profile = BuildBlipProfile(refAlly)
  7418. if profile then
  7419. blipsResolved = true
  7420. for did, p in pairs(profile) do
  7421. local v = blips * p * (cost[did] or 0)
  7422. eByDef[did] = (eByDef[did] or 0) + v
  7423. eCount[did] = (eCount[did] or 0) + blips * p
  7424. Ve = Ve + v
  7425. end
  7426. else
  7427. -- Nothing remembered to draw from. A contact is still a unit, so price it as an
  7428. -- average of what we can see and let it score every matchup as even.
  7429. local mean = (eUnits > 0) and (Ve / eUnits) or 200
  7430. local v = blips * mean
  7431. eByDef[BLIP_KEY] = (eByDef[BLIP_KEY] or 0) + v
  7432. eCount[BLIP_KEY] = (eCount[BLIP_KEY] or 0) + blips
  7433. Ve = Ve + v
  7434. end
  7435. end
  7436.  
  7437. if Ve <= 0 then
  7438. return nil, "No enemies within range of this force."
  7439. end
  7440.  
  7441. return {
  7442. fByDef = fByDef, fCount = fCount, Vf = Vf, fUnits = fUnits,
  7443. eByDef = eByDef, eCount = eCount, Ve = Ve, eUnits = eUnits,
  7444. blips = blips, blipsResolved = blipsResolved,
  7445. cx = cx, cz = cz, radius = ringR,
  7446. -- How far outside the ring the nearest enemy stands; 0 once one is at or inside it.
  7447. gap = max(0, gap2 ^ 0.5 - ringR),
  7448. lastHit = lastHit,
  7449. }
  7450. end
  7451.  
  7452. --------------------------------------------------------------------------------
  7453. -- Scoring
  7454. --------------------------------------------------------------------------------
  7455.  
  7456. local function RoleOf(cm, defID)
  7457. if defID == BLIP_KEY then
  7458. return "unknown"
  7459. end
  7460. -- ColumnArch rather than the raw archetype: a building is a position rather than a
  7461. -- unit, and the matrix has columns for exactly that. It reads better in the breakdown
  7462. -- too, where "static defence" says more than "riot" does about a Stinger.
  7463. return (cm.ColumnArch and cm.ColumnArch(defID)) or "support"
  7464. end
  7465.  
  7466. local function Plural(cm, role)
  7467. if role == "unknown" then
  7468. return "unidentified units"
  7469. end
  7470. return (cm and cm.ROLE_PLURAL and cm.ROLE_PLURAL[role]) or (role .. "s")
  7471. end
  7472.  
  7473. -- Four of the matrix's role names are mass nouns -- "artillery", "anti-air", "static
  7474. -- defence", "economy" -- so as the subject of a factor line they want a singular verb.
  7475. -- Without this the panel says "Their static defence dominate your raiders".
  7476. local MASS_NOUN_ROLE = {arty = true, aa = true, defense = true, eco = true}
  7477.  
  7478. local function Verb(role, stem)
  7479. return MASS_NOUN_ROLE[role] and (stem .. "s") or stem
  7480. end
  7481.  
  7482. local function RollUp(cm, byDef, counts)
  7483. local acc = {}
  7484. for did, v in pairs(byDef) do
  7485. local role = cm and RoleOf(cm, did) or "all"
  7486. local a = acc[role]
  7487. if not a then
  7488. a = {role = role, value = 0, count = 0, topDef = did, topVal = -1}
  7489. acc[role] = a
  7490. end
  7491. a.value = a.value + v
  7492. a.count = a.count + (counts[did] or 0)
  7493. if v > a.topVal then
  7494. a.topVal, a.topDef = v, did
  7495. end
  7496. end
  7497. local list = {}
  7498. for _, a in pairs(acc) do
  7499. list[#list + 1] = a
  7500. end
  7501. table.sort(list, function(x, y) return x.value > y.value end)
  7502. return list
  7503. end
  7504.  
  7505. -- One gathered fight in, one prediction out. Split from Recompute so the front overlay can
  7506. -- score fights nobody has selected.
  7507. local function Score(g)
  7508. local cm = WG.CounterMatrix
  7509. local degraded = (cm == nil)
  7510. local fByDef, eByDef, Vf, Ve = g.fByDef, g.eByDef, g.Vf, g.Ve
  7511.  
  7512. local Pf, Pe = Vf, Ve
  7513. local blindF, blindE = 0, 0
  7514. local qAvgF, qAvgE = 1, 1 -- value-weighted tier multiplier per side, for the log
  7515. local netPair = {}
  7516.  
  7517. if not degraded then
  7518. local PairScore, isAA = cm.PairScore, cm.isAA or {}
  7519.  
  7520. -- Each side's AA presence as a share of its value. PairScore uses it to price air
  7521. -- against the AA already standing on the field, rather than only against whatever
  7522. -- single unit it happens to be scored against at that moment.
  7523. local fAA, eAA = 0, 0
  7524. for did, v in pairs(fByDef) do
  7525. if did ~= BLIP_KEY and isAA[did] then
  7526. fAA = fAA + v
  7527. end
  7528. end
  7529. for did, v in pairs(eByDef) do
  7530. if did ~= BLIP_KEY and isAA[did] then
  7531. eAA = eAA + v
  7532. end
  7533. end
  7534. local fAAShare = (Vf > 0) and (fAA / Vf) or 0
  7535. local eAAShare = (Ve > 0) and (eAA / Ve) or 0
  7536.  
  7537. local hitByF, hitByE = {}, {}
  7538. local roleF, roleE = {}, {}
  7539. for did in pairs(fByDef) do
  7540. roleF[did] = RoleOf(cm, did)
  7541. end
  7542. for did in pairs(eByDef) do
  7543. roleE[did] = RoleOf(cm, did)
  7544. end
  7545.  
  7546. -- Tier, as a per-unit multiplier on the value each side brings. It sits outside the
  7547. -- pair loops on purpose: the matrix prices a matchup, while this prices the unit,
  7548. -- and a good unit is worth having whichever end of the field it is on. Absent from
  7549. -- an older Build Proposer, in which case every unit is unrated and nothing moves.
  7550. local qw = options.qualityWeight.value
  7551. local Quality = cm.Quality
  7552. local qF, qE = {}, {}
  7553. if Quality and qw > 0 then
  7554. for did in pairs(fByDef) do
  7555. qF[did] = (did ~= BLIP_KEY) and (Quality(did) ^ qw) or 1
  7556. end
  7557. for did in pairs(eByDef) do
  7558. qE[did] = (did ~= BLIP_KEY) and (Quality(did) ^ qw) or 1
  7559. end
  7560. local sf, se = 0, 0
  7561. for did, v in pairs(fByDef) do
  7562. sf = sf + v * (qF[did] or 1)
  7563. end
  7564. for did, v in pairs(eByDef) do
  7565. se = se + v * (qE[did] or 1)
  7566. end
  7567. qAvgF = (Vf > 0) and (sf / Vf) or 1
  7568. qAvgE = (Ve > 0) and (se / Ve) or 1
  7569. end
  7570.  
  7571. Pf = 0
  7572. for d, vd in pairs(fByDef) do
  7573. local acc = 0
  7574. for e, ve in pairs(eByDef) do
  7575. local s = NEUTRAL
  7576. if d ~= BLIP_KEY and e ~= BLIP_KEY then
  7577. s = PairScore(d, e, eAAShare)
  7578. end
  7579. local share = ve / Ve
  7580. acc = acc + share * s
  7581. if s > 0 then
  7582. hitByF[e] = true
  7583. end
  7584. local key = roleF[d] .. "/" .. roleE[e]
  7585. netPair[key] = (netPair[key] or 0) + vd * share * (s - NEUTRAL)
  7586. end
  7587. Pf = Pf + vd * acc * (qF[d] or 1)
  7588. end
  7589.  
  7590. Pe = 0
  7591. for e, ve in pairs(eByDef) do
  7592. local acc = 0
  7593. for d, vd in pairs(fByDef) do
  7594. local s = NEUTRAL
  7595. if d ~= BLIP_KEY and e ~= BLIP_KEY then
  7596. s = PairScore(e, d, fAAShare)
  7597. end
  7598. local share = vd / Vf
  7599. acc = acc + share * s
  7600. if s > 0 then
  7601. hitByE[d] = true
  7602. end
  7603. local key = roleF[d] .. "/" .. roleE[e]
  7604. netPair[key] = (netPair[key] or 0) - ve * share * (s - NEUTRAL)
  7605. end
  7606. Pe = Pe + ve * acc * (qE[e] or 1)
  7607. end
  7608.  
  7609. -- Value on the field this side has no weapon for at all. It already scores zero
  7610. -- inside the sums above, but "I cannot ever kill that" is worse than averaging zero
  7611. -- against it: whatever else happens, that part of the enemy force walks away intact
  7612. -- and comes back. This is the term that makes gunships over an army with no AA read
  7613. -- as hopeless rather than merely bad.
  7614. for e, ve in pairs(eByDef) do
  7615. if not hitByF[e] then
  7616. blindF = blindF + ve
  7617. end
  7618. end
  7619. for d, vd in pairs(fByDef) do
  7620. if not hitByE[d] then
  7621. blindE = blindE + vd
  7622. end
  7623. end
  7624. blindF = (Ve > 0) and (blindF / Ve) or 0
  7625. blindE = (Vf > 0) and (blindE / Vf) or 0
  7626.  
  7627. Pf = Pf * (1 - blindF)
  7628. Pe = Pe * (1 - blindE)
  7629. end
  7630.  
  7631. -- Effective strength to a probability. Written as 1/(1+(Pe/Pf)^k) rather than
  7632. -- Pf^k/(Pf^k+Pe^k) so a large army and a high exponent cannot overflow on the way.
  7633. local k = options.decisiveness.value
  7634. local p
  7635. if Pf <= 0 and Pe <= 0 then
  7636. p = 0.5
  7637. elseif Pf <= 0 then
  7638. p = 0
  7639. elseif Pe <= 0 then
  7640. p = 1
  7641. else
  7642. p = 1 / (1 + (Pe / Pf) ^ k)
  7643. end
  7644. -- Never claim certainty. Micro, terrain and reinforcements are all outside this model.
  7645. if p < 0.01 then
  7646. p = 0.01
  7647. elseif p > 0.99 then
  7648. p = 0.99
  7649. end
  7650.  
  7651. -- Key factors, from a single net figure per role pair so the same matchup cannot be
  7652. -- reported twice from both directions at once.
  7653. local factors = {}
  7654. if blindF > 0.005 then
  7655. factors[#factors + 1] = {
  7656. sign = -1,
  7657. text = ("You cannot hit %d%% of their force"):format(floor(blindF * 100 + 0.5)),
  7658. }
  7659. end
  7660. if blindE > 0.005 then
  7661. factors[#factors + 1] = {
  7662. sign = 1,
  7663. text = ("They cannot hit %d%% of your force"):format(floor(blindE * 100 + 0.5)),
  7664. }
  7665. end
  7666. if not degraded then
  7667. local ranked = {}
  7668. local scale = max(Vf + Ve, 1)
  7669. for key, net in pairs(netPair) do
  7670. local mag = abs(net) / scale
  7671. if mag > 0.01 then
  7672. ranked[#ranked + 1] = {key = key, net = net, mag = mag}
  7673. end
  7674. end
  7675. table.sort(ranked, function(a, b) return a.mag > b.mag end)
  7676. for i = 1, min(3, #ranked) do
  7677. if #factors >= 4 then
  7678. break
  7679. end
  7680. local rec = ranked[i]
  7681. local rf, re = rec.key:match("^(.-)/(.*)$")
  7682. local stem = (rec.mag > 0.12) and "dominate" or "beat"
  7683. local text
  7684. if rec.net > 0 then
  7685. text = ("Your %s %s their %s")
  7686. :format(Plural(cm, rf), Verb(rf, stem), Plural(cm, re))
  7687. else
  7688. text = ("Their %s %s your %s")
  7689. :format(Plural(cm, re), Verb(re, stem), Plural(cm, rf))
  7690. end
  7691. factors[#factors + 1] = {sign = (rec.net > 0) and 1 or -1, text = text}
  7692. end
  7693. end
  7694.  
  7695. local notes = {}
  7696. if degraded then
  7697. notes[#notes + 1] = "Build Proposer off - comparing metal only"
  7698. end
  7699. if g.blips > 0 then
  7700. local how
  7701. if not options.useBlips.value then
  7702. how = "ignored"
  7703. elseif g.blipsResolved then
  7704. how = "estimated from Enemy Intel"
  7705. else
  7706. how = "estimated at average cost"
  7707. end
  7708. notes[#notes + 1] = ("%d of %d enemies unidentified (%s)")
  7709. :format(g.blips, g.blips + g.eUnits, how)
  7710. end
  7711.  
  7712. return {
  7713. p = p, Pf = Pf, Pe = Pe, Vf = Vf, Ve = Ve,
  7714. fUnits = g.fUnits, eUnits = g.eUnits + g.blips,
  7715. degraded = degraded,
  7716. qAvgF = qAvgF, qAvgE = qAvgE,
  7717. rolesF = RollUp(cm, fByDef, g.fCount),
  7718. rolesE = RollUp(cm, eByDef, g.eCount),
  7719. factors = factors,
  7720. notes = notes,
  7721. cx = g.cx, cz = g.cz, radius = g.radius, gap = g.gap, lastHit = g.lastHit,
  7722. }
  7723. end
  7724.  
  7725. local function Recompute()
  7726. local g, why = Gather()
  7727. if not g then
  7728. result = nil
  7729. idleText = why or "No prediction."
  7730. return
  7731. end
  7732. result = Score(g)
  7733. end
  7734.  
  7735. LogDetail = function()
  7736. Recompute()
  7737. local r = result
  7738. if not r then
  7739. spEcho("Engagement Predictor: " .. idleText)
  7740. return
  7741. end
  7742. spEcho(("Engagement Predictor: win %d%% power %d vs %d metal %d vs %d%s")
  7743. :format(floor(r.p * 100 + 0.5), floor(r.Pf), floor(r.Pe), floor(r.Vf), floor(r.Ve),
  7744. r.degraded and " [no counter matrix]" or ""))
  7745. -- Power is metal times matchups times tier, and the tier part is the one that moves
  7746. -- without anything on screen saying so, so name it here.
  7747. if not r.degraded and (abs(r.qAvgF - 1) > 0.005 or abs(r.qAvgE - 1) > 0.005) then
  7748. spEcho((" tier multiplier us %.2fx them %.2fx"):format(r.qAvgF, r.qAvgE))
  7749. end
  7750. local function Side(label, list)
  7751. for i = 1, #list do
  7752. local a = list[i]
  7753. spEcho((" %s %-14s x%-7.1f %d m"):format(label, a.role, a.count, floor(a.value)))
  7754. end
  7755. end
  7756. Side("us ", r.rolesF)
  7757. Side("them", r.rolesE)
  7758. for i = 1, #r.factors do
  7759. spEcho((" %s %s"):format(r.factors[i].sign > 0 and "+" or "-", r.factors[i].text))
  7760. end
  7761. for i = 1, #r.notes do
  7762. spEcho(" . " .. r.notes[i])
  7763. end
  7764. end
  7765.  
  7766. --------------------------------------------------------------------------------
  7767. -- Chili UI
  7768. --------------------------------------------------------------------------------
  7769.  
  7770. local Chili
  7771. local window_main, scrollPanel, holder
  7772. local lblWin, barWin, lblTotals
  7773. local rowPool = {}
  7774.  
  7775. local PAD_L, PAD_T, PAD_R, PAD_B = 8, 6, 8, 8
  7776. local ICON = 18
  7777. local ROW_H_ICON = 22
  7778. local ROW_H_TEXT = 15
  7779. local GAP = 6
  7780. local WIN_H = 32 -- the big percentage
  7781. local BAR_W = 180
  7782. local BAR_H = 10
  7783. local TOTALS_H = 16
  7784. local ICON_GAP = 6
  7785. local MIN_WIN_W = 200
  7786. local MAX_WIN_W = 420
  7787. local MIN_WIN_H = 70
  7788. local WIN_VPAD = 12
  7789. local WIN_HPAD = 12
  7790. local SCROLLBAR_W = 14
  7791. local MAX_WIN_FRAC = 0.7
  7792.  
  7793. local COL_GOOD = {0.35, 0.85, 0.40, 1}
  7794. local COL_BAD = {0.90, 0.30, 0.28, 1}
  7795. local COL_EVEN = {0.95, 0.82, 0.30, 1}
  7796. local COL_HEAD = {0.72, 0.78, 0.90, 1}
  7797. local COL_TEXT = {0.92, 0.92, 0.92, 1}
  7798. local COL_NOTE = {0.62, 0.62, 0.62, 1}
  7799.  
  7800. -- The two palettes the verdict colours come from. ApplyPalette copies one into COL_GOOD,
  7801. -- COL_EVEN and COL_BAD *in place*, so every holder of those tables follows the switch.
  7802. local PALETTES = {
  7803. [false] = {bad = {0.90, 0.30, 0.28}, even = {0.95, 0.82, 0.30}, good = {0.35, 0.85, 0.40}},
  7804. [true] = {bad = {0.95, 0.55, 0.15}, even = {0.72, 0.72, 0.72}, good = {0.30, 0.60, 1.00}},
  7805. }
  7806.  
  7807. ApplyPalette = function(colourBlind)
  7808. local pal = PALETTES[colourBlind and true or false]
  7809. for i = 1, 3 do
  7810. COL_BAD[i], COL_EVEN[i], COL_GOOD[i] = pal.bad[i], pal.even[i], pal.good[i]
  7811. end
  7812. dirty = true -- the panel's colours were taken at its last rebuild
  7813. end
  7814.  
  7815. -- Red through amber to green, so the colour carries the result as fast as the number does.
  7816. -- Three numbers rather than a table: the rings call this every frame.
  7817. local function WinRGB(p)
  7818. local a, b, t
  7819. if p < 0.5 then
  7820. a, b, t = COL_BAD, COL_EVEN, p * 2
  7821. else
  7822. a, b, t = COL_EVEN, COL_GOOD, (p - 0.5) * 2
  7823. end
  7824. return a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t
  7825. end
  7826.  
  7827. -- The panel keeps the table form: a Chili font wants its colour as one.
  7828. local function WinColor(p)
  7829. local r, g, b = WinRGB(p)
  7830. return {r, g, b, 1}
  7831. end
  7832.  
  7833. -- Chili exposes each control's font; fall back to a rough estimate if it does not.
  7834. local function TextWidth(ctrl, text)
  7835. local font = ctrl and ctrl.font
  7836. if font and font.GetTextWidth then
  7837. local ok, w = pcall(font.GetTextWidth, font, text)
  7838. if ok and w then
  7839. return ceil(w)
  7840. end
  7841. end
  7842. return ceil(#text * 7)
  7843. end
  7844.  
  7845. -- One pooled row shape covers every line the panel draws: an optional tactical icon and a
  7846. -- caption. Rows are recycled rather than rebuilt, because a Chili control is expensive to
  7847. -- create and this panel re-lays itself out twice a second.
  7848. local function EnsureRow(i)
  7849. local row = rowPool[i]
  7850. if row then
  7851. return row
  7852. end
  7853. local ctrl = Chili.Control:New{
  7854. parent = holder,
  7855. x = 0, y = 0,
  7856. width = MAX_WIN_W, height = ROW_H_TEXT,
  7857. padding = {0, 0, 0, 0},
  7858. }
  7859. local img = Chili.Image:New{
  7860. parent = ctrl,
  7861. x = 0, y = 2,
  7862. width = ICON, height = ICON,
  7863. keepAspect = true,
  7864. file = DEFAULT_ICON,
  7865. }
  7866. local lbl = Chili.Label:New{
  7867. parent = ctrl,
  7868. x = 0, y = 1,
  7869. font = {
  7870. size = 12,
  7871. color = COL_TEXT,
  7872. outline = true,
  7873. outlineWidth = 3,
  7874. outlineWeight = 6,
  7875. outlineColor = {0, 0, 0, 1},
  7876. },
  7877. caption = "",
  7878. }
  7879. row = {ctrl = ctrl, img = img, lbl = lbl}
  7880. rowPool[i] = row
  7881. return row
  7882. end
  7883.  
  7884. local function Rebuild()
  7885. if not (window_main and holder) then
  7886. return
  7887. end
  7888.  
  7889. local r = result
  7890. local y = PAD_T
  7891. local used = 0
  7892. local widest = 0
  7893.  
  7894. local function Line(text, color, icon, indent)
  7895. used = used + 1
  7896. local row = EnsureRow(used)
  7897. local h = icon and ROW_H_ICON or ROW_H_TEXT
  7898. local x = indent or 0
  7899. row.ctrl:SetPos(PAD_L, y, MAX_WIN_W, h)
  7900. row.ctrl:Show()
  7901. if icon then
  7902. row.img.file = icon
  7903. row.img:SetPos(x, floor((h - ICON) / 2), ICON, ICON)
  7904. row.img:Show()
  7905. row.img:Invalidate()
  7906. x = x + ICON + ICON_GAP
  7907. else
  7908. row.img:Hide()
  7909. end
  7910. row.lbl:SetPos(x, floor((h - 12) / 2))
  7911. row.lbl.font.color = color or COL_TEXT
  7912. row.lbl:SetCaption(text)
  7913. row.lbl:Invalidate()
  7914. local w = x + TextWidth(row.lbl, text)
  7915. if w > widest then
  7916. widest = w
  7917. end
  7918. y = y + h
  7919. end
  7920.  
  7921. if not r then
  7922. lblWin:SetCaption("--")
  7923. lblWin.font.color = COL_NOTE
  7924. lblWin:SetPos(PAD_L, y, MAX_WIN_W, WIN_H)
  7925. lblWin:Invalidate()
  7926. barWin:Hide()
  7927. lblTotals:Hide()
  7928. y = y + WIN_H + GAP
  7929. Line(idleText, COL_NOTE)
  7930. else
  7931. local pct = floor(r.p * 100 + 0.5)
  7932. lblWin:SetCaption(pct .. "%")
  7933. lblWin.font.color = WinColor(r.p)
  7934. lblWin:SetPos(PAD_L, y, MAX_WIN_W, WIN_H)
  7935. lblWin:Invalidate()
  7936. y = y + WIN_H
  7937.  
  7938. barWin:SetPos(PAD_L, y, BAR_W, BAR_H)
  7939. barWin:SetValue(pct)
  7940. barWin:Show()
  7941. y = y + BAR_H + 4
  7942.  
  7943. lblTotals:SetPos(PAD_L, y, MAX_WIN_W, TOTALS_H)
  7944. lblTotals:SetCaption(("You %d m (%d) vs %d m (%d)")
  7945. :format(floor(r.Vf), r.fUnits, floor(r.Ve), r.eUnits))
  7946. lblTotals:Show()
  7947. lblTotals:Invalidate()
  7948. y = y + TOTALS_H + GAP
  7949. if widest < BAR_W then
  7950. widest = BAR_W
  7951. end
  7952.  
  7953. if options.showBreakdown.value and not r.degraded then
  7954. local cm = WG.CounterMatrix
  7955. local function Section(label, list)
  7956. Line(label, COL_HEAD)
  7957. for i = 1, #list do
  7958. local a = list[i]
  7959. -- No icon for the unidentified: DEFAULT_ICON is fully transparent, so it
  7960. -- drew a blank box. Line hides the image when the icon is nil.
  7961. local icon = (a.topDef ~= BLIP_KEY) and iconFile[a.topDef] or nil
  7962. Line(("%d %s %d m"):format(floor(a.count + 0.5), Plural(cm, a.role),
  7963. floor(a.value)),
  7964. COL_TEXT, icon, 4)
  7965. end
  7966. end
  7967. Section("Your force", r.rolesF)
  7968. if #r.rolesE > 0 then
  7969. y = y + 2
  7970. Section("Their force", r.rolesE)
  7971. end
  7972. end
  7973.  
  7974. if options.showFactors.value and #r.factors > 0 then
  7975. y = y + GAP
  7976. Line("Why", COL_HEAD)
  7977. for i = 1, #r.factors do
  7978. local f = r.factors[i]
  7979. Line((f.sign > 0 and "+ " or "- ") .. f.text,
  7980. (f.sign > 0) and COL_GOOD or COL_BAD, nil, 4)
  7981. end
  7982. end
  7983.  
  7984. for i = 1, #r.notes do
  7985. if i == 1 then
  7986. y = y + GAP
  7987. end
  7988. Line(r.notes[i], COL_NOTE)
  7989. end
  7990. end
  7991.  
  7992. for i = used + 1, #rowPool do
  7993. rowPool[i].ctrl:Hide()
  7994. end
  7995.  
  7996. local contentH = y + PAD_B
  7997. local contentW = widest + PAD_L + PAD_R
  7998. holder:SetPos(nil, nil, nil, contentH)
  7999.  
  8000. -- Both dimensions follow the content. Height is capped so a big fight cannot run off
  8001. -- the screen; past the cap the ScrollPanel takes over and the width pays for a bar.
  8002. local screenH = (Chili.Screen0 and Chili.Screen0.height) or 1080
  8003. local screenW = (Chili.Screen0 and Chili.Screen0.width) or 1920
  8004. local maxH = floor(screenH * MAX_WIN_FRAC)
  8005. local wantH = max(min(contentH + WIN_VPAD, maxH), MIN_WIN_H)
  8006. local scrolling = (contentH + WIN_VPAD) > maxH
  8007. local wantW = contentW + WIN_HPAD + (scrolling and SCROLLBAR_W or 0)
  8008. wantW = max(min(wantW, MAX_WIN_W), MIN_WIN_W)
  8009.  
  8010. if window_main.width ~= wantW or window_main.height ~= wantH then
  8011. local x = window_main.x
  8012. -- Grow towards the middle of the screen, so a panel parked on the right keeps its
  8013. -- right edge planted instead of walking off it.
  8014. if x + window_main.width * 0.5 > screenW * 0.5 then
  8015. x = max(0, x + window_main.width - wantW)
  8016. end
  8017. window_main:SetPos(x, window_main.y, wantW, wantH)
  8018. end
  8019. end
  8020.  
  8021. local function CreateWindow()
  8022. window_main = Chili.Window:New{
  8023. parent = Chili.Screen0,
  8024. name = "EngagementPredictor",
  8025. classname = "main_window_small_very_flat",
  8026. color = {0, 0, 0, 0},
  8027. backgroundColor = {0, 0, 0, 0},
  8028. borderColor = {0, 0, 0, 0},
  8029. dockable = true,
  8030. dockableSavePositionOnly = true,
  8031. parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
  8032. right = 273, y = 44,
  8033. clientWidth = 240,
  8034. clientHeight = 120,
  8035. minWidth = MIN_WIN_W, minHeight = MIN_WIN_H,
  8036. draggable = false, resizable = false,
  8037. tweakDraggable = true, tweakResizable = true,
  8038. padding = {6, 6, 6, 6},
  8039. }
  8040.  
  8041. scrollPanel = Chili.ScrollPanel:New{
  8042. parent = window_main,
  8043. x = 0, y = 0, right = 0, bottom = 0,
  8044. padding = {0, 0, 0, 0},
  8045. borderColor = {0, 0, 0, 0},
  8046. backgroundColor = {0, 0, 0, 0},
  8047. horizontalScrollbar = false,
  8048. }
  8049.  
  8050. holder = Chili.Control:New{
  8051. parent = scrollPanel,
  8052. x = 0, y = 0, right = 0,
  8053. height = 1,
  8054. padding = {0, 0, 0, 0},
  8055. }
  8056.  
  8057. lblWin = Chili.Label:New{
  8058. parent = holder,
  8059. x = PAD_L, y = PAD_T,
  8060. width = MAX_WIN_W, height = WIN_H,
  8061. font = {
  8062. size = 26,
  8063. color = COL_EVEN,
  8064. outline = true,
  8065. outlineWidth = 4,
  8066. outlineWeight = 8,
  8067. outlineColor = {0, 0, 0, 1},
  8068. },
  8069. caption = "--",
  8070. }
  8071.  
  8072. barWin = Chili.Progressbar:New{
  8073. parent = holder,
  8074. x = PAD_L, y = PAD_T + WIN_H,
  8075. width = BAR_W, height = BAR_H,
  8076. min = 0, max = 100, value = 50,
  8077. noFont = true,
  8078. -- The bar is the split itself: filled is your side, the ground behind it is theirs.
  8079. color = {0.35, 0.80, 0.40, 0.95},
  8080. backgroundColor = {0.55, 0.16, 0.16, 0.85},
  8081. }
  8082.  
  8083. lblTotals = Chili.Label:New{
  8084. parent = holder,
  8085. x = PAD_L, y = PAD_T + WIN_H + BAR_H + 4,
  8086. width = MAX_WIN_W, height = TOTALS_H,
  8087. font = {
  8088. size = 12,
  8089. color = COL_NOTE,
  8090. outline = true,
  8091. outlineWidth = 3,
  8092. outlineWeight = 6,
  8093. outlineColor = {0, 0, 0, 1},
  8094. },
  8095. caption = "",
  8096. }
  8097. end
  8098.  
  8099. -- Show/hide only. The window is kept alive so its docked position, and anything the
  8100. -- tweak-mode layout did to it, survive being toggled off and on again.
  8101. function SetPanelVisible(visible)
  8102. if not window_main then
  8103. return
  8104. end
  8105. if visible then
  8106. window_main:Show()
  8107. dirty = true -- whatever is selected now was never scored; do it before it is read
  8108. else
  8109. window_main:Hide()
  8110. end
  8111. end
  8112.  
  8113. --------------------------------------------------------------------------------
  8114. -- Callins
  8115. --------------------------------------------------------------------------------
  8116.  
  8117. --------------------------------------------------------------------------------
  8118. -- Rings on the map
  8119. --------------------------------------------------------------------------------
  8120.  
  8121. -- The word over the selection ring. The same thresholds are in the option's description.
  8122. local function Verdict(p)
  8123. if p >= 0.65 then
  8124. return "Favourable - engage"
  8125. elseif p >= 0.45 then
  8126. return "Even fight"
  8127. elseif p >= 0.25 then
  8128. return "Unfavourable"
  8129. end
  8130. return "Retreat!"
  8131. end
  8132.  
  8133. -- Clusters of recently hit units of ours, each scored as if it were the selection. Greedy
  8134. -- clustering against running centres is plenty for a handful of fights: two groups a
  8135. -- screen apart never share a front, and one group never splits over a grid line.
  8136. local function FindFronts(gt)
  8137. local hitAt = rings.hitAt
  8138. local clusters = {}
  8139. for uid, t in pairs(hitAt) do
  8140. local x, _, z = spGetUnitPosition(uid)
  8141. if gt - t > rings.MEMORY or not x then
  8142. hitAt[uid] = nil
  8143. else
  8144. local join
  8145. for i = 1, #clusters do
  8146. local c = clusters[i]
  8147. local dx, dz = c.x / c.n - x, c.z / c.n - z
  8148. if dx * dx + dz * dz <= rings.JOIN * rings.JOIN then
  8149. join = c
  8150. break
  8151. end
  8152. end
  8153. if not join then
  8154. join = {x = 0, z = 0, n = 0, units = {}}
  8155. clusters[#clusters + 1] = join
  8156. end
  8157. join.x, join.z, join.n = join.x + x, join.z + z, join.n + 1
  8158. join.units[join.n] = uid
  8159. end
  8160. end
  8161. table.sort(clusters, function(a, b)
  8162. if a.n ~= b.n then return a.n > b.n end
  8163. return a.units[1] < b.units[1]
  8164. end)
  8165.  
  8166. -- The selection already has its ring and its word; a front on top of it would only say
  8167. -- the same thing again in a second colour.
  8168. -- Scoring is not done here. Gather and Score for six fronts in one frame was a visible
  8169. -- hitch once a second, so the clusters are queued and Update scores one per frame.
  8170. local sel = options.selRing.value and result
  8171. local queue = {}
  8172. for i = 1, #clusters do
  8173. local c = clusters[i]
  8174. local mx, mz = c.x / c.n, c.z / c.n
  8175. local dup = false
  8176. if sel and sel.cx then
  8177. local dx, dz = sel.cx - mx, sel.cz - mz
  8178. dup = dx * dx + dz * dz <= (sel.radius + rings.JOIN * 0.5) ^ 2
  8179. end
  8180. if not dup then
  8181. queue[#queue + 1] = c.units
  8182. end
  8183. end
  8184. rings.queue, rings.qi, rings.pending = queue, 1, {}
  8185. if #queue == 0 then
  8186. CommitFronts(gt)
  8187. end
  8188. end
  8189.  
  8190. -- One queued cluster scored. The list is committed whole, once the queue runs dry or the
  8191. -- cap is reached, so the fronts are never half this second's and half the last's.
  8192. local function ScoreNextFront()
  8193. local queue, i = rings.queue, rings.qi
  8194. if i > #queue then
  8195. return
  8196. end
  8197. rings.qi = i + 1
  8198. local g = Gather(queue[i])
  8199. if g then
  8200. local r = Score(g)
  8201. local pending = rings.pending
  8202. pending[#pending + 1] = {cx = r.cx, cz = r.cz, radius = r.radius, p = r.p, gap = r.gap,
  8203. lastHit = r.lastHit}
  8204. end
  8205. if rings.qi > #queue or #rings.pending >= rings.MAX then
  8206. CommitFronts(Spring.GetGameSeconds())
  8207. end
  8208. end
  8209.  
  8210. --------------------------------------------------------------------------------
  8211. -- The drawn rings: easing, fading, and the ground geometry
  8212. --------------------------------------------------------------------------------
  8213.  
  8214. local exp, sin, cos, TAU = math.exp, math.sin, math.cos, 2 * math.pi
  8215. local RING_SEG = 64 -- segments round a ring; the heights are cached at these
  8216. local DASHES = 24 -- dashes round a front
  8217. local LIFT = 3 -- elmos the lines sit above the ground
  8218.  
  8219. -- Point a drawn ring at a fresh score, and remember the chance for the trend arrow. A
  8220. -- paused game scores the same second again and again: that overwrites, so the history
  8221. -- cannot grow while game time stands still.
  8222. local function Retarget(s, r, gt)
  8223. s.tx, s.tz, s.tr, s.tp = r.cx, r.cz, r.radius, r.p
  8224. s.tgap = r.gap or 0
  8225. s.lastHit = r.lastHit or -huge
  8226. s.Vf, s.Ve = r.Vf, r.Ve
  8227. local ht, hp = s.ht, s.hp
  8228. local n = #ht
  8229. if n > 0 and ht[n] >= gt then
  8230. hp[n] = r.p
  8231. else
  8232. ht[n + 1], hp[n + 1] = gt, r.p
  8233. end
  8234. while #ht > 1 and ht[1] < gt - 2 * rings.TREND do
  8235. table.remove(ht, 1)
  8236. table.remove(hp, 1)
  8237. end
  8238. end
  8239.  
  8240. -- A drawn ring born where its score says, invisible, to fade in from there.
  8241. local function NewShown(r, gt)
  8242. local s = {
  8243. cx = r.cx, cz = r.cz, radius = r.radius, p = r.p, gap = r.gap or 0,
  8244. alpha = 0, live = true,
  8245. ht = {}, hp = {},
  8246. hOut = {}, hIn = {},
  8247. }
  8248. Retarget(s, r, gt)
  8249. return s
  8250. end
  8251.  
  8252. -- +1, -1 or 0: how the chance has moved against the newest score at least TREND s old.
  8253. local function Trend(s)
  8254. local ht, hp = s.ht, s.hp
  8255. local n = #ht
  8256. if n < 2 then
  8257. return 0
  8258. end
  8259. local cut = ht[n] - rings.TREND
  8260. for i = n - 1, 1, -1 do
  8261. if ht[i] <= cut then
  8262. local d = hp[n] - hp[i]
  8263. if d > rings.TREND_MIN then
  8264. return 1
  8265. elseif d < -rings.TREND_MIN then
  8266. return -1
  8267. end
  8268. return 0
  8269. end
  8270. end
  8271. return 0
  8272. end
  8273.  
  8274. -- Each new front takes over the nearest drawn ring within JOIN, keeping its history and
  8275. -- its place on screen; anything left over is a new fight and fades in. A drawn ring no
  8276. -- front claimed is over and fades out -- or back in, if the fight flares up again first.
  8277. CommitFronts = function(gt)
  8278. local fronts = rings.pending
  8279. rings.fronts = fronts
  8280. rings.queue, rings.qi, rings.pending = {}, 1, {}
  8281.  
  8282. local shown = rings.shown
  8283. for j = 1, #shown do
  8284. shown[j].live = false
  8285. end
  8286. for i = 1, #fronts do
  8287. local f = fronts[i]
  8288. local best, bestD = nil, rings.JOIN * rings.JOIN
  8289. for j = 1, #shown do
  8290. local s = shown[j]
  8291. if not s.live then
  8292. local dx, dz = s.tx - f.cx, s.tz - f.cz
  8293. local d = dx * dx + dz * dz
  8294. if d < bestD then
  8295. best, bestD = s, d
  8296. end
  8297. end
  8298. end
  8299. if best then
  8300. Retarget(best, f, gt)
  8301. else
  8302. best = NewShown(f, gt)
  8303. shown[#shown + 1] = best
  8304. end
  8305. best.live = true
  8306. end
  8307. end
  8308.  
  8309. -- Frame-rate independent: an exponential ease over dt reaches the same place whether it
  8310. -- got there in 30 steps or 144.
  8311. local function EaseRing(s, dt)
  8312. local kp = 1 - exp(-dt / rings.EASE_POS)
  8313. local kc = 1 - exp(-dt / rings.EASE_P)
  8314. s.cx = s.cx + (s.tx - s.cx) * kp
  8315. s.cz = s.cz + (s.tz - s.cz) * kp
  8316. s.radius = s.radius + (s.tr - s.radius) * kp
  8317. s.p = s.p + (s.tp - s.p) * kc
  8318. s.gap = s.gap + (s.tgap - s.gap) * kc
  8319. local step = dt / rings.FADE
  8320. -- nearOnly hides a ring whose nearest enemy is still beyond the near distance. It only
  8321. -- fades it: the ring stays live, keeping its place and history for when they close in.
  8322. local hidden = options.nearOnly.value and s.tgap > options.nearDist.value
  8323. if s.live and not hidden then
  8324. s.alpha = min(1, s.alpha + step)
  8325. else
  8326. s.alpha = max(0, s.alpha - step)
  8327. end
  8328. end
  8329.  
  8330. -- Once a frame, from Update. It follows the selection's score into `rings.sel`, snapping
  8331. -- (and fading in) rather than gliding when the new fight is somewhere else entirely.
  8332. local function Animate()
  8333. local now = Spring.GetTimer()
  8334. local dt = rings.clock and Spring.DiffTimers(now, rings.clock) or 0
  8335. rings.clock = now
  8336. if dt > 0.1 then
  8337. dt = 0.1 -- after a hitch, carry on from where it was rather than leaping
  8338. end
  8339. rings.t = rings.t + dt
  8340. local gt = Spring.GetGameSeconds()
  8341. rings.gt = gt
  8342.  
  8343. local s = rings.sel
  8344. if not options.selRing.value then
  8345. s = nil
  8346. elseif result and result.cx then
  8347. if s then
  8348. local dx, dz = result.cx - s.cx, result.cz - s.cz
  8349. if dx * dx + dz * dz > rings.JOIN * rings.JOIN then
  8350. s = nil
  8351. end
  8352. end
  8353. if not s then
  8354. s = NewShown(result, gt)
  8355. elseif s.src ~= result then
  8356. Retarget(s, result, gt)
  8357. end
  8358. s.src, s.live = result, true
  8359. elseif s then
  8360. s.live = false
  8361. end
  8362. if s then
  8363. EaseRing(s, dt)
  8364. if not s.live and s.alpha <= 0 then
  8365. s = nil
  8366. end
  8367. end
  8368. rings.sel = s
  8369.  
  8370. local shown = rings.shown
  8371. for i = #shown, 1, -1 do
  8372. local f = shown[i]
  8373. EaseRing(f, dt)
  8374. if not f.live and f.alpha <= 0 then
  8375. table.remove(shown, i)
  8376. end
  8377. end
  8378. end
  8379.  
  8380. local function Ground(x, z)
  8381. local h = Spring.GetGroundHeight(x, z) or 0
  8382. return ((h > 0) and h or 0) + LIFT -- over water the ring floats, as the units do
  8383. end
  8384.  
  8385. -- The heights round a ring are sampled only when the ring has moved more than a few elmos
  8386. -- since the last sample. x and z are always the live eased ones, so the ring never lags;
  8387. -- only the height is a frame or two stale while it glides, which nothing can see.
  8388. local function Geo(s)
  8389. if s.gr and abs(s.cx - s.gx) < 6 and abs(s.cz - s.gz) < 6 and abs(s.radius - s.gr) < 6 then
  8390. return
  8391. end
  8392. local ro = s.radius
  8393. local ri = ro - min(40, ro * 0.3)
  8394. local hOut, hIn = s.hOut, s.hIn
  8395. for i = 0, RING_SEG - 1 do
  8396. local a = i / RING_SEG * TAU
  8397. local ux, uz = sin(a), -cos(a)
  8398. hOut[i] = Ground(s.cx + ux * ro, s.cz + uz * ro)
  8399. hIn[i] = Ground(s.cx + ux * ri, s.cz + uz * ri)
  8400. end
  8401. hOut[RING_SEG], hIn[RING_SEG] = hOut[0], hIn[0]
  8402. s.gx, s.gz, s.gr = s.cx, s.cz, s.radius
  8403. s.gy = Ground(s.cx, s.cz)
  8404. end
  8405.  
  8406. -- Angles are fractions of a turn, clockwise seen from above, from the far (-z) edge: the
  8407. -- edge the label sits on, so the gauge starts under the number.
  8408. -- `R` and `hs` default to the ring itself; the reach circle passes its own radius and heights.
  8409. local function RingVertex(s, f, R, hs)
  8410. R, hs = R or s.radius, hs or s.hOut
  8411. local idx = (f % 1) * RING_SEG
  8412. local i = floor(idx)
  8413. local h0 = hs[i]
  8414. local y = h0 + (hs[i + 1] - h0) * (idx - i)
  8415. local a = f * TAU
  8416. gl.Vertex(s.cx + sin(a) * R, y, s.cz - cos(a) * R)
  8417. end
  8418.  
  8419. -- The heights for the reach circle, cached like Geo's, but at radius + near distance.
  8420. local function ReachGeo(s, R)
  8421. if s.rgr and abs(s.cx - s.rgx) < 6 and abs(s.cz - s.rgz) < 6 and abs(R - s.rgr) < 6 then
  8422. return
  8423. end
  8424. local hs = s.hReach or {}
  8425. s.hReach = hs
  8426. for i = 0, RING_SEG - 1 do
  8427. local a = i / RING_SEG * TAU
  8428. hs[i] = Ground(s.cx + sin(a) * R, s.cz - cos(a) * R)
  8429. end
  8430. hs[RING_SEG] = hs[0]
  8431. s.rgx, s.rgz, s.rgr = s.cx, s.cz, R
  8432. end
  8433.  
  8434. -- 48 short dashes, each a single segment: at this radius they are short enough to sit on
  8435. -- the ground without bending.
  8436. local function ReachVerts(s, R)
  8437. local hs = s.hReach
  8438. for k = 0, 47 do
  8439. local f0 = k / 48
  8440. RingVertex(s, f0, R, hs)
  8441. RingVertex(s, f0 + 1 / 96, R, hs)
  8442. end
  8443. end
  8444.  
  8445. -- An arc from f0 to f1, through every segment boundary between, so it hugs the ground.
  8446. local function ArcVerts(s, f0, f1)
  8447. RingVertex(s, f0)
  8448. for i = floor(f0 * RING_SEG) + 1, ceil(f1 * RING_SEG) - 1 do
  8449. RingVertex(s, i / RING_SEG)
  8450. end
  8451. RingVertex(s, f1)
  8452. end
  8453.  
  8454. -- The glow: bright at the line, nothing a band's width inside it. Outer/inner pairs, drawn
  8455. -- as a triangle strip (the same band a quad strip gives, without the deprecated primitive).
  8456. local function BandVerts(s, r, g, b, a)
  8457. local ro = s.radius
  8458. local ri = ro - min(40, ro * 0.3)
  8459. for i = 0, RING_SEG do
  8460. local ang = i / RING_SEG * TAU
  8461. local ux, uz = sin(ang), -cos(ang)
  8462. gl.Color(r, g, b, a)
  8463. gl.Vertex(s.cx + ux * ro, s.hOut[i], s.cz + uz * ro)
  8464. gl.Color(r, g, b, 0)
  8465. gl.Vertex(s.cx + ux * ri, s.hIn[i], s.cz + uz * ri)
  8466. end
  8467. end
  8468.  
  8469. -- The front's dashes, turning with `phase`. Each is lit if its middle is inside the gauge's
  8470. -- arc and dim otherwise, so the lit run reads as the chance while the dashes slide round.
  8471. -- Two segments a dash, so a big front's dashes still bend with the ground.
  8472. local function DashVerts(s, phase, r, g, b, a, dr, dg, db, da)
  8473. local len = 0.5 / DASHES
  8474. for k = 0, DASHES - 1 do
  8475. local f0 = (k / DASHES + phase) % 1
  8476. if (f0 + len * 0.5) % 1 < s.p then
  8477. gl.Color(r, g, b, a)
  8478. else
  8479. gl.Color(dr, dg, db, da)
  8480. end
  8481. local fm = f0 + len * 0.5
  8482. RingVertex(s, f0)
  8483. RingVertex(s, fm)
  8484. RingVertex(s, fm)
  8485. RingVertex(s, f0 + len)
  8486. end
  8487. end
  8488.  
  8489. local function LoopVerts(s)
  8490. for i = 0, RING_SEG - 1 do
  8491. RingVertex(s, i / RING_SEG)
  8492. end
  8493. end
  8494.  
  8495. local function FanVerts(s)
  8496. gl.Vertex(s.cx, s.gy, s.cz)
  8497. for i = 0, RING_SEG do
  8498. RingVertex(s, i / RING_SEG)
  8499. end
  8500. end
  8501.  
  8502. -- 1 while the ringed units are hitting or being hit and for HIT_HOLD s after, easing to 0
  8503. -- over HIT_FADE s more. Read on game time, so a paused fight stays lit.
  8504. local function Combat(s)
  8505. local since = rings.gt - (s.lastHit or -huge)
  8506. if since <= rings.HIT_HOLD then
  8507. return 1
  8508. end
  8509. local t = (since - rings.HIT_HOLD) / rings.HIT_FADE
  8510. if t >= 1 then
  8511. return 0
  8512. end
  8513. return 1 - t * t * (3 - 2 * t)
  8514. end
  8515.  
  8516. -- How strongly the inside is filled. Full while an enemy is at or inside the ring (gap 0) or
  8517. -- while the ring is fighting; otherwise it eases down with the nearest enemy's distance to
  8518. -- the `fillFar` share at `fillRange` elmos out. Not the enemy gather radius: at 2000 elmos
  8519. -- that put every enemy close enough to shoot at nine tenths full, so nothing ever changed.
  8520. local function FillFactor(s)
  8521. local t = min(1, max(0, s.gap / max(options.fillRange.value, 1)))
  8522. local near = 1 - t * t * (3 - 2 * t)
  8523. local level = max(near, Combat(s))
  8524. local floorShare = options.fillFar.value / 100
  8525. return floorShare + (1 - floorShare) * level
  8526. end
  8527.  
  8528. -- Fill, glow, dark halo, then the gauge: a solid arc for the selection, dashes for a front. Below
  8529. -- 25% the whole ring pulses. Everything is scaled by the ring's fade.
  8530. local function DrawShown(s, width, dashed)
  8531. s.onScreen = false
  8532. if s.alpha <= 0.01 then
  8533. return
  8534. end
  8535. Geo(s)
  8536. local reach = options.nearReach.value and (s.radius + options.nearDist.value)
  8537. if not Spring.IsSphereInView(s.cx, s.gy, s.cz, reach or s.radius) then
  8538. return
  8539. end
  8540. s.onScreen = true
  8541. local r, g, b = WinRGB(s.p)
  8542. local a = s.alpha
  8543. local pulse = 1
  8544. if s.p < 0.25 then
  8545. pulse = 1 + 0.35 * sin(rings.t * TAU * 1.6)
  8546. end
  8547. local lit = a * min(1, 0.8 + 0.2 * pulse)
  8548.  
  8549. if reach then
  8550. ReachGeo(s, reach)
  8551. gl.LineWidth(1)
  8552. gl.Color(r, g, b, 0.3 * a)
  8553. gl.BeginEnd(GL.LINES, ReachVerts, s, reach)
  8554. end
  8555.  
  8556. if options.plainRings.value then
  8557. -- The dashes alone. The selection's stand still, so it still reads apart from a front.
  8558. local phase = dashed and (rings.t * 0.03) % 1 or 0
  8559. gl.LineWidth(width * pulse)
  8560. gl.BeginEnd(GL.LINES, DashVerts, s, phase, r, g, b, lit,
  8561. COL_BAD[1], COL_BAD[2], COL_BAD[3], 0.35 * a)
  8562. return
  8563. end
  8564.  
  8565. local fill = rings.FILL_A * a * FillFactor(s)
  8566. if options.fillFlash.value then
  8567. -- Only while shooting is going on; Combat's tail makes the flash die away, not stop.
  8568. local fighting = Combat(s)
  8569. if fighting > 0 then
  8570. local wave = 0.5 + 0.5 * cos(rings.t * TAU / rings.FLASH_PERIOD) -- 1, 0, 1
  8571. fill = fill * (1 - fighting * (1 - wave))
  8572. end
  8573. end
  8574. if fill > 0.005 then
  8575. gl.Color(r, g, b, fill)
  8576. gl.BeginEnd(GL.TRIANGLE_FAN, FanVerts, s)
  8577. end
  8578. gl.BeginEnd(GL.TRIANGLE_STRIP, BandVerts, s, r, g, b, 0.22 * a * pulse)
  8579.  
  8580. gl.LineWidth(width * pulse + 3)
  8581. gl.Color(0, 0, 0, 0.45 * a)
  8582. gl.BeginEnd(GL.LINE_LOOP, LoopVerts, s)
  8583.  
  8584. gl.LineWidth(width * pulse)
  8585. local dr, dg, db = COL_BAD[1], COL_BAD[2], COL_BAD[3]
  8586. if dashed then
  8587. gl.BeginEnd(GL.LINES, DashVerts, s, (rings.t * 0.03) % 1, r, g, b, lit, dr, dg, db, 0.35 * a)
  8588. else
  8589. gl.Color(r, g, b, lit)
  8590. gl.BeginEnd(GL.LINE_STRIP, ArcVerts, s, 0, s.p)
  8591. gl.Color(dr, dg, db, 0.35 * a)
  8592. gl.BeginEnd(GL.LINE_STRIP, ArcVerts, s, s.p, 1)
  8593. end
  8594. end
  8595.  
  8596. --------------------------------------------------------------------------------
  8597. -- Circles under the units near a ring (nearEnemies / nearAll)
  8598. --------------------------------------------------------------------------------
  8599.  
  8600. local function MarkRing(s, marks, seen, myAlly, all)
  8601. if not s or s.alpha <= 0.01 then
  8602. return
  8603. end
  8604. local us = Spring.GetUnitsInCylinder(s.cx, s.cz, s.radius + options.nearDist.value)
  8605. for i = 1, #(us or {}) do
  8606. local uid = us[i]
  8607. if not seen[uid] then
  8608. seen[uid] = true
  8609. if spGetUnitTeam(uid) ~= gaiaTeamID then
  8610. local enemy = spGetUnitAllyTeam(uid) ~= myAlly
  8611. if enemy or all then
  8612. local rad = Spring.GetUnitRadius(uid) or 20
  8613. marks[#marks + 1] = {uid, enemy, min(60, max(12, rad))}
  8614. end
  8615. end
  8616. end
  8617. end
  8618. end
  8619.  
  8620. -- Who is near is swept a few times a second, a unit counted once however many rings it is
  8621. -- near; the circles themselves follow the units every frame, in DrawMarks.
  8622. local function CollectMarks()
  8623. local marks = {}
  8624. local all = options.nearAll.value
  8625. if all or options.nearEnemies.value then
  8626. local myAlly, seen = spGetMyAllyTeamID(), {}
  8627. MarkRing(rings.sel, marks, seen, myAlly, all)
  8628. if options.showFronts.value then
  8629. local shown = rings.shown
  8630. for i = 1, #shown do
  8631. MarkRing(shown[i], marks, seen, myAlly, all)
  8632. end
  8633. end
  8634. end
  8635. rings.marks = marks
  8636. end
  8637.  
  8638. local function DrawMarks()
  8639. local marks = rings.marks
  8640. if #marks == 0 then
  8641. return
  8642. end
  8643. gl.LineWidth(1.5)
  8644. for i = 1, #marks do
  8645. local m = marks[i]
  8646. local x, y, z = spGetUnitPosition(m[1])
  8647. if x then
  8648. local c = m[2] and COL_BAD or COL_GOOD
  8649. gl.Color(c[1], c[2], c[3], 0.75)
  8650. gl.DrawGroundCircle(x, y, z, m[3], 16)
  8651. end
  8652. end
  8653. end
  8654.  
  8655. --------------------------------------------------------------------------------
  8656. -- The label over a ring
  8657. --------------------------------------------------------------------------------
  8658.  
  8659. local PILL_PAD = 5
  8660. local PILL_R = 5
  8661.  
  8662. -- A rounded rectangle as one fan: the middle, then the four corner arcs anticlockwise.
  8663. local function PillVerts(x0, y0, x1, y1, rd)
  8664. gl.Vertex((x0 + x1) * 0.5, (y0 + y1) * 0.5)
  8665. for q = 0, 3 do
  8666. local ccx = (q == 0 or q == 3) and (x1 - rd) or (x0 + rd)
  8667. local ccy = (q < 2) and (y1 - rd) or (y0 + rd)
  8668. for j = 0, 4 do
  8669. local ang = (q * 4 + j) / 16 * TAU
  8670. gl.Vertex(ccx + cos(ang) * rd, ccy + sin(ang) * rd)
  8671. end
  8672. end
  8673. gl.Vertex(x1, y1 - rd)
  8674. end
  8675.  
  8676. local function TriVerts(x, y, h, up)
  8677. if up then
  8678. gl.Vertex(x - h * 0.6, y - h * 0.5)
  8679. gl.Vertex(x + h * 0.6, y - h * 0.5)
  8680. gl.Vertex(x, y + h * 0.5)
  8681. else
  8682. gl.Vertex(x - h * 0.6, y + h * 0.5)
  8683. gl.Vertex(x, y - h * 0.5)
  8684. gl.Vertex(x + h * 0.6, y + h * 0.5)
  8685. end
  8686. end
  8687.  
  8688. local function Kilo(v)
  8689. v = v or 0
  8690. if v >= 1000 then
  8691. return ("%.1fk"):format(v / 1000)
  8692. end
  8693. return tostring(floor(v + 0.5))
  8694. end
  8695.  
  8696. local function TextW(text, size)
  8697. return (gl.GetTextWidth and gl.GetTextWidth(text) or #text * 0.55) * size
  8698. end
  8699.  
  8700. -- The label's strings and their widths only change when the whole percentage does, so they
  8701. -- are rebuilt then and not every frame.
  8702. local function LabelText(s, full)
  8703. local pct = floor(s.p * 100 + 0.5)
  8704. if pct == s.pctN and (not full or s.Vf == s.metalVf and s.Ve == s.metalVe) then
  8705. return
  8706. end
  8707. s.pctN = pct
  8708. s.pctText = pct .. "%"
  8709. if full then
  8710. s.verdict = Verdict(pct / 100) -- from the shown number, so the two never disagree
  8711. s.metalText = Kilo(s.Vf) .. " vs " .. Kilo(s.Ve)
  8712. s.metalVf, s.metalVe = s.Vf, s.Ve
  8713. end
  8714. end
  8715.  
  8716. -- The label sits on the ring's far edge, so it reads above the fight rather than on it: a
  8717. -- dark pill, the chance big, and for the selection the verdict and the metal under it.
  8718. local function Label(s, full)
  8719. if not s.onScreen or s.alpha <= 0.01 then
  8720. return
  8721. end
  8722. local fx, fz = s.cx, s.cz - s.radius
  8723. local sx, sy, sz = Spring.WorldToScreenCoords(fx, s.hOut[0] or s.gy or 0, fz)
  8724. if not sx or (sz and sz > 1) then
  8725. return -- behind the camera
  8726. end
  8727. LabelText(s, full)
  8728. local a = s.alpha
  8729. local r, g, b = WinRGB(s.p)
  8730. local big = full and 20 or 14
  8731. local small, tiny = 12, 11
  8732. local trend = Trend(s)
  8733. local arrow = (trend ~= 0) and big * 0.6 or 0
  8734.  
  8735. local w = TextW(s.pctText, big) + ((arrow > 0) and (arrow + 4) or 0)
  8736. local h = big
  8737. if full then
  8738. w = max(w, TextW(s.verdict, small), TextW(s.metalText, tiny))
  8739. h = h + small + tiny + 4
  8740. end
  8741. local x0, x1 = sx - w * 0.5 - PILL_PAD, sx + w * 0.5 + PILL_PAD
  8742. local y0 = sy + 6
  8743. local y1 = y0 + h + PILL_PAD * 2
  8744.  
  8745. -- The plain style has no box: every line gets a black outline instead. That is gl.Text's
  8746. -- lower-case "o"; the capital "O" is the *light* outline, which reads as a white halo.
  8747. local plain = options.plainRings.value
  8748. local opts = plain and "co" or "c"
  8749. if not plain then
  8750. gl.Color(0, 0, 0, 0.55 * a)
  8751. gl.BeginEnd(GL.TRIANGLE_FAN, PillVerts, x0, y0, x1, y1, PILL_R)
  8752. end
  8753.  
  8754. -- Top to bottom: the chance (and its arrow), the verdict, the metal.
  8755. local ty = y1 - PILL_PAD - big
  8756. local pctW = TextW(s.pctText, big)
  8757. local px = sx - ((arrow > 0) and (arrow + 4) * 0.5 or 0)
  8758. gl.Color(r, g, b, a)
  8759. gl.Text(s.pctText, px, ty, big, plain and "co" or "cO") -- normal style keeps its old look
  8760. if arrow > 0 then
  8761. local up = trend > 0
  8762. local c = up and COL_GOOD or COL_BAD
  8763. gl.Color(c[1], c[2], c[3], a)
  8764. gl.BeginEnd(GL.TRIANGLES, TriVerts, px + pctW * 0.5 + 4 + arrow * 0.5, ty + big * 0.4, arrow, up)
  8765. end
  8766. if full then
  8767. ty = ty - small - 2
  8768. gl.Color(r, g, b, a)
  8769. gl.Text(s.verdict, sx, ty, small, opts)
  8770. ty = ty - tiny - 2
  8771. gl.Color(0.8, 0.8, 0.8, 0.9 * a)
  8772. gl.Text(s.metalText, sx, ty, tiny, opts)
  8773. end
  8774. end
  8775.  
  8776. function M:UnitDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, _, _,
  8777. attackerID, attackerDefID, attackerTeam)
  8778. -- Both rings read the hits: the fronts are made of them, and a ring whose units are
  8779. -- hitting or being hit is fighting, however far off the nearest enemy it can see is.
  8780. if not (options.showFronts.value or options.selRing.value) then
  8781. return
  8782. end
  8783. local myAlly = spGetMyAllyTeamID()
  8784. local gt = Spring.GetGameSeconds()
  8785. if spGetUnitAllyTeam(unitID) == myAlly then
  8786. rings.hitAt[unitID] = gt
  8787. elseif attackerID and spGetUnitAllyTeam(attackerID) == myAlly then
  8788. rings.hitAt[attackerID] = gt
  8789. end
  8790. end
  8791.  
  8792. function M:DrawWorldPreUnit()
  8793. if Spring.IsGUIHidden() then
  8794. return
  8795. end
  8796. local sel, shown = rings.sel, rings.shown
  8797. if not sel and #shown == 0 then
  8798. return
  8799. end
  8800. -- No gl.Smoothing: this engine build does not have it, and calling it removed the widget.
  8801. DrawMarks() -- first, so the rings draw over the unit circles
  8802. if options.showFronts.value then
  8803. for i = 1, #shown do
  8804. DrawShown(shown[i], 2, true)
  8805. end
  8806. end
  8807. if sel then
  8808. DrawShown(sel, 3, false) -- last, so it sits on top of any front it overlaps
  8809. end
  8810. gl.LineWidth(1)
  8811. gl.Color(1, 1, 1, 1)
  8812. end
  8813.  
  8814. function M:DrawScreen()
  8815. if Spring.IsGUIHidden() then
  8816. return
  8817. end
  8818. local sel, shown = rings.sel, rings.shown
  8819. if not sel and #shown == 0 then
  8820. return
  8821. end
  8822. if options.showFronts.value then
  8823. for i = 1, #shown do
  8824. Label(shown[i], false)
  8825. end
  8826. end
  8827. if sel then
  8828. Label(sel, true)
  8829. end
  8830. gl.Color(1, 1, 1, 1)
  8831. end
  8832.  
  8833. function M:Initialize()
  8834. Chili = WG.Chili
  8835. if not Chili then
  8836. spEcho("Engagement Predictor: WG.Chili unavailable, removing widget.")
  8837. widgetHandler:RemoveWidget(widget)
  8838. return
  8839. end
  8840. myTeamID = spGetMyTeamID()
  8841. gaiaTeamID = spGetGaiaTeamID()
  8842. ApplyPalette(options.cbPalette.value) -- the saved choice is loaded before this runs
  8843. CreateWindow()
  8844. SetPanelVisible(options.showPanel.value)
  8845. dirty = true
  8846. end
  8847.  
  8848. function M:Shutdown()
  8849. if window_main then
  8850. window_main:Dispose()
  8851. window_main = nil
  8852. end
  8853. rowPool = {}
  8854. end
  8855.  
  8856. function M:SelectionChanged()
  8857. dirty = true
  8858. end
  8859.  
  8860. function M:PlayerChanged()
  8861. myTeamID = spGetMyTeamID()
  8862. dirty = true
  8863. end
  8864.  
  8865. function M:Update()
  8866. local panel = options.showPanel.value
  8867. local selRing = options.selRing.value
  8868. local now = os.clock()
  8869.  
  8870. -- Every frame, whatever else happens below: the rings ease and fade on wall time.
  8871. Animate()
  8872. if now >= rings.nextMark then
  8873. rings.nextMark = now + rings.MARK_EVERY
  8874. CollectMarks()
  8875. end
  8876.  
  8877. -- Fronts on their own clock: they depend on who is being hit, not on the selection.
  8878. -- Clustering is once a second; the scoring it queues is one front a frame.
  8879. if options.showFronts.value then
  8880. if now >= rings.next then
  8881. rings.next = now + 1
  8882. FindFronts(Spring.GetGameSeconds())
  8883. end
  8884. ScoreNextFront()
  8885. elseif selRing and now >= rings.next then
  8886. -- No fronts to age the hits out, but the selection ring still records them.
  8887. rings.next = now + 1
  8888. local gt = Spring.GetGameSeconds()
  8889. for uid, t in pairs(rings.hitAt) do
  8890. if gt - t > rings.MEMORY then
  8891. rings.hitAt[uid] = nil
  8892. end
  8893. end
  8894. end
  8895.  
  8896. if not panel and not selRing then
  8897. -- Gathering and scoring the selection exist only for the panel and the ring, so with
  8898. -- both off there is nothing worth computing. `dirty` is left standing so the fight on
  8899. -- screen when one comes back is scored fresh rather than shown as it was.
  8900. result = nil
  8901. return
  8902. end
  8903.  
  8904. if not dirty and now < nextRefresh then
  8905. return
  8906. end
  8907. dirty = false
  8908. nextRefresh = now + options.updateRate.value
  8909.  
  8910. -- WG.CounterMatrix is read inside Recompute rather than cached at Initialize: widget
  8911. -- load order is not guaranteed, and Build Proposer can be toggled mid-game.
  8912. Recompute()
  8913. if panel then
  8914. Rebuild()
  8915. end
  8916. end
  8917.  
  8918. RegisterModule{
  8919. callins = M,
  8920. prefix = 'ep_',
  8921. optionsPath = options_path,
  8922. options = options,
  8923. optionsOrder = options_order,
  8924. }
  8925. end
  8926.  
  8927. --------------------------------------------------------------------------------
  8928. -- Scout Cam
  8929. --
  8930. -- The other four panels answer questions about the enemy army as a tally. This one
  8931. -- answers the question a tally cannot: *what did my scout just find*. The moment a
  8932. -- scout lays eyes on an enemy factory, a fusion, a nuke silo -- anything expensive
  8933. -- enough to be a decision rather than a building -- a small viewer opens down the left
  8934. -- edge showing that structure's model, named, for a few seconds. Clicking it takes the
  8935. -- camera there.
  8936. --
  8937. -- It fires once per site and never again. A scout that circles the same base, a second
  8938. -- scout following the first, a nanoframe re-acquired under a new unitID after the ghost
  8939. -- was dropped: all of it is the same find, and a find you have already been shown is not
  8940. -- news. `sighted` keys the unit and `sightedSite` keys the ground it stands on, so both
  8941. -- the unit-level and the rebuilt-in-place cases are covered.
  8942. --
  8943. -- It is drawn in DrawScreen rather than built out of Chili, unlike every other panel
  8944. -- here. Two reasons. The content is a unit model, and gl.UnitShape wants a scissor box in
  8945. -- absolute screen coordinates and a depth buffer of its own -- both awkward to reach from
  8946. -- inside a Chili control's transform. And these are transient popups pinned to the screen
  8947. -- edge, so there is no docked position to preserve and nothing for tweak mode to move:
  8948. -- the whole of what Chili would be buying us here is layout we are not using.
  8949. --
  8950. -- The model drawing is the recipe from Zero-K's own gui_unitgroups.lua -- material setup,
  8951. -- front-face culling because the screen projection flips handedness, and a fit computed
  8952. -- from Spring.GetUnitDefDimensions. It reads no widget state and publishes none, so it
  8953. -- registers last and dies first.
  8954. --------------------------------------------------------------------------------
  8955.  
  8956. do
  8957. local M = {} -- this module's callins, collected by RegisterModule below
  8958. local options_path, options_order, options -- were file-scope globals
  8959.  
  8960. --------------------------------------------------------------------------------
  8961. -- Includes / static per-unitDef data
  8962. --------------------------------------------------------------------------------
  8963.  
  8964. local spGetUnitDefID = Spring.GetUnitDefID
  8965. local spGetUnitTeam = Spring.GetUnitTeam
  8966. local spGetUnitPosition = Spring.GetUnitPosition
  8967. local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt
  8968. local spGetAllUnits = Spring.GetAllUnits
  8969. local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
  8970. local spGetTeamList = Spring.GetTeamList
  8971. local spGetTeamInfo = Spring.GetTeamInfo
  8972. local spSetCameraTarget = Spring.SetCameraTarget
  8973. local spGetUnitDefDimensions = Spring.GetUnitDefDimensions
  8974. local spIsGUIHidden = Spring.IsGUIHidden
  8975. local spGetUnitsInCylinder = Spring.GetUnitsInCylinder
  8976. local spGetFeaturesInCylinder = Spring.GetFeaturesInCylinder
  8977. local spGetFeatureDefID = Spring.GetFeatureDefID
  8978. local spEcho = Spring.Echo
  8979. local spMarkerAddPoint = Spring.MarkerAddPoint
  8980. local spMarkerErasePosition = Spring.MarkerErasePosition
  8981.  
  8982. local glColor = gl.Color
  8983. local glRect = gl.Rect
  8984. local glText = gl.Text
  8985. local glScissor = gl.Scissor
  8986. local glPushMatrix = gl.PushMatrix
  8987. local glPopMatrix = gl.PopMatrix
  8988. local glTranslate = gl.Translate
  8989. local glRotate = gl.Rotate
  8990. local glScale = gl.Scale
  8991. local glUnitShape = gl.UnitShape
  8992. local glUnit = gl.Unit
  8993. local glFeature = gl.Feature
  8994. -- (defID, push), not (bool): these bind the textures belonging to one model, and the
  8995. -- first live build crashed on being handed a boolean. The call is copied from Zero-K's own
  8996. -- unit_ghostsite.lua, which is the only shipped user of it.
  8997. local glUnitShapeTextures = gl.UnitShapeTextures
  8998. local glFeatureShapeTextures = gl.FeatureShapeTextures
  8999. local glMaterial = gl.Material
  9000. local glTexture = gl.Texture
  9001. local glBeginEnd = gl.BeginEnd
  9002. local glVertex = gl.Vertex
  9003. local glViewport = gl.Viewport
  9004. local glMatrixMode = gl.MatrixMode
  9005. local glLoadIdentity = gl.LoadIdentity
  9006.  
  9007. local floor = math.floor
  9008. local max = math.max
  9009. local min = math.min
  9010. local tan = math.tan
  9011. local rad = math.rad
  9012.  
  9013. local unitName = {}
  9014. local cost = {}
  9015. local isStatic = {}
  9016.  
  9017. -- What the module means by "a factory": a building that builds, whose roster contains
  9018. -- something mobile. The engine's isFactory is also true of a Caretaker and of the Missile
  9019. -- Silo, and a factory plate is its parent's second pad rather than a find of its own --
  9020. -- the same gate Fac Switcher applies one block up, and for the same reason.
  9021. local isRealFactory = {}
  9022.  
  9023. -- The heaviest thing each plant can put on the field: Cyclops out of the tank factory,
  9024. -- Grizzly out of amph, Jugglenaut out of jump, Crab, Krow, Likho -- and Detriment out of
  9025. -- the Strider Hub, which is the same idea one tier up. Zero-K has no flag for this class,
  9026. -- and a bare cost floor dates badly, so it is derived rather than listed: the most
  9027. -- expensive mobile in a factory's roster is that factory's flagship by definition, and
  9028. -- stays the right answer when the balance numbers move under it.
  9029. local isFlagship = {}
  9030.  
  9031. -- Pseudo-units: things the engine models as units that are not buildings anyone built.
  9032. -- Zero-K flags them `dontcount`, and the whole set is a trap for a cost-based filter
  9033. -- because several are priced absurdly to keep the engine away from them. Terraform is the
  9034. -- one that actually bites: every patch of ground being levelled is a static, permanently
  9035. -- unfinished 100000-metal "unit" called Terraform, so an unfiltered cost floor turns a
  9036. -- single enemy ramp into a column of identical cams. The others -- the LOS fakeunit,
  9037. -- Planet Wars props, the Starlight satellite, temporary shields, teleport beacons, laid
  9038. -- mines -- are the same kind of mistake waiting to happen.
  9039. --
  9040. -- This is the gate Engagement Predictor already applies two blocks up, for the same
  9041. -- reason and with the same helper: customparams arrive stringified, so a flag that is
  9042. -- present but off is the string "false", which is perfectly truthy in Lua.
  9043. local skipDef = {}
  9044.  
  9045. -- Anti-nukes, for the map marker. Derived from the weapons rather than listed: a def is an
  9046. -- anti-nuke if any of its weapons intercepts. In Zero-K today that is the Antinuke alone.
  9047. local isAntinuke = {}
  9048.  
  9049. local function Flag(cp, key)
  9050. local v = cp and cp[key]
  9051. return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
  9052. end
  9053.  
  9054. -- Superweapons and nukes, in one table because this block is short of locals.
  9055. -- isSuper the five game-enders whose cams count down to completion while they go up
  9056. -- isNuke Zero-K's own is_nuke flag, the silos the coverage check plans against
  9057. -- cover an anti-nuke's real reach, customParams.nuke_coverage: its weapon claims a
  9058. -- coverage of 100000, and Zero-K's gadgets use this figure instead
  9059. -- enemy unitID -> {x, z} of every enemy silo found, kept while out of sight
  9060. -- exposed our key buildings a known silo could hit unintercepted, as {x, y, z, r}
  9061. local nukes = {
  9062. isSuper = {}, isNuke = {}, cover = {}, enemy = {}, exposed = {}, next = 0,
  9063. SUPER_NAMES = {staticnuke = true, staticheavyarty = true, zenith = true, mahlazer = true,
  9064. raveparty = true},
  9065. }
  9066. -- Zero-K's own intercept test, which accounts for the missile's path and not only where it
  9067. -- lands. A plain distance check stands in if the include is not there.
  9068. do
  9069. local ok, fn = pcall(function()
  9070. return VFS.Include("LuaRules/Gadgets/Include/GetNukeIntercepted.lua", nil, VFS.GAME)
  9071. end)
  9072. nukes.Intercepted = (ok and type(fn) == "function") and fn
  9073. or function(ux, uz, px, pz, tx, tz, radiusSq)
  9074. return (tx - ux) ^ 2 + (tz - uz) ^ 2 < radiusSq
  9075. end
  9076. end
  9077.  
  9078. for defID = 1, #UnitDefs do
  9079. local ud = UnitDefs[defID]
  9080. unitName[defID] = ud.humanName or ud.name or "?"
  9081. cost[defID] = ud.metalCost or 0
  9082. isStatic[defID] = (ud.speed or 0) == 0
  9083.  
  9084. local cp = ud.customParams
  9085. skipDef[defID] = Flag(cp, "dontcount") or Flag(cp, "is_drone")
  9086. nukes.isSuper[defID] = nukes.SUPER_NAMES[ud.name] or nil
  9087. nukes.isNuke[defID] = Flag(cp, "is_nuke") or nil
  9088. nukes.cover[defID] = cp and tonumber(cp.nuke_coverage) or nil
  9089.  
  9090. local weapons = ud.weapons
  9091. for i = 1, (weapons and #weapons or 0) do
  9092. local wd = WeaponDefs[weapons[i].weaponDef]
  9093. if wd and (wd.interceptor or 0) ~= 0 then
  9094. isAntinuke[defID] = true
  9095. end
  9096. end
  9097.  
  9098. local isPlate = (cp and cp.child_of_factory) and true or false
  9099. local opts = ud.isFactory and not isPlate and ud.buildOptions
  9100. if opts then
  9101. local best, bestCost
  9102. for i = 1, #opts do
  9103. local o = opts[i]
  9104. local od = UnitDefs[o]
  9105. if (od.speed or 0) > 0 then
  9106. isRealFactory[defID] = true
  9107. -- od.metalCost, not cost[o]: this loop fills `cost` as it goes, so a
  9108. -- roster entry with a higher defID than the factory has not been reached.
  9109. local c = od.metalCost or 0
  9110. if not bestCost or c > bestCost then
  9111. best, bestCost = o, c
  9112. end
  9113. end
  9114. end
  9115. if best then
  9116. isFlagship[best] = true
  9117. end
  9118. end
  9119. end
  9120.  
  9121. -- Populated on first use: GetUnitDefDimensions builds the model's bounding box, which
  9122. -- means loading the model. Doing all 249 at load would stall the game for a table most
  9123. -- of whose rows will never be looked at.
  9124. local dimsOf = {}
  9125.  
  9126. local function Dims(defID)
  9127. local d = dimsOf[defID]
  9128. if d == nil then
  9129. d = spGetUnitDefDimensions(defID) or false
  9130. dimsOf[defID] = d
  9131. end
  9132. return d or nil
  9133. end
  9134.  
  9135. --------------------------------------------------------------------------------
  9136. -- State (declared before `options` so the option closures can capture them)
  9137. --------------------------------------------------------------------------------
  9138.  
  9139. local cams = {} -- newest last; index is also the slot down the left edge
  9140.  
  9141. local sighted = {} -- unitID -> true, sites this unit has already been shown for
  9142. local sightedSite = {} -- "defID:gx:gz" -> true, the same by ground, for rebuilds and ghost churn
  9143.  
  9144. -- Wide enough that a structure re-acquired after its ghost was dropped lands in the same
  9145. -- cell, tight enough that two Stingers side by side do not collapse into one find.
  9146. local SITE_GRID = 64
  9147.  
  9148. local GAP = 6 -- between cams, across and down
  9149. local CAPTION_H = 18 -- the name strip, overlaid on the bottom of the image
  9150. local PROGRESS_H = 3 -- the build bar, sitting directly on top of that strip
  9151. local FADE = 0.8 -- seconds of fade-out once the hold time is up
  9152. local ASPECT = 1 -- height over width: square, so a spinning cam reads the same from every side
  9153.  
  9154. local myAllyTeamID = spGetMyAllyTeamID()
  9155. local teamAlly = {}
  9156.  
  9157. local vsx, vsy = widgetHandler:GetViewSizes()
  9158.  
  9159. -- The live view's own clipping planes. Near is generous because nothing is ever closer
  9160. -- than the orbit distance; far has to clear the map's diagonal so a cam aimed across a
  9161. -- valley does not slice the far side off.
  9162. local NEAR_PLANE = 16
  9163. local FAR_PLANE = 20000
  9164.  
  9165. local COL_GROUND = {0.42, 0.44, 0.40} -- the terrain quad, when it arrives untextured
  9166.  
  9167. local MAP_X = Game.mapSizeX
  9168. local MAP_Z = Game.mapSizeZ
  9169.  
  9170. -- Built once. Every colour in the live view is carried by the material, because
  9171. -- GL_COLOR_MATERIAL is off in this pass and gl.Color goes nowhere.
  9172. local MAT_LIT = {
  9173. ambient = {0.55, 0.55, 0.55, 1.0},
  9174. diffuse = {1.0, 1.0, 1.0, 1.0},
  9175. emission = {0.0, 0.0, 0.0, 1.0},
  9176. specular = {0.2, 0.2, 0.2, 1.0},
  9177. shininess = 16.0,
  9178. }
  9179. local MAT_GROUND = {
  9180. ambient = {COL_GROUND[1] * 0.6, COL_GROUND[2] * 0.6, COL_GROUND[3] * 0.6, 1.0},
  9181. diffuse = {COL_GROUND[1], COL_GROUND[2], COL_GROUND[3], 1.0},
  9182. emission = {0.0, 0.0, 0.0, 1.0},
  9183. specular = {0.0, 0.0, 0.0, 1.0},
  9184. shininess = 0.0,
  9185. }
  9186.  
  9187. local COL_FRAME = {0.62, 0.66, 0.74}
  9188. local COL_FRAME_F = {0.95, 0.80, 0.35} -- a factory is the find worth a brighter border
  9189. local COL_BG = {0.04, 0.05, 0.07}
  9190. local COL_FLOOR = {0.10, 0.12, 0.15}
  9191. local COL_CAPTION = {0, 0, 0}
  9192. local COL_BUILD = {1.00, 0.72, 0.20} -- nanoframe amber, the same signal the game uses
  9193. local COL_DONE = {0.45, 0.85, 0.45}
  9194.  
  9195. -- Enemy anti-nukes carrying one of our map markers, by unitID: where the marker is, the
  9196. -- percentage it states (100 once finished), and what was last seen while in sight. See
  9197. -- MarkAntinuke below.
  9198. local antiMarks = {}
  9199.  
  9200. local DismissAll, TrimStack, EraseAntinukeMarks -- forward declarations, defined below
  9201.  
  9202. -- Declared up here, not beside the draw that sets it, because the viewMode option's
  9203. -- OnChange closes over it and the options table is built further up this block. A `local`
  9204. -- written later would leave that closure assigning a global instead -- silently, which is
  9205. -- the whole reason this file forward-declares rather than trusting the order it reads in.
  9206. local liveFailed = false
  9207.  
  9208. --------------------------------------------------------------------------------
  9209. -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
  9210. --------------------------------------------------------------------------------
  9211.  
  9212. options_path = 'Settings/HUD Panels/Scout Cam'
  9213. options_order = {'showPanel', 'viewMode', 'groundStyle', 'camZoom', 'camPitch', 'spin',
  9214. 'showWrecks',
  9215. 'minCost', 'mobileCost', 'flagshipCost', 'includeFactories',
  9216. 'includeNanoframes', 'predictProgress', 'antinukeMarker',
  9217. 'superEta', 'nukeCoverage', 'keyCost',
  9218. 'holdTime', 'maxCams', 'camWidth', 'camColumns', 'topOffset', 'leftOffset',
  9219. 'dismissAll'}
  9220. options = {
  9221. showPanel = {
  9222. name = 'Show Scout Cam popups',
  9223. desc = 'Open a viewer down the left edge the first time a scout finds a major enemy '
  9224. .. 'structure. Turning this off closes any that are open and stops new ones; the '
  9225. .. 'sites already found stay found, so turning it back on will not replay them.',
  9226. type = 'bool',
  9227. value = true,
  9228. OnChange = function(self)
  9229. if not self.value and DismissAll then
  9230. DismissAll()
  9231. end
  9232. end,
  9233. },
  9234. viewMode = {
  9235. name = 'Cam shows',
  9236. desc = 'Live view renders the site itself: the real terrain, and whatever is standing '
  9237. .. 'there right now, moving as it moves. It is a second render of the map rather '
  9238. .. 'than a picture of one, so the ground arrives as shape without the map '
  9239. .. 'texture on it, and only what is in line of sight can be drawn -- a cam whose '
  9240. .. 'scout has moved on shows empty ground. Unit model falls back to turning the model '
  9241. .. 'of the structure in front of a flat backdrop, which asks nothing of the '
  9242. .. 'driver and always draws something. Name only drops the picture altogether: '
  9243. .. 'each cam shrinks to a strip with the site name and, for a construction '
  9244. .. 'site, its build progress.',
  9245. type = 'radioButton',
  9246. value = 'text',
  9247. items = {
  9248. {key = 'live', name = 'Live view', desc = 'The site as it is now.'},
  9249. {key = 'model', name = 'Unit model', desc = 'The model of the structure, turning.'},
  9250. {key = 'text', name = 'Name only', desc = 'The name and build progress, no view.'},
  9251. },
  9252. OnChange = function(self)
  9253. -- Choosing Live again is the way to ask for another go after it has stood itself
  9254. -- down, so a driver problem that turns out to be transient does not need a
  9255. -- reload to clear.
  9256. if self.value == 'live' then
  9257. liveFailed = false
  9258. end
  9259. end,
  9260. },
  9261. groundStyle = {
  9262. name = 'Cam ground',
  9263. desc = 'The live view draws the terrain itself, but the map texture belongs to the '
  9264. .. 'engine ground pass, which has finished by then -- so the paintwork has to be '
  9265. .. 'put back by hand, from the map overview image. Which way up that image sits '
  9266. .. 'is the one thing that cannot be worked out from here, so if the ground comes '
  9267. .. 'out mirrored, take the other one. Flat shading skips it and gives plain lit '
  9268. .. 'relief.',
  9269. type = 'radioButton',
  9270. value = 'flat',
  9271. items = {
  9272. {key = 'map', name = 'Map texture', desc = 'The map overview image.'},
  9273. {key = 'map_unflip', name = 'Map texture, flipped', desc = 'The same, the other way up.'},
  9274. {key = 'flat', name = 'Flat shading', desc = 'Lit relief, no texture.'},
  9275. },
  9276. },
  9277. camZoom = {
  9278. name = 'Cam zoom out',
  9279. desc = 'How much ground around the site the live view takes in, as a multiple of the '
  9280. .. 'footprint of the structure itself. 1 is tight on the building; raise it to see what '
  9281. .. 'is parked around it.',
  9282. type = 'number',
  9283. min = 0.6, max = 8, step = 0.1, value = 3.5,
  9284. },
  9285. camPitch = {
  9286. name = 'Cam angle above ground',
  9287. desc = 'Degrees. Low is a dramatic ground-level shot and hides what is behind things; '
  9288. .. 'high looks down on the site and shows the layout.',
  9289. type = 'number',
  9290. min = 5, max = 85, step = 1, value = 56,
  9291. },
  9292. showWrecks = {
  9293. name = 'Draw wrecks in the live view',
  9294. desc = 'Include features -- wreckage, rocks, trees -- not just units. Wreckage around '
  9295. .. 'a structure is half the story of what happened there.',
  9296. type = 'bool',
  9297. value = true,
  9298. },
  9299. minCost = {
  9300. name = 'Cam sites costing at least (metal)',
  9301. desc = 'How expensive an enemy building has to be before finding it is worth a cam. '
  9302. .. 'Low values will pop for every solar and turret on the map.',
  9303. type = 'number',
  9304. min = 50, max = 3000, step = 50, value = 1000,
  9305. },
  9306. mobileCost = {
  9307. name = 'Cam units being built costing at least (metal)',
  9308. desc = 'Heavy units, wherever they are made: a strider on a Strider Hub pad, a '
  9309. .. 'Cyclops or a Grizzly in a factory. One going up is a site like any other -- '
  9310. .. 'it is not going anywhere, and how far along it is decides the next ten '
  9311. .. 'minutes -- so it gets a cam, with the same build bar as a building. Only '
  9312. .. 'while it is being built: once it walks it is an army, and the other panels '
  9313. .. 'answer armies. Set to 0 to leave this rule out; the one below still applies.',
  9314. type = 'number',
  9315. min = 0, max = 20000, step = 100, value = 1500,
  9316. },
  9317. flagshipCost = {
  9318. name = 'Cam a factory heaviest unit if it costs at least (metal)',
  9319. desc = 'The most expensive thing each plant can build -- Cyclops, Grizzly, '
  9320. .. 'Jugglenaut, Crab, Krow, and Detriment out of the Strider Hub. Worked out '
  9321. .. 'from the rosters rather than listed, so it stays right when the costs move. '
  9322. .. 'It catches them under the floor above, which is the point, but not every '
  9323. .. 'plant has a heavy unit to be proud of: at the default this leaves out the '
  9324. .. 'cheap flagships -- Aspis, Impaler, Phantom -- and keeps the ones that are '
  9325. .. 'really a decision. Set to 0 to leave this rule out.',
  9326. type = 'number',
  9327. min = 0, max = 5000, step = 50, value = 1000,
  9328. },
  9329. includeFactories = {
  9330. name = 'Always cam factories',
  9331. desc = 'Show a cam for any enemy factory, whatever it cost. Which factory they are '
  9332. .. 'running is the single most useful thing a scout brings back, and the cheap '
  9333. .. 'plants would otherwise fall under the cost floor above.',
  9334. type = 'bool',
  9335. value = true,
  9336. },
  9337. includeNanoframes = {
  9338. name = 'Cam construction sites too',
  9339. desc = 'Pop for a site that is still being built, not just a finished building. '
  9340. .. 'A nanoframe is the earlier warning of the two, and it is the only one you '
  9341. .. 'get for something that finishes out of sight. These cams carry a build bar '
  9342. .. 'and a percentage, which keep running while you can see the site and dim to '
  9343. .. 'the last known figure when you cannot.',
  9344. type = 'bool',
  9345. value = true,
  9346. },
  9347. predictProgress = {
  9348. name = 'Predict progress out of sight',
  9349. desc = 'Once a construction site leaves sight, keep its percentage moving at the '
  9350. .. 'build rate measured while it was visible, marked with a ~. The bar shows the '
  9351. .. 'last known figure solid and the predicted part faint. It is a guess: it cannot '
  9352. .. 'know about builders arriving, leaving or running out of metal. Needs about a '
  9353. .. 'second of watching to measure a rate; without one it holds the last figure.',
  9354. type = 'bool',
  9355. value = true,
  9356. },
  9357. antinukeMarker = {
  9358. name = 'Mark enemy anti-nukes on the map',
  9359. desc = 'Drop a map marker on every enemy anti-nuke a scout finds, labelled with how '
  9360. .. 'far along it was and the game time it was seen at: "Enemy Antinuke 43% (12:34)", '
  9361. .. 'or "complete". Once the scout loses sight of it, the marker is replaced with the last '
  9362. .. 'figure seen, and it is replaced again the moment the anti-nuke is seen to '
  9363. .. 'finish. This works whether or not the cams are on, and whatever the cost floors '
  9364. .. 'say. "For me only" markers are private. "For my team" markers are placed like '
  9365. .. 'a ping of your own, so your allies see them and hear them arrive. Zero-K\'s own '
  9366. .. 'Unit Marker (Settings/Interface/Unit Marker) can also mark the Antinuke, so '
  9367. .. 'leave that one off or you will get two markers.',
  9368. type = 'radioButton',
  9369. value = 'local',
  9370. items = {
  9371. {key = 'off', name = 'Off', desc = 'No markers.'},
  9372. {key = 'local', name = 'For me only', desc = 'Only you see the markers.'},
  9373. {key = 'allies', name = 'For my team', desc = 'Your allies see them too.'},
  9374. },
  9375. OnChange = function(self)
  9376. if self.value == 'off' and EraseAntinukeMarks then
  9377. EraseAntinukeMarks()
  9378. end
  9379. end,
  9380. },
  9381. superEta = {
  9382. name = 'Show superweapon countdowns',
  9383. desc = 'A cam for an enemy Trinity, Big Bertha, Zenith, Starlight or Disco Rave Party '
  9384. .. 'still under construction shows how long it has left at the build rate seen, '
  9385. .. 'as "~2:10", and stays up until it is due rather than fading with the others. '
  9386. .. 'Out of sight the countdown runs on the predicted progress. Needs a second of '
  9387. .. 'watching to measure a rate.',
  9388. type = 'bool',
  9389. value = true,
  9390. },
  9391. nukeCoverage = {
  9392. name = 'Check anti-nuke coverage of key buildings',
  9393. desc = 'Once a scout has found an enemy nuke silo, ring every key building of your '
  9394. .. 'team that a missile from that silo could reach without passing through a '
  9395. .. 'finished allied anti-nuke, and say how many there are above the cam stack. '
  9396. .. 'Uses Zero-K\'s own intercept test, which follows the missile\'s path. '
  9397. .. 'Checked every 2 seconds.',
  9398. type = 'bool',
  9399. value = true,
  9400. OnChange = function(self)
  9401. if not self.value then
  9402. nukes.exposed = {}
  9403. end
  9404. end,
  9405. },
  9406. keyCost = {
  9407. name = 'Key buildings cost at least (metal)',
  9408. desc = 'Which of your buildings the anti-nuke check cares about. Factories always count.',
  9409. type = 'number',
  9410. min = 100, max = 10000, step = 100, value = 1000,
  9411. },
  9412. holdTime = {
  9413. name = 'Keep each cam for (s)',
  9414. desc = 'How long a cam stays up before fading out. Set to 0 to keep every cam until '
  9415. .. 'you dismiss it with a right click.',
  9416. type = 'number',
  9417. min = 0, max = 120, step = 1, value = 10,
  9418. },
  9419. maxCams = {
  9420. name = 'Most cams at once',
  9421. desc = 'When a find arrives and the stack is full, the least important cam it outranks '
  9422. .. 'makes room for it -- by metal cost, with a bonus for factories. The new find is '
  9423. .. 'always shown. Name only strips stack a lot more of these into a screen than the '
  9424. .. 'square views do.',
  9425. type = 'number',
  9426. min = 1, max = 24, step = 1, value = 8,
  9427. OnChange = function()
  9428. if TrimStack then TrimStack() end
  9429. end,
  9430. },
  9431. camWidth = {
  9432. name = 'Cam size (px)',
  9433. desc = 'Each cam is square, so this is both its width and its height.',
  9434. type = 'number',
  9435. min = 60, max = 400, step = 10, value = 150,
  9436. },
  9437. camColumns = {
  9438. name = 'Cam columns',
  9439. desc = 'How many cams sit side by side before the stack starts a new row. The most '
  9440. .. 'important find is top left, the next one to its right.',
  9441. type = 'number',
  9442. min = 1, max = 4, step = 1, value = 2,
  9443. },
  9444. topOffset = {
  9445. name = 'Cam stack top offset (px)',
  9446. desc = 'How far down the left edge the first cam starts, so the stack clears whatever '
  9447. .. 'you keep in the top-left corner.',
  9448. type = 'number',
  9449. min = 0, max = 800, step = 5, value = 105,
  9450. },
  9451. leftOffset = {
  9452. name = 'Cam stack left offset (px)',
  9453. type = 'number',
  9454. min = 0, max = 400, step = 5, value = 165,
  9455. },
  9456. spin = {
  9457. name = 'Orbit the cam',
  9458. desc = 'Turn each cam slowly around its site, so it is read from more than one side. '
  9459. .. 'Off holds it at a fixed three-quarters angle.',
  9460. type = 'bool',
  9461. value = false,
  9462. },
  9463. dismissAll = {
  9464. name = 'Dismiss all cams',
  9465. desc = 'Close everything currently on screen. The sites stay found.',
  9466. type = 'button',
  9467. OnChange = function()
  9468. if DismissAll then DismissAll() end
  9469. end,
  9470. },
  9471. }
  9472.  
  9473. --------------------------------------------------------------------------------
  9474. -- Tracking
  9475. --------------------------------------------------------------------------------
  9476.  
  9477. local function RebuildTeamMap()
  9478. teamAlly = {}
  9479. local teams = spGetTeamList()
  9480. if teams then
  9481. for i = 1, #teams do
  9482. local t = teams[i]
  9483. teamAlly[t] = select(6, spGetTeamInfo(t, false))
  9484. end
  9485. end
  9486. myAllyTeamID = spGetMyAllyTeamID()
  9487. end
  9488.  
  9489. local function IsEnemyTeam(unitTeam)
  9490. if not unitTeam then
  9491. return false
  9492. end
  9493. -- Gaia owns the critters and the map's own units; a cam for one of those would be
  9494. -- a find about nobody. Asked of the engine rather than cached, because this
  9495. -- module keeps no team state of its own beyond the ally map.
  9496. if unitTeam == Spring.GetGaiaTeamID() then
  9497. return false
  9498. end
  9499. local at = teamAlly[unitTeam]
  9500. if at == nil then
  9501. at = select(6, spGetTeamInfo(unitTeam, false))
  9502. teamAlly[unitTeam] = at
  9503. end
  9504. return at ~= myAllyTeamID
  9505. end
  9506.  
  9507. -- Enemy, and expensive enough to be a decision rather than a building. Asked fresh each
  9508. -- time rather than baked into a table at load, because the cost floors are settings and a
  9509. -- player who drops one mid-game means it for the next find, not for the next reload.
  9510. --
  9511. -- `building` is what lets a mobile unit through. A Detriment going up on a Strider Hub pad
  9512. -- is a site in every sense that matters here -- it is not going anywhere, it is the most
  9513. -- expensive thing on the map, and how far along it is decides the next ten minutes. Once
  9514. -- it walks off the pad it stops being a site and becomes an army, which is what the other
  9515. -- four panels are for; so a strider already on its feet does not open a cam.
  9516. -- The flagship rule, as its own question because the cheap pre-filter in Consider asks it
  9517. -- too and the two must never disagree about what qualifies.
  9518. local function IsFlagshipFind(defID)
  9519. if not isFlagship[defID] then
  9520. return false
  9521. end
  9522. local floor = options.flagshipCost.value
  9523. return floor > 0 and cost[defID] >= floor
  9524. end
  9525.  
  9526. local function IsMajorDef(defID, building)
  9527. if not defID or skipDef[defID] then
  9528. return false
  9529. end
  9530. if not isStatic[defID] then
  9531. if not building then
  9532. return false
  9533. end
  9534. if IsFlagshipFind(defID) then
  9535. return true
  9536. end
  9537. local floor = options.mobileCost.value
  9538. return floor > 0 and cost[defID] >= floor
  9539. end
  9540. if isRealFactory[defID] and options.includeFactories.value then
  9541. return true
  9542. end
  9543. return cost[defID] >= options.minCost.value
  9544. end
  9545.  
  9546. -- What the stack is ordered by, and what it drops first when it is full. Metal is most of
  9547. -- the answer -- it is what the enemy chose to spend, which is what a scout is really
  9548. -- reporting -- but a factory is worth more than its price tag says: which plant they are
  9549. -- running is the single most useful fact a scout brings home, and the cheapest plants cost
  9550. -- less than a heavy turret. The bonus is sized to put any factory above the ordinary run
  9551. -- of buildings while leaving the genuine game-enders -- nukes, striders -- above it.
  9552. local FACTORY_BONUS = 1500
  9553.  
  9554. local function Importance(defID)
  9555. local v = cost[defID] or 0
  9556. if isRealFactory[defID] then
  9557. v = v + FACTORY_BONUS
  9558. end
  9559. return v
  9560. end
  9561.  
  9562. local function SiteKey(defID, x, z)
  9563. return defID .. ":" .. floor(x / SITE_GRID) .. ":" .. floor(z / SITE_GRID)
  9564. end
  9565.  
  9566. DismissAll = function()
  9567. for i = #cams, 1, -1 do
  9568. cams[i] = nil
  9569. end
  9570. end
  9571.  
  9572. -- The stack reads top-down in order of importance, so the thing worth reacting to is the
  9573. -- one nearest the top of the screen however late it was found. Ties go to the newer find,
  9574. -- and unitID settles the rest: table.sort needs a strict order and will raise on a
  9575. -- comparator that ever says two entries are both smaller.
  9576. local function SortCams()
  9577. table.sort(cams, function(a, b)
  9578. if a.score ~= b.score then return a.score > b.score end
  9579. if a.born ~= b.born then return a.born > b.born end
  9580. return a.unitID > b.unitID
  9581. end)
  9582. end
  9583.  
  9584. -- Bring the stack back within the limit by dropping the least important of what is on it,
  9585. -- oldest first among equals. `keep` is exempt, which is how a new find is guaranteed a
  9586. -- slot: it can push something out but can never be the thing pushed out, so the cam that
  9587. -- just opened is always one you actually get to see.
  9588. TrimStack = function(keep)
  9589. local limit = options.maxCams.value
  9590. while #cams > limit do
  9591. local at
  9592. for i = 1, #cams do
  9593. local c = cams[i]
  9594. if c ~= keep and (at == nil or c.score < cams[at].score
  9595. or (c.score == cams[at].score and c.born < cams[at].born)) then
  9596. at = i
  9597. end
  9598. end
  9599. if not at then
  9600. break -- everything left is exempt
  9601. end
  9602. table.remove(cams, at)
  9603. end
  9604. end
  9605.  
  9606. -- Every path into the module funnels here, and this is the only place that decides a find
  9607. -- is new. `quiet` is the seeding pass: it marks what is already on screen as found without
  9608. -- opening anything, so enabling the widget in the eightieth minute does not dump the whole
  9609. -- map into the stack.
  9610. local function Consider(unitID, unitDefID, unitTeam, quiet)
  9611. if not unitID or sighted[unitID] then
  9612. return
  9613. end
  9614.  
  9615. unitDefID = unitDefID or spGetUnitDefID(unitID)
  9616. if not unitDefID or skipDef[unitDefID] then
  9617. return
  9618. end
  9619.  
  9620. -- The cheap half of the test, first: this runs for every unit that enters line of
  9621. -- sight, which in a real game is most of them. A mobile unit cannot qualify at all
  9622. -- unless it is dear enough to be a strider, and that is one table lookup.
  9623. local mobile = not isStatic[unitDefID]
  9624. if mobile and not IsFlagshipFind(unitDefID) then
  9625. local floor = options.mobileCost.value
  9626. if floor <= 0 or cost[unitDefID] < floor then
  9627. return
  9628. end
  9629. end
  9630.  
  9631. unitTeam = unitTeam or spGetUnitTeam(unitID)
  9632. if not IsEnemyTeam(unitTeam) then
  9633. return
  9634. end
  9635.  
  9636. local x, y, z = spGetUnitPosition(unitID)
  9637. if not x then
  9638. -- A radar blip has a unitID and no position we can point a camera at. It is also
  9639. -- not marked as found: the scout that actually gets eyes on it still deserves
  9640. -- its cam.
  9641. return
  9642. end
  9643.  
  9644. -- Nil when the unit is out of LOS rather than when it is finished, so a ghost reads as
  9645. -- complete. That is the right way round: a ghost is the memory of something we saw.
  9646. -- The second return is how far along it is, which is what the cam's bar tracks.
  9647. local building, progress = spGetUnitIsBeingBuilt(unitID)
  9648. building = building or false
  9649.  
  9650. if not IsMajorDef(unitDefID, building) then
  9651. return
  9652. end
  9653.  
  9654. -- `includeNanoframes` is about buildings: whether a site counts before it is finished.
  9655. -- A strider is only ever a site while it is being built, so it is governed by its own
  9656. -- cost floor instead and this gate does not apply to it.
  9657. if building and not mobile and not options.includeNanoframes.value and not quiet then
  9658. -- Skipped, and deliberately not filed. With construction sites turned off, the cam
  9659. -- this site is owed is the one for the finished building, and filing the nanoframe
  9660. -- here would swallow it -- leaving a site that was scouted early showing nothing at
  9661. -- all, ever. The seeding pass is the exception: what was on screen when the widget
  9662. -- loaded is old news whatever state it is in.
  9663. return
  9664. end
  9665.  
  9666. sighted[unitID] = true
  9667. -- Ground identifies a building and nothing else. A strider is a thing, not a place --
  9668. -- two of them built on the same pad are two finds -- so a mobile is deduplicated by
  9669. -- unitID alone.
  9670. if not mobile then
  9671. local key = SiteKey(unitDefID, x, z)
  9672. if sightedSite[key] then
  9673. return -- same ground, same building: this is the find we already showed
  9674. end
  9675. sightedSite[key] = true
  9676. end
  9677.  
  9678. if quiet or not options.showPanel.value then
  9679. return
  9680. end
  9681.  
  9682. local cam = {
  9683. unitID = unitID,
  9684. defID = unitDefID,
  9685. teamID = unitTeam,
  9686. x = x, y = y, z = z,
  9687. mobile = mobile,
  9688. score = Importance(unitDefID),
  9689. factory = isRealFactory[unitDefID] or false,
  9690. super = nukes.isSuper[unitDefID],
  9691. name = unitName[unitDefID],
  9692. -- nil for something found already finished, which is what keeps the bar off it.
  9693. progress = building and (progress or 0) or nil,
  9694. polling = building, -- keep asking, until it tops out or we lose sight of it
  9695. stale = false,
  9696. born = os.clock(),
  9697. yaw = 35, -- degrees; the spin starts from three-quarters view, not head-on
  9698. }
  9699.  
  9700. cams[#cams + 1] = cam
  9701. TrimStack(cam)
  9702. SortCams()
  9703. end
  9704.  
  9705. --------------------------------------------------------------------------------
  9706. -- Anti-nuke markers
  9707. --
  9708. -- These are separate from the cams. A cam holds your attention for a few seconds, while a
  9709. -- marker stays on the map for when a nuke is being planned. Two things are copied from
  9710. -- Zero-K's own unit_marker.lua. The engine cannot edit a marker, so a marker is replaced
  9711. -- rather than changed. And the replacement goes down half a second after the old one is
  9712. -- erased, because an erase and an add in the same frame leave the new marker gone within
  9713. -- a second.
  9714. --
  9715. -- When the marker is replaced: when the anti-nuke is seen to finish, which is the one
  9716. -- change worth saying at once, and when the scout loses sight of it, so the marker carries
  9717. -- the last figure seen rather than the first. Nothing is replaced while it stays in sight,
  9718. -- because the cam is already showing the live figure. A team marker pings every ally, and
  9719. -- a scout on the edge of sight must not ping them once a second.
  9720. --------------------------------------------------------------------------------
  9721.  
  9722. local MARK_DEFER = 0.5 -- s between erasing a marker and putting its replacement down
  9723. local MARK_SPACING = 10 -- s, the least time between two replacements for one anti-nuke
  9724. local nextMarkPoll = 0
  9725.  
  9726. -- What was seen, kept apart from what the marker says (`pct`, `text`) until it is replaced.
  9727. -- Short of 100 until the engine says it is finished: 99.6% rounds to 99, not to done.
  9728. local function ObserveAntinuke(m, building, progress, x, y, z)
  9729. m.seenPct = building and min(99, floor((progress or 0) * 100)) or 100
  9730. local s = floor(Spring.GetGameSeconds and Spring.GetGameSeconds() or 0)
  9731. m.seenAt = string.format("%d:%02d", floor(s / 60), s % 60)
  9732. m.seenX, m.seenY, m.seenZ = x, y, z
  9733. end
  9734.  
  9735. local function PlaceAntinukeMark(m, now, defer)
  9736. if m.text and not m.due then
  9737. spMarkerErasePosition(m.x, m.y, m.z, nil, m.localOnly)
  9738. end
  9739. m.x, m.y, m.z = m.seenX, m.seenY, m.seenZ
  9740. m.pct = m.seenPct
  9741. m.text = "Enemy " .. unitName[m.defID]
  9742. .. ((m.pct >= 100) and " complete" or (" " .. m.pct .. "%"))
  9743. .. " (" .. m.seenAt .. ")"
  9744. m.localOnly = options.antinukeMarker.value ~= 'allies'
  9745. m.markedAt = now
  9746. if defer then
  9747. m.due = now + MARK_DEFER
  9748. else
  9749. spMarkerAddPoint(m.x, m.y, m.z, m.text, m.localOnly)
  9750. m.due = nil
  9751. end
  9752. end
  9753.  
  9754. -- First sight. Every callin that can hand us a unit comes through here. After that, the
  9755. -- poll below owns the marker.
  9756. local function MarkAntinuke(unitID, unitDefID, unitTeam)
  9757. if options.antinukeMarker.value == 'off' or antiMarks[unitID] then
  9758. return
  9759. end
  9760. unitDefID = unitDefID or spGetUnitDefID(unitID)
  9761. if not (unitDefID and isAntinuke[unitDefID]) then
  9762. return
  9763. end
  9764. -- A spectator sees all of it anyway, and a team marker from one goes to the spectators.
  9765. if Spring.GetSpectatingState() or not IsEnemyTeam(unitTeam or spGetUnitTeam(unitID)) then
  9766. return
  9767. end
  9768. -- Nil for a radar blip. There is no figure to give yet, and the scout that gets eyes on
  9769. -- it will come back through here.
  9770. local building, progress = spGetUnitIsBeingBuilt(unitID)
  9771. local x, y, z = spGetUnitPosition(unitID)
  9772. if building == nil or not x then
  9773. return
  9774. end
  9775. local m = {defID = unitDefID, inSight = true}
  9776. ObserveAntinuke(m, building, progress, x, y, z)
  9777. antiMarks[unitID] = m
  9778. PlaceAntinukeMark(m, os.clock(), false)
  9779. end
  9780.  
  9781. local function PollAntinukeMarks(now)
  9782. if now < nextMarkPoll or not next(antiMarks) then
  9783. return
  9784. end
  9785. nextMarkPoll = now + 0.25
  9786. for unitID, m in pairs(antiMarks) do
  9787. if m.due and now >= m.due then
  9788. spMarkerAddPoint(m.x, m.y, m.z, m.text, m.localOnly)
  9789. m.due = nil
  9790. end
  9791. local building, progress = spGetUnitIsBeingBuilt(unitID)
  9792. local x, y, z = spGetUnitPosition(unitID)
  9793. if building ~= nil and x then
  9794. ObserveAntinuke(m, building, progress, x, y, z)
  9795. m.inSight = true
  9796. if not building and m.pct < 100 then
  9797. PlaceAntinukeMark(m, now, true)
  9798. end
  9799. else
  9800. -- Out of sight. `stale` stays set until the marker agrees with the last thing
  9801. -- seen, so a loss of sight inside MARK_SPACING is caught up afterwards rather than dropped.
  9802. if m.inSight then
  9803. m.stale = true
  9804. end
  9805. m.inSight = false
  9806. if m.stale and m.seenPct == m.pct then
  9807. m.stale = false
  9808. elseif m.stale and not m.due and now >= m.markedAt + MARK_SPACING then
  9809. PlaceAntinukeMark(m, now, true)
  9810. m.stale = false
  9811. end
  9812. end
  9813. end
  9814. end
  9815.  
  9816. EraseAntinukeMarks = function()
  9817. for _, m in pairs(antiMarks) do
  9818. if not m.due then
  9819. spMarkerErasePosition(m.x, m.y, m.z, nil, m.localOnly)
  9820. end
  9821. end
  9822. antiMarks = {}
  9823. end
  9824.  
  9825. --------------------------------------------------------------------------------
  9826. -- Superweapons
  9827. --
  9828. -- A cam for a superweapon under construction counts down to completion at the build rate
  9829. -- the predicted-progress code already measures, and is held on screen until it is due:
  9830. -- ten seconds of "Trinity 12%" is the least useful way to learn the enemy is building one.
  9831. -- Separately, once an enemy silo is known, every key building of ours a missile from it
  9832. -- could reach unintercepted is ringed on the map.
  9833. --------------------------------------------------------------------------------
  9834.  
  9835. -- Game seconds until the site is finished, or nil with nothing to go on: no rate measured
  9836. -- yet, a rate that is not climbing, or a site that is done.
  9837. local function SuperEta(cam)
  9838. local p = cam.progress
  9839. if not (p and p < 1 and cam.polling and cam.rate and cam.rate > 0) then
  9840. return nil
  9841. end
  9842. local cur = (cam.stale and options.predictProgress.value and cam.predicted) or p
  9843. if cur >= 1 then
  9844. return nil
  9845. end
  9846. return (1 - cur) / cam.rate
  9847. end
  9848.  
  9849. -- Held while still going up as far as we know. The prediction reaching 100% releases it
  9850. -- too, since a site that died out of sight would otherwise be held for the whole game.
  9851. local function Pinned(cam)
  9852. return cam.super and options.superEta.value and cam.polling
  9853. and not (cam.stale and cam.predicted and cam.predicted >= 1)
  9854. end
  9855.  
  9856. local function NoteNuke(unitID, unitDefID, unitTeam)
  9857. unitDefID = unitDefID or spGetUnitDefID(unitID)
  9858. if not (unitDefID and nukes.isNuke[unitDefID]) or nukes.enemy[unitID] then
  9859. return
  9860. end
  9861. if not IsEnemyTeam(unitTeam or spGetUnitTeam(unitID)) then
  9862. return
  9863. end
  9864. local x, _, z = spGetUnitPosition(unitID)
  9865. if x then
  9866. nukes.enemy[unitID] = {x = x, z = z}
  9867. end
  9868. end
  9869.  
  9870. local function CheckCoverage()
  9871. nukes.exposed = {}
  9872. if not options.nukeCoverage.value or not next(nukes.enemy) then
  9873. return
  9874. end
  9875. -- A silo that died out of sight leaves a record behind. Seeing the ground it stood on
  9876. -- with nothing there says so; ZK's Antinuke Coverage widget drops them the same way.
  9877. for uID, n in pairs(nukes.enemy) do
  9878. if not spGetUnitDefID(uID) and select(2, Spring.GetPositionLosState(n.x, 0, n.z)) then
  9879. nukes.enemy[uID] = nil
  9880. end
  9881. end
  9882.  
  9883. local antis, keys = {}, {}
  9884. local keyCost = options.keyCost.value
  9885. local units = spGetAllUnits()
  9886. for i = 1, (units and #units or 0) do
  9887. local uID = units[i]
  9888. if Spring.GetUnitAllyTeam(uID) == myAllyTeamID then
  9889. local defID = spGetUnitDefID(uID)
  9890. local finished = defID and not spGetUnitIsBeingBuilt(uID)
  9891. if finished and nukes.cover[defID] then
  9892. local x, _, z = spGetUnitPosition(uID)
  9893. antis[#antis + 1] = {x, z, nukes.cover[defID] ^ 2}
  9894. elseif finished and isStatic[defID] and not skipDef[defID]
  9895. and (cost[defID] >= keyCost or isRealFactory[defID]) then
  9896. keys[#keys + 1] = uID
  9897. end
  9898. end
  9899. end
  9900.  
  9901. for i = 1, #keys do
  9902. local uID = keys[i]
  9903. local x, y, z = spGetUnitPosition(uID)
  9904. local open = false
  9905. for _, n in pairs(nukes.enemy) do
  9906. local stopped = false
  9907. for j = 1, #antis do
  9908. local a = antis[j]
  9909. if nukes.Intercepted(a[1], a[2], n.x, n.z, x, z, a[3]) then
  9910. stopped = true
  9911. break
  9912. end
  9913. end
  9914. if not stopped then
  9915. open = true
  9916. break
  9917. end
  9918. end
  9919. if open then
  9920. local d = Dims(spGetUnitDefID(uID))
  9921. local r = d and max(d.maxx - d.minx, d.maxz - d.minz) * 0.6 or 60
  9922. nukes.exposed[#nukes.exposed + 1] = {x, y, z, max(40, r)}
  9923. end
  9924. end
  9925. end
  9926.  
  9927. --------------------------------------------------------------------------------
  9928. -- Drawing
  9929. --------------------------------------------------------------------------------
  9930.  
  9931. -- Screen rectangle of the i-th cam, in GL's own frame: origin bottom left, y upwards.
  9932. -- Both the draw and the click test read it from here, so a cam can never be drawn
  9933. -- somewhere other than where it can be clicked.
  9934. local function CamRect(i)
  9935. local w = options.camWidth.value
  9936. local col, row
  9937. if options.viewMode.value == 'text' then
  9938. -- Name only is just the chrome: the caption strip with the build bar on its top
  9939. -- edge. Strips are small enough that one column holds a lot of them, so they fill
  9940. -- a column top to bottom and only start the next when the screen runs out.
  9941. local h = CAPTION_H + PROGRESS_H
  9942. local perCol = max(1, floor((vsy - options.topOffset.value + GAP) / (h + GAP)))
  9943. col = floor((i - 1) / perCol)
  9944. row = (i - 1) % perCol
  9945. local x0 = options.leftOffset.value + col * (w + GAP)
  9946. local yTop = vsy - options.topOffset.value - row * (h + GAP)
  9947. return x0, yTop - h, x0 + w, yTop
  9948. end
  9949. local h = floor(w * ASPECT)
  9950. -- Filled a row at a time, so the stack's order -- most important first -- reads left to
  9951. -- right and then down, and the top row is always the two finds that matter most.
  9952. local cols = options.camColumns.value
  9953. col = (i - 1) % cols
  9954. row = floor((i - 1) / cols)
  9955. local x0 = options.leftOffset.value + col * (w + GAP)
  9956. local yTop = vsy - options.topOffset.value - row * (h + GAP)
  9957. return x0, yTop - h, x0 + w, yTop
  9958. end
  9959.  
  9960. -- How much ground the cam takes in, in elmos. Derived from the structure's own footprint
  9961. -- so a Solar and a Strider Hub both fill their frame, then opened up by the zoom setting.
  9962. local function CamRadius(cam)
  9963. local d = Dims(cam.defID)
  9964. local hSize = d and max(d.maxx - d.minx, d.maxz - d.minz) or 64
  9965. return max(40, hSize * 0.9) * options.camZoom.value
  9966. end
  9967.  
  9968. --------------------------------------------------------------------------------
  9969. -- The live view
  9970. --
  9971. -- This is a second render of the map, not a picture of one. The engine gives LuaUI no way
  9972. -- to point a second camera at something -- SetCameraState and the dolly API all move the
  9973. -- one real camera, and RenderToTexture has no world pass to put inside it -- but it does
  9974. -- expose the pieces the world pass is built from. So the camera is built by hand:
  9975. -- gl.Frustum for the projection, an orbit around the site for the view, gl.Viewport and
  9976. -- gl.Scissor to confine the whole thing to the cam's box, and then the actual live scene
  9977. -- is drawn into it: gl.DrawGroundQuad for the terrain, gl.Unit and gl.Feature for whatever
  9978. -- is standing there *now*. Units move in it, turrets turn in it, a nanoframe fills in.
  9979. --
  9980. -- Two things follow from the engine's own passes not being involved, and neither is a bug
  9981. -- to be fixed here:
  9982. --
  9983. -- The terrain arrives as geometry, not as the map's texture. The ground shader and its
  9984. -- texture belong to the engine's ground pass, which is long finished by the time this
  9985. -- runs, so the quad is lit and coloured by the state set below. Relief reads; the map's
  9986. -- paintwork does not.
  9987. --
  9988. -- Only what is in LOS draws, because only what is in LOS can be asked for. A cam whose
  9989. -- scout has moved on shows the ground and nothing on it. That is the honest answer, and
  9990. -- it is the same thing the main view would show you if you flew there.
  9991. --
  9992. -- It runs in DrawScreenEffects rather than DrawWorld deliberately. DrawWorld would put it
  9993. -- inside the engine's own pass, with the right state already bound -- but the engine is
  9994. -- not finished at that point, and projectiles, particles and LUPS would all paint over a
  9995. -- box we had just cleared. DrawScreenEffects is the first callin after the world is
  9996. -- complete.
  9997. --------------------------------------------------------------------------------
  9998.  
  9999. local FOV_TAN = tan(rad(45) * 0.5) -- half-angle of a 45-degree vertical field of view
  10000.  
  10001. local function DrawLive(cam, i)
  10002. local x0, y0, x1, y1 = CamRect(i)
  10003. local w, h = x1 - x0, y1 - y0
  10004. local tx, ty, tz = cam.x, cam.y, cam.z
  10005. local radius = CamRadius(cam)
  10006.  
  10007. -- Far enough back that a sphere of `radius` about the site fits the shorter axis.
  10008. local dist = radius / FOV_TAN
  10009.  
  10010. glViewport(x0, y0, w, h)
  10011. glScissor(x0, y0, w, h)
  10012. -- Both buffers, and both only inside the scissor: the colour clear is this cam's
  10013. -- backdrop, and the depth clear is what stops the world's own depth values -- written
  10014. -- by a camera that has nothing to do with this one -- rejecting every fragment.
  10015. gl.Clear(GL.COLOR_BUFFER_BIT, COL_BG[1], COL_BG[2], COL_BG[3], 1)
  10016. gl.Clear(GL.DEPTH_BUFFER_BIT, 1)
  10017.  
  10018. glMatrixMode(GL.PROJECTION)
  10019. glPushMatrix()
  10020. glLoadIdentity()
  10021. local top = NEAR_PLANE * FOV_TAN
  10022. local right = top * (w / h)
  10023. gl.Frustum(-right, right, -top, top, NEAR_PLANE, FAR_PLANE)
  10024.  
  10025. glMatrixMode(GL.MODELVIEW)
  10026. glPushMatrix()
  10027. glLoadIdentity()
  10028. -- Read bottom-up, which is the order these actually apply in: put the site at the
  10029. -- origin, spin it by the cam's yaw, tip it down by the pitch, then back the camera off
  10030. -- along its own -Z. An orbit, in other words, with the site at the centre of it.
  10031. glTranslate(0, 0, -dist)
  10032. glRotate(options.camPitch.value, 1, 0, 0)
  10033. glRotate(cam.yaw, 0, 1, 0)
  10034. glTranslate(-tx, -ty, -tz)
  10035.  
  10036. gl.DepthTest(true)
  10037. gl.DepthMask(true)
  10038. gl.Culling(GL.BACK)
  10039. gl.Lighting(true)
  10040. gl.Blending(false)
  10041.  
  10042. -- The ground. Colour here comes from the *material*, never from gl.Color: with
  10043. -- GL_COLOR_MATERIAL off -- and nothing in this pass turns it on -- a glColor is simply
  10044. -- ignored and the material's diffuse is what reaches the screen. A white diffuse and a
  10045. -- white ground are the same bug.
  10046. local style = options.groundStyle.value
  10047. local textured = (style ~= 'flat') and glTexture("$minimap") or false
  10048.  
  10049. if textured then
  10050. glMaterial(MAT_LIT)
  10051. -- The map's own top-down image, cropped to the patch under the cam. `$minimap` is
  10052. -- the whole map in one texture, so the crop is just the site's extent expressed as
  10053. -- a fraction of the map, and the only real question is which way up it is -- hence
  10054. -- the setting rather than a guess.
  10055. local u0 = (tx - radius) / MAP_X
  10056. local u1 = (tx + radius) / MAP_X
  10057. local v0 = (tz - radius) / MAP_Z
  10058. local v1 = (tz + radius) / MAP_Z
  10059. if style == 'map' then
  10060. v0, v1 = 1 - v0, 1 - v1
  10061. end
  10062. gl.DrawGroundQuad(tx - radius, tz - radius, tx + radius, tz + radius, true, true,
  10063. u0, v0, u1, v1)
  10064. glTexture(false)
  10065. else
  10066. glMaterial(MAT_GROUND)
  10067. -- Normals on, texture coordinates off: nothing is bound to put them to.
  10068. gl.DrawGroundQuad(tx - radius, tz - radius, tx + radius, tz + radius, true, false)
  10069. end
  10070.  
  10071. -- Units and wrecks. gl.Unit with the raw flag is what keeps them in *this* camera --
  10072. -- the engine's own unit path binds a shader that reads the engine's view and
  10073. -- projection, and would draw them where the real camera thinks they are. The cost of
  10074. -- the raw path is that it binds nothing, so the model textures have to be bound by
  10075. -- hand around it; without this the models arrive as white geometry.
  10076. glMaterial(MAT_LIT)
  10077. local us = spGetUnitsInCylinder(tx, tz, radius)
  10078. if us then
  10079. -- Textures belong to the model, so they are bound per unitDef rather than per unit,
  10080. -- and only when the def actually changes. A base is mostly the same few types, so
  10081. -- this collapses to a handful of binds however many units are standing in it.
  10082. local bound
  10083. for j = 1, #us do
  10084. local uid = us[j]
  10085. local udid = spGetUnitDefID(uid)
  10086. if udid then
  10087. if udid ~= bound then
  10088. if bound then
  10089. glUnitShapeTextures(bound, false)
  10090. end
  10091. glUnitShapeTextures(udid, true)
  10092. bound = udid
  10093. end
  10094. glUnit(uid, true)
  10095. end
  10096. end
  10097. if bound then
  10098. glUnitShapeTextures(bound, false)
  10099. end
  10100. end
  10101.  
  10102. if options.showWrecks.value then
  10103. local fs = spGetFeaturesInCylinder(tx, tz, radius)
  10104. if fs then
  10105. local bound
  10106. for j = 1, #fs do
  10107. local fid = fs[j]
  10108. local fdid = spGetFeatureDefID(fid)
  10109. if fdid then
  10110. if fdid ~= bound then
  10111. if bound then
  10112. glFeatureShapeTextures(bound, false)
  10113. end
  10114. glFeatureShapeTextures(fdid, true)
  10115. bound = fdid
  10116. end
  10117. glFeature(fid, true)
  10118. end
  10119. end
  10120. if bound then
  10121. glFeatureShapeTextures(bound, false)
  10122. end
  10123. end
  10124. end
  10125.  
  10126. gl.Lighting(false)
  10127. gl.Culling(false)
  10128. gl.DepthMask(false)
  10129. gl.DepthTest(false)
  10130. gl.Blending(true)
  10131.  
  10132. glMatrixMode(GL.PROJECTION)
  10133. glPopMatrix()
  10134. glMatrixMode(GL.MODELVIEW)
  10135. glPopMatrix()
  10136. glScissor(false)
  10137. glViewport(0, 0, vsx, vsy)
  10138. glColor(1, 1, 1, 1)
  10139. end
  10140.  
  10141. --------------------------------------------------------------------------------
  10142. -- The model view
  10143. --
  10144. -- The fallback, and the thing to reach for if the live view comes out wrong on a given
  10145. -- driver: no second camera, no world geometry, just the structure's own model turned in
  10146. -- front of a flat backdrop. The recipe is Zero-K's own gui_unitgroups.lua, front-face
  10147. -- culling included -- the screen projection mirrors the world one, so the winding the
  10148. -- model was built with arrives reversed.
  10149. --------------------------------------------------------------------------------
  10150.  
  10151. local function SetupModelDrawing()
  10152. gl.DepthTest(true)
  10153. gl.DepthMask(true)
  10154. gl.Culling(GL.FRONT)
  10155. gl.Lighting(true)
  10156. gl.Blending(false)
  10157. gl.Material({
  10158. ambient = {0.35, 0.35, 0.35, 1.0},
  10159. diffuse = {1.0, 1.0, 1.0, 1.0},
  10160. emission = {0.0, 0.0, 0.0, 1.0},
  10161. specular = {0.2, 0.2, 0.2, 1.0},
  10162. shininess = 16.0,
  10163. })
  10164. end
  10165.  
  10166. local function RevertModelDrawing()
  10167. gl.Blending(true)
  10168. gl.Lighting(false)
  10169. gl.Culling(false)
  10170. gl.DepthMask(false)
  10171. gl.DepthTest(false)
  10172. end
  10173.  
  10174. -- Scale the model so its longest side very nearly fills the box, then bring its bounding
  10175. -- box centre to the origin.
  10176. local function FitUnitDef(defID, boxW, boxH)
  10177. local d = Dims(defID)
  10178. if not d then
  10179. return false
  10180. end
  10181.  
  10182. local xSize = d.maxx - d.minx
  10183. local ySize = d.maxy - d.miny
  10184. local zSize = d.maxz - d.minz
  10185. local hSize = max(xSize, zSize)
  10186. if hSize <= 0 or ySize <= 0 then
  10187. return false
  10188. end
  10189.  
  10190. -- The model is turned towards the viewer before this runs, so its height on screen is
  10191. -- somewhere between ySize and the diagonal. Fitting against both dimensions and taking
  10192. -- the tighter of the two keeps a flat, wide building and a tall thin one equally inside
  10193. -- the frame.
  10194. local scale = min(boxW / hSize, boxH / (ySize + hSize * 0.45)) * 0.82
  10195. glScale(scale, scale, scale)
  10196.  
  10197. glTranslate(-0.5 * (d.maxx + d.minx), -0.5 * (d.maxy + d.miny), -0.5 * (d.maxz + d.minz))
  10198. return true
  10199. end
  10200.  
  10201. local function DrawModel(cam, i)
  10202. local x0, y0, x1, y1 = CamRect(i)
  10203. local w, h = x1 - x0, y1 - y0
  10204.  
  10205. glTexture(false)
  10206. glColor(COL_BG[1], COL_BG[2], COL_BG[3], 0.88)
  10207. glRect(x0, y0, x1, y1)
  10208. glColor(COL_FLOOR[1], COL_FLOOR[2], COL_FLOOR[3], 0.9)
  10209. glRect(x0, y0, x1, y0 + floor(h * 0.30))
  10210.  
  10211. if not Dims(cam.defID) then
  10212. return
  10213. end
  10214.  
  10215. gl.Clear(GL.DEPTH_BUFFER_BIT)
  10216. SetupModelDrawing()
  10217. glScissor(x0, y0, w, h)
  10218. glPushMatrix()
  10219. -- Below centre: a building is widest at its base, and leaving more room above it keeps
  10220. -- the caption strip off the model.
  10221. glTranslate(x0 + w * 0.5, y0 + h * 0.44, 0)
  10222. glRotate(22, 1, 0, 0)
  10223. glRotate(cam.yaw, 0, 1, 0)
  10224. if FitUnitDef(cam.defID, w, h) then
  10225. glUnitShape(cam.defID, cam.teamID)
  10226. end
  10227. glPopMatrix()
  10228. glScissor(false)
  10229. RevertModelDrawing()
  10230. glColor(1, 1, 1, 1)
  10231. end
  10232.  
  10233. --------------------------------------------------------------------------------
  10234. -- Chrome
  10235. --
  10236. -- The name strip and the frame, drawn over whichever view filled the box. Kept apart from
  10237. -- both because the live view runs a callin earlier, in DrawScreenEffects, and 2D over a
  10238. -- custom viewport and projection is not worth the matrix juggling.
  10239. --------------------------------------------------------------------------------
  10240.  
  10241. local function DrawChrome(cam, i, now)
  10242. local x0, y0, x1, y1 = CamRect(i)
  10243. local w = x1 - x0
  10244.  
  10245. local alpha = 1
  10246. local hold = options.holdTime.value
  10247. if hold > 0 then
  10248. local over = (now - (cam.heldAt or cam.born)) - hold
  10249. if over > 0 then
  10250. alpha = max(0, 1 - over / FADE)
  10251. end
  10252. end
  10253.  
  10254. glTexture(false)
  10255. glColor(COL_CAPTION[1], COL_CAPTION[2], COL_CAPTION[3], 0.66 * alpha)
  10256. glRect(x0, y0, x1, y0 + CAPTION_H)
  10257.  
  10258. -- How far along the construction is, on the strip's top edge. Only a site that was
  10259. -- still going up when it was found carries one, so a finished building gets its name
  10260. -- and nothing else. The bar stays at full once it tops out rather than vanishing: that
  10261. -- it finished while the cam was watching is the thing worth having seen.
  10262. local p = cam.progress
  10263. if p then
  10264. local done = p >= 1
  10265. local by = y0 + CAPTION_H
  10266. glColor(0, 0, 0, 0.55 * alpha)
  10267. glRect(x0, by, x1, by + PROGRESS_H)
  10268. local c = done and COL_DONE or COL_BUILD
  10269. -- Dimmed while we cannot see it: the figure is the last one we had, not the one
  10270. -- that is true now, and it should not read as live.
  10271. local a = alpha * (cam.stale and 0.45 or 1)
  10272. glColor(c[1], c[2], c[3], a)
  10273. glRect(x0, by, x0 + w * (p > 1 and 1 or p), by + PROGRESS_H)
  10274. local pp = cam.stale and cam.predicted
  10275. if pp and pp > p and options.predictProgress.value then
  10276. glColor(c[1], c[2], c[3], alpha * 0.2)
  10277. glRect(x0 + w * p, by, x0 + w * pp, by + PROGRESS_H)
  10278. end
  10279. end
  10280.  
  10281. local label = cam.name
  10282. local pp = p and cam.stale and cam.predicted
  10283. if pp and pp > p and options.predictProgress.value then
  10284. label = label .. (pp >= 1 and " ~complete"
  10285. or (" ~%d%%"):format(floor(pp * 100 + 0.5)))
  10286. elseif p then
  10287. label = label .. (p >= 1 and " complete"
  10288. or (" %d%%"):format(floor(p * 100 + 0.5)))
  10289. end
  10290. local eta = cam.super and options.superEta.value and SuperEta(cam)
  10291. if eta then
  10292. label = label .. (" ~%d:%02d"):format(floor(eta / 60), floor(eta % 60))
  10293. end
  10294. glColor(1, 1, 1, alpha)
  10295. glText(label, x0 + w * 0.5, y0 + 5, 11, "co")
  10296.  
  10297. local fc = cam.factory and COL_FRAME_F or COL_FRAME
  10298. glColor(fc[1], fc[2], fc[3], 0.9 * alpha)
  10299. glBeginEnd(GL.LINE_LOOP, function()
  10300. glVertex(x0 + 0.5, y0 + 0.5)
  10301. glVertex(x1 - 0.5, y0 + 0.5)
  10302. glVertex(x1 - 0.5, y1 - 0.5)
  10303. glVertex(x0 + 0.5, y1 - 0.5)
  10304. end)
  10305.  
  10306. glColor(1, 1, 1, 1)
  10307. end
  10308.  
  10309. -- Set once if the live path ever throws. The live view is the one part of this module
  10310. -- that cannot be checked outside a running game -- it reaches into GL calls whose exact
  10311. -- arguments the engine is the only authority on -- and an unguarded error there does not
  10312. -- merely break the cams: cawidgets removes the whole widget, taking Enemy Intel, Build
  10313. -- Proposer, Fac Switcher and Engagement Predictor down with it. So the live draw is
  10314. -- guarded, and a failure stands the live path down for the session and falls back to the
  10315. -- model view rather than repeating the error every frame. `liveFailed` itself is declared
  10316. -- with the rest of the state, where the option that clears it can see it.
  10317. local function ResetLiveState()
  10318. pcall(function()
  10319. glScissor(false)
  10320. glViewport(0, 0, vsx, vsy)
  10321. gl.ResetState()
  10322. gl.ResetMatrices()
  10323. end)
  10324. end
  10325.  
  10326. -- True when the stack should be on screen at all. Asked by all three draw callins, so a
  10327. -- hidden cam cannot leave half its chrome behind.
  10328. local function Showing()
  10329. return #cams > 0 and options.showPanel.value and not spIsGUIHidden()
  10330. end
  10331.  
  10332. -- A cam whose box has run off the screen is skipped rather than clamped: the settings that
  10333. -- put it there are the place to pull it back, and silently stacking cams on top of each
  10334. -- other would hide the fact that the offsets are wrong.
  10335. local function OnScreen(i)
  10336. local x0, y0, x1 = CamRect(i)
  10337. return y0 >= 0 and x1 <= vsx
  10338. end
  10339.  
  10340. --------------------------------------------------------------------------------
  10341. -- Callins
  10342. --------------------------------------------------------------------------------
  10343.  
  10344. local lastClock = os.clock()
  10345.  
  10346. function M:Initialize()
  10347. vsx, vsy = widgetHandler:GetViewSizes()
  10348. RebuildTeamMap()
  10349.  
  10350. -- Everything already standing in LOS predates the widget, so it is filed as found
  10351. -- without opening a cam for it. Loading mid-game is not a scouting run.
  10352. local units = spGetAllUnits()
  10353. if units then
  10354. for i = 1, #units do
  10355. local uID = units[i]
  10356. Consider(uID, spGetUnitDefID(uID), spGetUnitTeam(uID), true)
  10357. NoteNuke(uID)
  10358. end
  10359. end
  10360. end
  10361.  
  10362. function M:Shutdown()
  10363. DismissAll()
  10364. sighted, sightedSite = {}, {}
  10365. nukes.enemy, nukes.exposed = {}, {}
  10366. end
  10367.  
  10368. function M:ViewResize(newX, newY)
  10369. vsx, vsy = newX, newY
  10370. end
  10371.  
  10372. function M:UnitEnteredLos(unitID)
  10373. Consider(unitID)
  10374. MarkAntinuke(unitID)
  10375. NoteNuke(unitID)
  10376. end
  10377.  
  10378. function M:UnitCreated(unitID, unitDefID, unitTeam)
  10379. Consider(unitID, unitDefID, unitTeam)
  10380. MarkAntinuke(unitID, unitDefID, unitTeam)
  10381. NoteNuke(unitID, unitDefID, unitTeam)
  10382. end
  10383.  
  10384. function M:UnitFinished(unitID, unitDefID, unitTeam)
  10385. Consider(unitID, unitDefID, unitTeam)
  10386. MarkAntinuke(unitID, unitDefID, unitTeam)
  10387. NoteNuke(unitID, unitDefID, unitTeam)
  10388. end
  10389.  
  10390. function M:UnitDestroyed(unitID)
  10391. sighted[unitID] = nil -- the ground stays keyed, so a rebuild on the spot is still old news
  10392. -- A dead anti-nuke is worth knowing too, but a marker that still says it is there is
  10393. -- worse than none.
  10394. local m = antiMarks[unitID]
  10395. if m then
  10396. if not m.due then
  10397. spMarkerErasePosition(m.x, m.y, m.z, nil, m.localOnly)
  10398. end
  10399. antiMarks[unitID] = nil
  10400. end
  10401. nukes.enemy[unitID] = nil
  10402. -- A site destroyed in front of us has nothing left to count down to.
  10403. for i = 1, #cams do
  10404. if cams[i].unitID == unitID and cams[i].polling then
  10405. cams[i].polling = false
  10406. end
  10407. end
  10408. end
  10409.  
  10410. function M:PlayerChanged()
  10411. -- Fires for any player's change, so only act when our own viewpoint moved. Looking
  10412. -- from a different side makes every find so far somebody else's, and the cams on
  10413. -- screen are answers to a question we are no longer asking.
  10414. local prevAllyTeam = myAllyTeamID
  10415. RebuildTeamMap()
  10416. if myAllyTeamID ~= prevAllyTeam then
  10417. DismissAll()
  10418. EraseAntinukeMarks()
  10419. sighted, sightedSite = {}, {}
  10420. nukes.enemy, nukes.exposed = {}, {}
  10421. local units = spGetAllUnits()
  10422. if units then
  10423. for i = 1, #units do
  10424. local uID = units[i]
  10425. Consider(uID, spGetUnitDefID(uID), spGetUnitTeam(uID), true)
  10426. NoteNuke(uID)
  10427. end
  10428. end
  10429. end
  10430. end
  10431.  
  10432. function M:Update()
  10433. local now = os.clock()
  10434. local dt = now - lastClock
  10435. lastClock = now
  10436.  
  10437. -- Before the empty-stack return: markers outlive their cams, and work with cams off.
  10438. PollAntinukeMarks(now)
  10439. if now >= nukes.next then
  10440. nukes.next = now + 2
  10441. CheckCoverage()
  10442. end
  10443.  
  10444. local n = #cams
  10445. if n == 0 then
  10446. return
  10447. end
  10448.  
  10449. -- Expiry lives here rather than in the draw, so a cam whose time is up goes away even
  10450. -- while the GUI is hidden and nothing is being drawn at all.
  10451. local hold = options.holdTime.value
  10452. if hold > 0 then
  10453. for i = n, 1, -1 do
  10454. local cam = cams[i]
  10455. if Pinned(cam) then
  10456. cam.heldAt = now -- the hold time starts over once it is released
  10457. end
  10458. if (now - (cam.heldAt or cam.born)) - hold > FADE then
  10459. table.remove(cams, i)
  10460. end
  10461. end
  10462. end
  10463.  
  10464. for i = 1, #cams do
  10465. local cam = cams[i]
  10466. if cam.mobile then
  10467. -- A strider cam is aimed at a pad, and the strider steps off it the moment it
  10468. -- is done. Following it while it is in sight keeps the cam on the thing it was
  10469. -- opened for rather than on the ground it used to stand on; lose sight and it
  10470. -- holds the last place we saw it, which is the honest answer.
  10471. local ux, uy, uz = spGetUnitPosition(cam.unitID)
  10472. if ux then
  10473. cam.x, cam.y, cam.z = ux, uy, uz
  10474. end
  10475. end
  10476. if cam.polling then
  10477. local beingBuilt, prog = spGetUnitIsBeingBuilt(cam.unitID)
  10478. -- Game time for the build rate, so a pause or a replay at 4x does not skew it.
  10479. local gt = Spring.GetGameSeconds and Spring.GetGameSeconds() or now
  10480. if beingBuilt == nil then
  10481. -- Out of sight, or dead. Either way the last figure we had is all there is,
  10482. -- so it is kept and flagged rather than dropped: "47%, last time we looked"
  10483. -- is intel, and a bar that emptied itself would be a lie.
  10484. cam.stale = true
  10485. -- The prediction runs beside it, never over it: cam.progress stays the
  10486. -- last thing actually seen.
  10487. if cam.rate and cam.rate > 0 and cam.seenT then
  10488. cam.predicted = min(1, cam.progress + cam.rate * (gt - cam.seenT))
  10489. end
  10490. else
  10491. cam.stale = false
  10492. cam.predicted = nil
  10493. cam.progress = prog or cam.progress
  10494. -- Build rate, sampled at most once a second: per-frame deltas are mostly
  10495. -- zero between sim frames. A sample spanning a spell out of sight is still
  10496. -- a true average, so it is not thrown away.
  10497. local p = cam.progress
  10498. if not cam.rateT then
  10499. cam.rateT, cam.rateP = gt, p
  10500. elseif gt - cam.rateT >= 1 then
  10501. local r = (p - cam.rateP) / (gt - cam.rateT)
  10502. cam.rate = cam.rate and (cam.rate + r) * 0.5 or r
  10503. cam.rateT, cam.rateP = gt, p
  10504. end
  10505. cam.seenT = gt
  10506. if not beingBuilt then
  10507. cam.progress = 1
  10508. cam.polling = false -- topped out; nothing left to ask
  10509. end
  10510. end
  10511. end
  10512. if options.spin.value then
  10513. cam.yaw = (cam.yaw + dt * 18) % 360
  10514. end
  10515. end
  10516. end
  10517.  
  10518. -- The live view goes here rather than in DrawScreen because this is the first callin after
  10519. -- the engine has finished with the world: in DrawWorld the projectiles, the particles and
  10520. -- LUPS are all still to come, and every one of them would paint over a box we had just
  10521. -- cleared to draw into.
  10522. function M:DrawScreenEffects()
  10523. if not Showing() or options.viewMode.value ~= 'live' or liveFailed then
  10524. return
  10525. end
  10526. for i = 1, #cams do
  10527. if OnScreen(i) then
  10528. local ok, err = pcall(DrawLive, cams[i], i)
  10529. if not ok then
  10530. ResetLiveState() -- the failed draw left the matrices and viewport its way
  10531. liveFailed = true
  10532. spEcho("Combat Advisor: Scout Cam live view failed, falling back to the unit "
  10533. .. "model. " .. tostring(err))
  10534. return
  10535. end
  10536. end
  10537. end
  10538. end
  10539.  
  10540. function M:DrawWorldPreUnit()
  10541. local exposed = nukes.exposed
  10542. if #exposed == 0 or spIsGUIHidden() then
  10543. return
  10544. end
  10545. glColor(1.0, 0.25, 0.20, 0.9)
  10546. gl.LineWidth(2)
  10547. for i = 1, #exposed do
  10548. local e = exposed[i]
  10549. gl.DrawGroundCircle(e[1], e[2], e[3], e[4], 32)
  10550. end
  10551. gl.LineWidth(1)
  10552. glColor(1, 1, 1, 1)
  10553. end
  10554.  
  10555. function M:DrawScreen()
  10556. -- Above the stack, and drawn whether or not any cam is up: the silo that caused it may
  10557. -- have been found long ago.
  10558. local nExposed = #nukes.exposed
  10559. if nExposed > 0 and not spIsGUIHidden() then
  10560. glColor(1.0, 0.35, 0.30, 1)
  10561. glText(("%d key building%s open to enemy nukes"):format(nExposed, nExposed == 1 and "" or "s"),
  10562. options.leftOffset.value, vsy - options.topOffset.value + 6, 12, "o")
  10563. glColor(1, 1, 1, 1)
  10564. end
  10565. if not Showing() then
  10566. return
  10567. end
  10568. local mode = options.viewMode.value
  10569. local live = mode == 'live' and not liveFailed
  10570. local model = not live and mode ~= 'text'
  10571. if model then
  10572. -- Once for the stack, not once per cam: the models are depth-tested against each
  10573. -- other only inside their own scissor box, and each box starts from the same empty
  10574. -- state. The live view does its own clearing, inside its own scissor.
  10575. gl.Clear(GL.DEPTH_BUFFER_BIT)
  10576. end
  10577. local now = os.clock()
  10578. for i = 1, #cams do
  10579. if OnScreen(i) then
  10580. if model then
  10581. DrawModel(cams[i], i)
  10582. end
  10583. DrawChrome(cams[i], i, now)
  10584. end
  10585. end
  10586. end
  10587.  
  10588. -- Left click takes the camera to the site, right click dismisses the cam. Returning true
  10589. -- is what stops the click also landing on the world behind it, so it is returned only for
  10590. -- a click that actually hit a cam.
  10591. function M:MousePress(mx, my, button)
  10592. if not Showing() then
  10593. return false
  10594. end
  10595. if button ~= 1 and button ~= 3 then
  10596. return false
  10597. end
  10598. for i = 1, #cams do
  10599. local x0, y0, x1, y1 = CamRect(i)
  10600. if mx >= x0 and mx <= x1 and my >= y0 and my <= y1 then
  10601. local cam = cams[i]
  10602. if button == 1 then
  10603. spSetCameraTarget(cam.x, cam.y, cam.z, 1)
  10604. else
  10605. table.remove(cams, i)
  10606. end
  10607. return true
  10608. end
  10609. end
  10610. return false
  10611. end
  10612.  
  10613. -- Only reached for a press we claimed above, and there is nothing to finish: -1 hands the
  10614. -- mouse straight back rather than leaving the widget holding it.
  10615. function M:MouseRelease()
  10616. return -1
  10617. end
  10618.  
  10619. RegisterModule{
  10620. callins = M,
  10621. prefix = 'sc_',
  10622. optionsPath = options_path,
  10623. options = options,
  10624. optionsOrder = options_order,
  10625. }
  10626. end
  10627.  
  10628. --------------------------------------------------------------------------------
  10629. -- Build Herald
  10630. --
  10631. -- Scout Cam's mirror image: that one reports what the enemy just put up, this one reports
  10632. -- what your own side just finished. When a large unit or building of your team is
  10633. -- completed, a map marker goes down on it with its name, and the owner's name if it is not
  10634. -- yours. A strider walking off the pad, an ally's Singularity coming online, or your own
  10635. -- anti-nuke going live is worth a glance at the minimap without anyone typing it into chat.
  10636. --
  10637. -- The markers clear themselves after a while by default, since a marker per fusion would
  10638. -- bury the map by the thirtieth minute. It publishes nothing.
  10639. --
  10640. -- Two more kinds of marker live here, because the engine erases markers by place rather
  10641. -- than by marker: two modules each clearing their own would take each other's with them,
  10642. -- and one owner of every marker this widget places is what keeps the waiting in Update
  10643. -- honest. Tech pings mark the first enemy unit of each new type, and of each new class --
  10644. -- air, cloakers, amphibious, submarines, striders, artillery -- where it is seen. Raid
  10645. -- alerts mark our metal extractors and energy buildings when armed enemies are at them and
  10646. -- nothing armed of ours is. Artillery is Build Proposer's role, read from WG.CounterMatrix.
  10647. --------------------------------------------------------------------------------
  10648.  
  10649. do
  10650. local M = {} -- this module's callins, collected by RegisterModule below
  10651. local options_path, options_order, options
  10652.  
  10653. local spGetUnitPosition = Spring.GetUnitPosition
  10654. local spGetTeamInfo = Spring.GetTeamInfo
  10655. local spGetPlayerInfo = Spring.GetPlayerInfo
  10656. local spGetMyTeamID = Spring.GetMyTeamID
  10657. local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
  10658. local spGetSpectatingState = Spring.GetSpectatingState
  10659. local spMarkerAddPoint = Spring.MarkerAddPoint
  10660. local spMarkerErasePosition = Spring.MarkerErasePosition
  10661. local spGetUnitDefID = Spring.GetUnitDefID
  10662. local spGetUnitTeam = Spring.GetUnitTeam
  10663. local spGetUnitAllyTeam = Spring.GetUnitAllyTeam
  10664. local spGetAllUnits = Spring.GetAllUnits
  10665. local spGetTeamUnits = Spring.GetTeamUnits
  10666. local spGetUnitsInCylinder = Spring.GetUnitsInCylinder
  10667.  
  10668. -- The engine erases every marker within a short distance of the point it is given, not one
  10669. -- marker. So two of ours closer than this share one clearing. See Update.
  10670. local ERASE_NEAR = 150
  10671.  
  10672. -- Customparams arrive stringified, so a flag that is present but off is the string "false".
  10673. local function Flag(cp, key)
  10674. local v = cp and cp[key]
  10675. return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
  10676. end
  10677.  
  10678. local unitName, cost, isStatic, skip = {}, {}, {}, {}
  10679. -- For the tech pings and the raid alerts. `classes[defID]` lists the static classes of a
  10680. -- mobile unit type we would ping about; artillery is added at ping time from Build
  10681. -- Proposer's roles. Pseudo-units -- mines, terraform -- are static and skipped, so they
  10682. -- never get a list.
  10683. local classes, isArmed, isEco = {}, {}, {}
  10684. local CLASS_NAME = {air = "air", cloak = "cloakers", amph = "amphibious", sub = "submarines",
  10685. strider = "striders", arty = "artillery"}
  10686. local strider = {}
  10687. for defID = 1, #UnitDefs do
  10688. local ud = UnitDefs[defID]
  10689. if ud.name == "striderhub" then
  10690. for i = 1, #(ud.buildOptions or {}) do
  10691. strider[ud.buildOptions[i]] = true
  10692. end
  10693. end
  10694. end
  10695. for defID = 1, #UnitDefs do
  10696. local ud = UnitDefs[defID]
  10697. local cp = ud.customParams
  10698. unitName[defID] = ud.humanName or ud.name or "?"
  10699. cost[defID] = ud.metalCost or 0
  10700. isStatic[defID] = (ud.speed or 0) == 0
  10701. -- Pseudo-units, as in Scout Cam: never finished, or not anyone's build. Commanders
  10702. -- too, because a morph finishes a fresh commander unit, and "your commander is your
  10703. -- commander" is not news.
  10704. skip[defID] = Flag(cp, "dontcount") or Flag(cp, "is_drone") or Flag(cp, "dynamic_comm")
  10705. or Flag(cp, "commtype")
  10706. isArmed[defID] = #(ud.weapons or {}) > 0
  10707. -- Zero-K pays metal through a gadget rather than the engine's extractsMetal, and moves
  10708. -- energyMake into income_energy, which factories and commanders also carry: hence
  10709. -- static, and builds nothing.
  10710. isEco[defID] = isStatic[defID] and (ud.buildSpeed or 0) == 0 and not ud.isFactory
  10711. and ((tonumber(cp and cp.metal_extractor_mult) or 0) > 0
  10712. or (tonumber(cp and cp.income_energy) or 0) > 0)
  10713. if not isStatic[defID] and not skip[defID] then
  10714. local c = {}
  10715. local move = ud.moveDef and ud.moveDef.name or ""
  10716. if ud.canFly then c[#c + 1] = "air" end
  10717. -- Personal cloak. Area cloak makes nearly every mobile canCloak, so that flag says
  10718. -- nothing; a cloak cost, or being the area cloaker itself, does.
  10719. if (ud.cloakCost or 0) > 0 or Flag(cp, "area_cloak") then c[#c + 1] = "cloak" end
  10720. if move:find("^akbot") or move:find("^bhover") then c[#c + 1] = "amph" end
  10721. if move:find("^uboat") then c[#c + 1] = "sub" end
  10722. -- The Strider Hub's roster, less the Athena that builds it.
  10723. if strider[defID] and (ud.buildSpeed or 0) == 0 then c[#c + 1] = "strider" end
  10724. classes[defID] = c
  10725. end
  10726. end
  10727.  
  10728. local myTeamID, myAllyTeamID = spGetMyTeamID(), spGetMyAllyTeamID()
  10729. local marks = {} -- {x, y, z, localOnly, expires}; ours, still on the map
  10730. -- What the enemy has been seen fielding, by unit type and by class, since we took this seat.
  10731. local tech = {types = {}, classes = {}}
  10732. -- Raid alert: a cooldown per map cell so one raid is one marker, and the check's own clock.
  10733. local raid = {cool = {}, next = 0, CELL = 600, GUARD = 400}
  10734.  
  10735. options_path = 'Settings/HUD Panels/Build Herald'
  10736. options_order = {'enabled', 'onlyMine', 'buildingCost', 'unitCost',
  10737. 'techTypes', 'techClasses', 'techMinCost',
  10738. 'raidEnabled', 'raidRadius', 'raidCooldown',
  10739. 'share', 'clearAfter'}
  10740. options = {
  10741. enabled = {
  10742. name = 'Mark large completions of your team',
  10743. desc = 'When a large unit or building of your team is completed, put a map marker on '
  10744. .. 'it with its name. An ally\'s build also carries the owner\'s name.',
  10745. type = 'bool',
  10746. value = true,
  10747. },
  10748. onlyMine = {
  10749. name = 'Build Herald: only my own units',
  10750. desc = 'Mark only what you built yourself, not your allies\' builds.',
  10751. type = 'bool',
  10752. value = false,
  10753. },
  10754. buildingCost = {
  10755. name = 'Build Herald: buildings costing at least (metal)',
  10756. desc = 'At the default a Singularity Reactor, Antinuke, Trinity, Big Bertha, Lucifer '
  10757. .. 'or Cerberus is marked, and a fusion is not.',
  10758. type = 'number',
  10759. min = 100, max = 20000, step = 100, value = 1500,
  10760. },
  10761. unitCost = {
  10762. name = 'Build Herald: units costing at least (metal)',
  10763. desc = 'At the default striders, Cyclops, Likho, Krow and Reef are marked, and the '
  10764. .. 'cheaper heavies such as Grizzly, Jugglenaut and Crab are not.',
  10765. type = 'number',
  10766. min = 100, max = 20000, step = 100, value = 2000,
  10767. },
  10768. techTypes = {
  10769. name = 'Tech ping: first of each enemy unit type',
  10770. desc = 'Put a map marker on the first enemy unit of every type you see, "Enemy '
  10771. .. 'Hammerhead". Mobile units only; Scout Cam covers buildings. Commanders are '
  10772. .. 'left out, since every morph is a new type.',
  10773. type = 'bool',
  10774. value = true,
  10775. },
  10776. techClasses = {
  10777. name = 'Tech ping: first of each enemy unit class',
  10778. desc = 'Put a map marker on the first enemy unit of each class you see: air, '
  10779. .. 'cloakers, amphibious, submarines, striders and artillery, "Enemy air: '
  10780. .. 'Hammerhead". Ignores the cost floor below. When a unit is both, the class '
  10781. .. 'marker is the one placed.',
  10782. type = 'bool',
  10783. value = true,
  10784. },
  10785. techMinCost = {
  10786. name = 'Tech ping: unit types costing at least (metal)',
  10787. desc = 'Only ping a new unit type from this cost up. 0 pings every type.',
  10788. type = 'number',
  10789. min = 0, max = 5000, step = 50, value = 0,
  10790. },
  10791. raidEnabled = {
  10792. name = 'Raid alert: ping enemies at undefended eco',
  10793. desc = 'Put a map marker on your metal extractor or energy building when armed enemy '
  10794. .. 'units are at it and nothing armed of your team is within 400 elmos, "Raid: 3 '
  10795. .. 'Glaive". Checked every second, one marker per area per cooldown.',
  10796. type = 'bool',
  10797. value = true,
  10798. },
  10799. raidRadius = {
  10800. name = 'Raid alert: enemy within (elmos)',
  10801. type = 'number',
  10802. min = 100, max = 1000, step = 25, value = 350,
  10803. },
  10804. raidCooldown = {
  10805. name = 'Raid alert: same area at most every (s)',
  10806. desc = 'The map is cut into 600-elmo squares, and a square that has just been marked '
  10807. .. 'stays quiet this long.',
  10808. type = 'number',
  10809. min = 5, max = 300, step = 5, value = 30,
  10810. },
  10811. share = {
  10812. name = 'Herald, tech ping and raid markers are',
  10813. desc = 'For me only: a private marker. For my team: placed like a ping of your own, so '
  10814. .. 'allies see it and hear it arrive. If more than one of you runs this with that '
  10815. .. 'setting, you will get one marker each.',
  10816. type = 'radioButton',
  10817. value = 'local',
  10818. items = {
  10819. {key = 'local', name = 'For me only', desc = 'Only you see them.'},
  10820. {key = 'allies', name = 'For my team', desc = 'Your allies see them too.'},
  10821. },
  10822. },
  10823. clearAfter = {
  10824. name = 'Clear Herald, tech ping and raid markers after (s)',
  10825. desc = 'Remove each marker after this long. 0 leaves them until you erase them. '
  10826. .. 'The engine clears by place, not by marker, so clearing also takes any other '
  10827. .. 'marker right on that spot with it. That includes an ally\'s, for everyone, if '
  10828. .. 'the markers are shared.',
  10829. type = 'number',
  10830. min = 0, max = 600, step = 5, value = 30,
  10831. },
  10832. }
  10833.  
  10834. local function TeamName(teamID)
  10835. local _, leader, _, isAI = spGetTeamInfo(teamID, false)
  10836. if isAI then
  10837. local _, botName = Spring.GetAIInfo(teamID)
  10838. return botName or "AI"
  10839. end
  10840. return (leader and spGetPlayerInfo(leader, false)) or ("team " .. teamID)
  10841. end
  10842.  
  10843. -- Every marker this module places goes through here, so Update can clear them all.
  10844. local function Mark(x, y, z, text)
  10845. local localOnly = options.share.value ~= 'allies'
  10846. spMarkerAddPoint(x, y, z, text, localOnly)
  10847. local hold = options.clearAfter.value
  10848. if hold > 0 then
  10849. marks[#marks + 1] = {x = x, y = y, z = z, localOnly = localOnly, expires = os.clock() + hold}
  10850. end
  10851. end
  10852.  
  10853. local function IsEnemy(team)
  10854. return team ~= nil and team ~= Spring.GetGaiaTeamID()
  10855. and select(6, spGetTeamInfo(team, false)) ~= myAllyTeamID
  10856. end
  10857.  
  10858. -- `quiet` files what is already in sight when we start, without a marker for any of it.
  10859. local function TechSeen(unitID, defID, quiet)
  10860. local c = defID and classes[defID]
  10861. if not c then
  10862. return -- not a mobile we ping about, or a radar dot with no type yet
  10863. end
  10864. local newType = not tech.types[defID]
  10865. tech.types[defID] = true
  10866. local all = c
  10867. local role = WG.CounterMatrix and WG.CounterMatrix.role
  10868. if role and role[defID] == "arty" then
  10869. all = {"arty"}
  10870. for i = 1, #c do
  10871. all[#all + 1] = c[i]
  10872. end
  10873. end
  10874. local fresh = {}
  10875. for i = 1, #all do
  10876. if not tech.classes[all[i]] then
  10877. tech.classes[all[i]] = true
  10878. fresh[#fresh + 1] = CLASS_NAME[all[i]]
  10879. end
  10880. end
  10881. if quiet then
  10882. return
  10883. end
  10884. local text
  10885. if #fresh > 0 and options.techClasses.value then
  10886. text = "Enemy " .. table.concat(fresh, ", ") .. ": " .. unitName[defID]
  10887. elseif newType and options.techTypes.value and cost[defID] >= options.techMinCost.value then
  10888. text = "Enemy " .. unitName[defID]
  10889. end
  10890. if text then
  10891. local x, y, z = spGetUnitPosition(unitID)
  10892. if x then
  10893. Mark(x, y, z, text)
  10894. end
  10895. end
  10896. end
  10897.  
  10898. local function SeedTech()
  10899. tech = {types = {}, classes = {}}
  10900. local units = spGetAllUnits()
  10901. for i = 1, (units and #units or 0) do
  10902. local uID = units[i]
  10903. if IsEnemy(spGetUnitTeam(uID)) then
  10904. TechSeen(uID, spGetUnitDefID(uID), true)
  10905. end
  10906. end
  10907. end
  10908.  
  10909. local function TechEvent(unitID, unitDefID, unitTeam)
  10910. if spGetSpectatingState() then
  10911. return
  10912. end
  10913. if IsEnemy(unitTeam or spGetUnitTeam(unitID)) then
  10914. TechSeen(unitID, unitDefID or spGetUnitDefID(unitID), false)
  10915. end
  10916. end
  10917.  
  10918. -- Once a second: every eco building of ours with armed enemies at it and nothing armed of
  10919. -- ours in guarding distance. Radar dots are left out: an unidentified dot at a mex is as
  10920. -- likely a scout as a raid, and the marker names what is there. Enemy mines are static,
  10921. -- and a mine field by a mex is not a raid.
  10922. local function CheckRaids(now)
  10923. if not options.raidEnabled.value or spGetSpectatingState() then
  10924. return
  10925. end
  10926. local R = options.raidRadius.value
  10927. local reach = math.max(R, raid.GUARD)
  10928. local gaia = Spring.GetGaiaTeamID()
  10929. local units = spGetTeamUnits(myTeamID)
  10930. for i = 1, (units and #units or 0) do
  10931. local uID = units[i]
  10932. local defID = spGetUnitDefID(uID)
  10933. local x, y, z = spGetUnitPosition(uID)
  10934. if defID and isEco[defID] and x then
  10935. local key = math.floor(x / raid.CELL) .. ":" .. math.floor(z / raid.CELL)
  10936. if (raid.cool[key] or 0) <= now then
  10937. local byDef, guarded, any = {}, false, false
  10938. local near = spGetUnitsInCylinder(x, z, reach)
  10939. for j = 1, (near and #near or 0) do
  10940. local n = near[j]
  10941. local nd = spGetUnitDefID(n)
  10942. local nx, _, nz = spGetUnitPosition(n)
  10943. if nd and nx and isArmed[nd] then
  10944. local d2 = (nx - x) ^ 2 + (nz - z) ^ 2
  10945. if spGetUnitAllyTeam(n) == myAllyTeamID then
  10946. if d2 <= raid.GUARD * raid.GUARD then
  10947. guarded = true
  10948. break
  10949. end
  10950. elseif not isStatic[nd] and d2 <= R * R and spGetUnitTeam(n) ~= gaia then
  10951. byDef[nd] = (byDef[nd] or 0) + 1
  10952. any = true
  10953. end
  10954. end
  10955. end
  10956. if any and not guarded then
  10957. local list = {}
  10958. for d, k in pairs(byDef) do
  10959. list[#list + 1] = {d = d, k = k}
  10960. end
  10961. table.sort(list, function(p, q)
  10962. if p.k ~= q.k then return p.k > q.k end
  10963. return p.d < q.d
  10964. end)
  10965. local parts = {}
  10966. for j = 1, math.min(2, #list) do
  10967. parts[j] = list[j].k .. " " .. unitName[list[j].d]
  10968. end
  10969. Mark(x, y, z, "Raid: " .. table.concat(parts, ", "))
  10970. raid.cool[key] = now + options.raidCooldown.value
  10971. end
  10972. end
  10973. end
  10974. end
  10975. end
  10976.  
  10977. local function IsHeraldDef(defID)
  10978. if not defID or skip[defID] then
  10979. return false
  10980. end
  10981. local floor = isStatic[defID] and options.buildingCost.value or options.unitCost.value
  10982. return cost[defID] >= floor
  10983. end
  10984.  
  10985. function M:Initialize()
  10986. myTeamID, myAllyTeamID = spGetMyTeamID(), spGetMyAllyTeamID()
  10987. SeedTech()
  10988. end
  10989.  
  10990. function M:Shutdown()
  10991. -- The markers are left where they are, as notes on the map. Only the clearing stops.
  10992. marks = {}
  10993. end
  10994.  
  10995. function M:PlayerChanged()
  10996. local prevAlly = myAllyTeamID
  10997. myTeamID, myAllyTeamID = spGetMyTeamID(), spGetMyAllyTeamID()
  10998. if myAllyTeamID ~= prevAlly then
  10999. SeedTech() -- a new seat: what that side has already seen is not news to it
  11000. raid.cool = {}
  11001. end
  11002. end
  11003.  
  11004. function M:UnitEnteredLos(unitID, unitTeam)
  11005. TechEvent(unitID, nil, unitTeam)
  11006. end
  11007.  
  11008. function M:UnitCreated(unitID, unitDefID, unitTeam)
  11009. TechEvent(unitID, unitDefID, unitTeam)
  11010. end
  11011.  
  11012. function M:UnitFinished(unitID, unitDefID, unitTeam)
  11013. if not options.enabled.value or not IsHeraldDef(unitDefID) then
  11014. return
  11015. end
  11016. -- A spectator's "team" is whoever they are looking through, and a shared marker from one
  11017. -- goes to the spectators, so nothing is marked while spectating.
  11018. if spGetSpectatingState() then
  11019. return
  11020. end
  11021. if unitTeam ~= myTeamID then
  11022. if options.onlyMine.value or select(6, spGetTeamInfo(unitTeam, false)) ~= myAllyTeamID then
  11023. return
  11024. end
  11025. end
  11026. local x, y, z = spGetUnitPosition(unitID)
  11027. if not x then
  11028. return
  11029. end
  11030. local text = unitName[unitDefID]
  11031. if unitTeam ~= myTeamID then
  11032. text = text .. " (" .. TeamName(unitTeam) .. ")"
  11033. end
  11034. Mark(x, y, z, text)
  11035. end
  11036.  
  11037. function M:Update()
  11038. local now = os.clock()
  11039. if now >= raid.next then
  11040. raid.next = now + 1
  11041. CheckRaids(now)
  11042. end
  11043. local n = #marks
  11044. if n == 0 then
  11045. return
  11046. end
  11047. for i = n, 1, -1 do
  11048. local m = marks[i]
  11049. if now >= m.expires then
  11050. -- An erase here would take a newer marker of ours next to it too, so this one waits
  11051. -- until that one is due and they go together.
  11052. local wait
  11053. for j = 1, #marks do
  11054. local o = marks[j]
  11055. if o ~= m and o.expires > m.expires then
  11056. local dx, dz = o.x - m.x, o.z - m.z
  11057. if dx * dx + dz * dz < ERASE_NEAR * ERASE_NEAR then
  11058. wait = math.max(wait or 0, o.expires)
  11059. end
  11060. end
  11061. end
  11062. if wait then
  11063. m.expires = wait
  11064. else
  11065. spMarkerErasePosition(m.x, m.y, m.z, nil, m.localOnly)
  11066. table.remove(marks, i)
  11067. end
  11068. end
  11069. end
  11070. end
  11071.  
  11072. RegisterModule{
  11073. callins = M,
  11074. prefix = 'bh_',
  11075. optionsPath = options_path,
  11076. options = options,
  11077. optionsOrder = options_order,
  11078. }
  11079. end
  11080.  
  11081. -- Handed to EPIC Menu only once every module has filed its own.
  11082. options_path = OPTIONS_PATH
  11083. options = allOptions
  11084. options_order = allOptionsOrder
  11085.  
  11086. --------------------------------------------------------------------------------
  11087. -- Callin dispatch
  11088. --------------------------------------------------------------------------------
  11089.  
  11090. -- Only the callins somebody actually implements get defined, so the engine is not handed an
  11091. -- empty UnitDamaged to call on every point of damage traded in the game. Where exactly one
  11092. -- module wants a callin -- SelectionChanged, DrawInMiniMap, MousePress -- its function is
  11093. -- installed directly: none of these bodies looks at `self`, and a fan-out of one is not
  11094. -- worth a trampoline on a hot callin. UnitDamaged has two implementors since the front
  11095. -- overlay (Build Proposer and Engagement Predictor), so keep both handlers cheap.
  11096. --
  11097. -- Installing directly is also the only form that carries a return value back to the engine,
  11098. -- and MousePress has to be able to say it consumed the click. So a callin whose answer
  11099. -- matters must stay in exactly one module: a second implementor would silently turn it into
  11100. -- the fan-out below, which calls each in turn and returns nothing.
  11101. local CALLINS = {
  11102. "Update", "DrawScreen", "DrawScreenEffects", "DrawWorldPreUnit", "DrawInMiniMap",
  11103. "ViewResize", "PlayerChanged",
  11104. "SelectionChanged",
  11105. "MousePress", "MouseRelease",
  11106. "UnitCreated", "UnitFinished", "UnitDestroyed", "UnitEnteredLos", "UnitDamaged",
  11107. }
  11108.  
  11109. for i = 1, #CALLINS do
  11110. local name = CALLINS[i]
  11111. local want = {}
  11112. for j = 1, #modules do
  11113. if modules[j][name] then
  11114. want[#want + 1] = modules[j]
  11115. end
  11116. end
  11117. if #want == 1 then
  11118. widget[name] = want[1][name]
  11119. elseif #want > 1 then
  11120. widget[name] = function(_, ...)
  11121. for j = 1, #want do
  11122. want[j][name](want[j], ...)
  11123. end
  11124. end
  11125. end
  11126. end
  11127.  
  11128. function widget:Initialize()
  11129. -- Hoisted out of the modules, all three of which want Chili and each of which would
  11130. -- otherwise pull the whole widget down on its own while the others carried on starting.
  11131. if not WG.Chili then
  11132. Spring.Echo("Combat Advisor: WG.Chili unavailable, removing widget.")
  11133. widgetHandler:RemoveWidget(widget)
  11134. return
  11135. end
  11136. for i = 1, #modules do
  11137. modules[i]:Initialize()
  11138. end
  11139. end
  11140.  
  11141. function widget:Shutdown()
  11142. -- Backwards: Fac Switcher and Engagement Predictor both read what Build Proposer
  11143. -- publishes, which reads what Enemy Intel publishes, so every consumer is gone before
  11144. -- the thing it consumes.
  11145. for i = #modules, 1, -1 do
  11146. modules[i]:Shutdown()
  11147. end
  11148. end
  11149.  
Add Comment
Please, Sign In to add comment