Guest User

Untitled

a guest
Aug 29th, 2026
92
0
Never
2
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 37.99 KB | None | 0 0
  1. function widget:GetInfo()
  2. return {
  3. name = "Engagement Predictor",
  4. desc = "Answers 'should I take this fight?'. Select units and it gathers the friendly "
  5. .. "force around the selection and the enemy force around that, both radii "
  6. .. "adjustable in Settings/HUD Panels/Engagement Predictor, then reports a win "
  7. .. "probability from the archetype counter matrix and tier list published by the "
  8. .. "Build Proposer widget. Radar contacts are given probable unit types out of the army the Enemy "
  9. .. "Intel widget remembers. Works without either, less well: with no Build Proposer "
  10. .. "it falls back to comparing metal, and says so.",
  11. author = "Thomas",
  12. date = "2026",
  13. license = "GPL-v2",
  14. layer = 0,
  15. enabled = false, -- enable from the F11 widget list
  16. }
  17. end
  18.  
  19. --------------------------------------------------------------------------------
  20. -- Includes / static per-unitDef data
  21. --
  22. -- The archetypes, the counter matrix and the scoring functions all live in
  23. -- gui_build_proposer.lua and arrive through WG.CounterMatrix. What is built here is only
  24. -- what is needed to draw a row and to price a unit, so the panel still has something to
  25. -- say when that widget is switched off.
  26. --------------------------------------------------------------------------------
  27.  
  28. local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
  29. local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
  30.  
  31. local iconFormat = ".dds"
  32. do
  33. -- chilitip_conf.lua returns: ignoreweapon, iconFormat
  34. local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
  35. if ok and fmt then
  36. iconFormat = fmt
  37. end
  38. end
  39.  
  40. local spGetSelectedUnits = Spring.GetSelectedUnits
  41. local spGetUnitDefID = Spring.GetUnitDefID
  42. local spGetUnitPosition = Spring.GetUnitPosition
  43. local spGetUnitHealth = Spring.GetUnitHealth
  44. local spGetUnitTeam = Spring.GetUnitTeam
  45. local spGetUnitAllyTeam = Spring.GetUnitAllyTeam
  46. local spGetUnitsInRectangle = Spring.GetUnitsInRectangle
  47. local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
  48. local spGetMyTeamID = Spring.GetMyTeamID
  49. local spGetGaiaTeamID = Spring.GetGaiaTeamID
  50. local spEcho = Spring.Echo
  51.  
  52. local floor, ceil, max, min, abs, huge =
  53. math.floor, math.ceil, math.max, math.min, math.abs, math.huge
  54.  
  55. -- Zero-K loads this helper into the widget environment too. It resolves commander and
  56. -- Tech-K cost scaling, neither of which the unitDef knows about, so it is worth preferring
  57. -- wherever there is a live unitID to ask about.
  58. local spuGetUnitCost = Spring.Utilities and Spring.Utilities.GetUnitCost
  59.  
  60. local DEFAULT_ICON = "icons/default.dds"
  61.  
  62. -- The key every radar contact that could not be resolved to a real type is filed under.
  63. -- Negative so it can never collide with a unitDefID, which forces every loop over the two
  64. -- force tables to decide what to do with it rather than silently indexing a nil.
  65. local BLIP_KEY = -1
  66.  
  67. local cost = {}
  68. local unitName = {}
  69. local iconFile = {}
  70. local isStatic = {}
  71. local skipDef = {} -- drones and anything flagged dontcount: already priced into a parent
  72.  
  73. -- customparams arrive stringified (see gamedata/unitdefs_post.lua), so a flag that is
  74. -- present but off is the string "false", which is perfectly truthy in Lua.
  75. local function Flag(cp, key)
  76. local v = cp and cp[key]
  77. return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
  78. end
  79.  
  80. for defID = 1, #UnitDefs do
  81. local ud = UnitDefs[defID]
  82. local it = ud.iconType or "default"
  83. local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
  84. or ("icons/" .. it .. iconFormat)
  85. if not VFS.FileExists(path) then
  86. path = DEFAULT_ICON
  87. end
  88. iconFile[defID] = path
  89. unitName[defID] = ud.humanName or ud.name or "?"
  90. cost[defID] = ud.metalCost or 0
  91. isStatic[defID] = (ud.speed or 0) == 0
  92.  
  93. local cp = ud.customParams
  94. skipDef[defID] = Flag(cp, "dontcount") or Flag(cp, "is_drone")
  95. end
  96.  
  97. --------------------------------------------------------------------------------
  98. -- State (declared before `options` so the option closures can capture them)
  99. --------------------------------------------------------------------------------
  100.  
  101. local NEUTRAL = 1.0
  102.  
  103. local result = nil -- the last computed prediction, or nil for the idle panel
  104. local idleText = "Select units to predict a fight."
  105. local dirty = true
  106. local nextRefresh = 0
  107.  
  108. local myTeamID = spGetMyTeamID()
  109. local gaiaTeamID = spGetGaiaTeamID()
  110.  
  111. local LogDetail -- forward declaration, defined below
  112.  
  113. --------------------------------------------------------------------------------
  114. -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
  115. --------------------------------------------------------------------------------
  116.  
  117. options_path = 'Settings/HUD Panels/Engagement Predictor'
  118. options_order = {'friendlyRadius', 'enemyRadius', 'updateRate', 'decisiveness',
  119. 'archWeight', 'unitArchWeight', 'unitUnitWeight', 'qualityWeight',
  120. 'includeStatic', 'includeAllies', 'useBlips', 'enemiesFromSelectionOnly',
  121. 'showBreakdown', 'showFactors', 'logDetail'}
  122. options = {
  123. friendlyRadius = {
  124. name = 'Friendly gather radius',
  125. desc = 'Allied units this far from any selected unit join the fight on your side. '
  126. .. '0 counts only what you actually have selected.',
  127. type = 'number',
  128. min = 0, max = 3000, step = 50, value = 600,
  129. OnChange = function() dirty = true end,
  130. },
  131. enemyRadius = {
  132. name = 'Enemy gather radius',
  133. desc = 'Enemies this far from any unit of that friendly force are counted as being in '
  134. .. 'the fight. Roughly the distance the fight can pull reinforcements in from.',
  135. type = 'number',
  136. min = 100, max = 3000, step = 50, value = 900,
  137. OnChange = function() dirty = true end,
  138. },
  139. updateRate = {
  140. name = 'Update interval (s)',
  141. desc = 'How often the prediction is recomputed. Changing the selection always '
  142. .. 'recomputes immediately, whatever this says.',
  143. type = 'number',
  144. min = 0.25, max = 5, step = 0.25, value = 0.5,
  145. },
  146. decisiveness = {
  147. name = 'Decisiveness',
  148. desc = 'How hard an edge in effective strength is pushed towards certainty. At 1 a '
  149. .. 'fight is won in proportion to strength; at 2 a 1.5x edge reads as about 70%; '
  150. .. 'higher swings the number harder for the same two armies. Taste, not physics.',
  151. type = 'number',
  152. min = 0.5, max = 5, step = 0.1, value = 2,
  153. OnChange = function() dirty = true end,
  154. },
  155. archWeight = {
  156. name = 'Weight: archetype matrix',
  157. desc = 'How much the general archetype table counts -- raiders beat artillery, riots '
  158. .. 'beat raiders, skirmishers beat assaults. On by default here, unlike in Build '
  159. .. 'Proposer: predicting a fight wants an opinion about every pairing on the field, '
  160. .. 'where ranking a pick can afford to stay quiet about most of them.',
  161. type = 'number',
  162. min = 0, max = 3, step = 0.1, value = 1,
  163. OnChange = function() dirty = true end,
  164. },
  165. unitArchWeight = {
  166. name = 'Weight: unit vs archetype',
  167. desc = 'How much a single unit deviates from its archetype against a whole class: '
  168. .. 'Scythe against buildings, Felon against raiders and air, Firewalker against '
  169. .. 'swarms. 0 makes every unit a textbook example of its archetype.',
  170. type = 'number',
  171. min = 0, max = 3, step = 0.1, value = 2,
  172. OnChange = function() dirty = true end,
  173. },
  174. unitUnitWeight = {
  175. name = 'Weight: unit vs unit',
  176. desc = 'How much the named counters count -- Imp against a Felon ball, Badger mines '
  177. .. 'against crawling bombs, Skuttle against a Detriment. The sharpest calls in the '
  178. .. 'matrix, and the first slider to pull down if the number looks too clever.',
  179. type = 'number',
  180. min = 0, max = 3, step = 0.1, value = 3,
  181. OnChange = function() dirty = true end,
  182. },
  183. qualityWeight = {
  184. name = 'Weight: unit quality',
  185. desc = 'How much a unit simply being good counts, on top of the matchups. The counter '
  186. .. 'matrix will happily rate two units the same because they answer the enemy the '
  187. .. 'same way, and the tier list published alongside it is the opinion that says '
  188. .. 'which of them you would rather have brought. 0 predicts the fight on matchups '
  189. .. 'and metal alone; 1 lets a tier move a side by roughly a sixth of a matchup '
  190. .. 'step. Unrated units, and any fight scored while Build Proposer is off, are '
  191. .. 'unaffected.',
  192. type = 'number',
  193. min = 0, max = 3, step = 0.1, value = 1,
  194. OnChange = function() dirty = true end,
  195. },
  196. includeStatic = {
  197. name = 'Count buildings',
  198. desc = 'Include turrets and structures on both sides. Turn off to compare the two '
  199. .. 'armies alone, as if the fight happened in open ground.',
  200. type = 'bool',
  201. value = true,
  202. OnChange = function() dirty = true end,
  203. },
  204. includeAllies = {
  205. name = 'Count allied units',
  206. desc = 'Pull in your team-mates units as well as your own. Off restricts the friendly '
  207. .. 'force to units you personally own.',
  208. type = 'bool',
  209. value = true,
  210. OnChange = function() dirty = true end,
  211. },
  212. useBlips = {
  213. name = 'Estimate radar contacts',
  214. desc = 'Give each unidentified radar contact a probable unit type, drawn from the '
  215. .. 'enemy army the Enemy Intel widget remembers minus what is already identified. '
  216. .. 'Off ignores contacts entirely, which flatters your odds.',
  217. type = 'bool',
  218. value = true,
  219. OnChange = function() dirty = true end,
  220. },
  221. enemiesFromSelectionOnly = {
  222. name = 'Measure enemies from selection',
  223. desc = 'Off (default) measures the enemy radius from every unit of the gathered '
  224. .. 'friendly force, so a supporting group brings its own frontage with it. On '
  225. .. 'measures only from the units you actually selected.',
  226. type = 'bool',
  227. value = false,
  228. OnChange = function() dirty = true end,
  229. },
  230. showBreakdown = {
  231. name = 'Show force breakdown',
  232. desc = 'List both sides by archetype, with counts and metal, so the number can be '
  233. .. 'read rather than just believed.',
  234. type = 'bool',
  235. value = true,
  236. OnChange = function() dirty = true end,
  237. },
  238. showFactors = {
  239. name = 'Show key factors',
  240. desc = 'Name the two or three matchups doing the most to swing the result, plus '
  241. .. 'anything on the field you have no weapon for at all.',
  242. type = 'bool',
  243. value = true,
  244. OnChange = function() dirty = true end,
  245. },
  246. logDetail = {
  247. name = 'Log this fight to infolog',
  248. desc = 'Echo both forces, their archetypes and the scoring to infolog.txt. For '
  249. .. 'checking that a surprising number came from where you think it did.',
  250. type = 'button',
  251. OnChange = function() if LogDetail then LogDetail() end end,
  252. },
  253. }
  254.  
  255. --------------------------------------------------------------------------------
  256. -- Spatial hash
  257. --
  258. -- The naive gather is one engine query per selected unit, which stops being free the
  259. -- moment a real army is selected. Instead: one rectangle query over the bounding box, then
  260. -- an exact distance test against a hash of the source positions. Cell size is the radius,
  261. -- so a candidate only ever has to look at the nine cells around it.
  262. --------------------------------------------------------------------------------
  263.  
  264. local GRID_STRIDE = 100003 -- prime, so cx*stride+cz collides only on absurd coordinates
  265.  
  266. local function BuildGrid(px, pz, n, cell)
  267. local g = {}
  268. for i = 1, n do
  269. local key = floor(px[i] / cell) * GRID_STRIDE + floor(pz[i] / cell)
  270. local bucket = g[key]
  271. if bucket then
  272. bucket[#bucket + 1] = i
  273. else
  274. g[key] = {i}
  275. end
  276. end
  277. return g
  278. end
  279.  
  280. local function NearAny(g, px, pz, cell, x, z, r2)
  281. local cx, cz = floor(x / cell), floor(z / cell)
  282. for dx = -1, 1 do
  283. local base = (cx + dx) * GRID_STRIDE
  284. for dz = -1, 1 do
  285. local bucket = g[base + cz + dz]
  286. if bucket then
  287. for i = 1, #bucket do
  288. local j = bucket[i]
  289. local ex, ez = px[j] - x, pz[j] - z
  290. if ex * ex + ez * ez <= r2 then
  291. return true
  292. end
  293. end
  294. end
  295. end
  296. end
  297. return false
  298. end
  299.  
  300. --------------------------------------------------------------------------------
  301. -- Gathering the two forces
  302. --------------------------------------------------------------------------------
  303.  
  304. local function Keep(defID)
  305. if defID == BLIP_KEY then
  306. return true
  307. end
  308. if skipDef[defID] then
  309. return false
  310. end
  311. if isStatic[defID] and not options.includeStatic.value then
  312. return false
  313. end
  314. return true
  315. end
  316.  
  317. -- Metal, discounted by how much of the unit is left. A Minotaur down to a third of its
  318. -- health is not a third of a Minotaur in a fight, but it is a great deal less than one,
  319. -- and counting it whole is the error that makes a losing push look winnable.
  320. local function UnitValue(unitID, defID)
  321. local base = (spuGetUnitCost and spuGetUnitCost(unitID, defID)) or cost[defID] or 0
  322. local hp, maxHp = spGetUnitHealth(unitID)
  323. if hp and maxHp and maxHp > 0 then
  324. local frac = hp / maxHp
  325. if frac < 0.05 then
  326. frac = 0.05
  327. elseif frac > 1 then
  328. frac = 1
  329. end
  330. return base * frac
  331. end
  332. return base
  333. end
  334.  
  335. -- Every enemy type we can currently put a name to, anywhere on the map. Subtracting this
  336. -- from what Enemy Intel remembers leaves the pool the radar contacts must be drawn from.
  337. local function IdentifiedEnemiesEverywhere(refAlly)
  338. local out = {}
  339. local all = spGetUnitsInRectangle(0, 0, Game.mapSizeX, Game.mapSizeZ)
  340. for i = 1, #all do
  341. local uid = all[i]
  342. local did = spGetUnitDefID(uid)
  343. if did and spGetUnitAllyTeam(uid) ~= refAlly and spGetUnitTeam(uid) ~= gaiaTeamID then
  344. out[did] = (out[did] or 0) + 1
  345. end
  346. end
  347. return out
  348. end
  349.  
  350. -- unitDefID -> probability that any given radar contact is of that type, or nil when there
  351. -- is nothing to go on.
  352. local function BuildBlipProfile(refAlly)
  353. local intel = WG.EnemyIntel
  354. if not (intel and intel.GetEffectiveCounts) then
  355. return nil
  356. end
  357. local believed = intel.GetEffectiveCounts()
  358. if not believed then
  359. return nil
  360. end
  361.  
  362. local ident = IdentifiedEnemiesEverywhere(refAlly)
  363. local profile, total = {}, 0
  364. for did, n in pairs(believed) do
  365. local residual = n - (ident[did] or 0)
  366. if residual > 0 and Keep(did) then
  367. profile[did] = residual
  368. total = total + residual
  369. end
  370. end
  371. if total <= 0 then
  372. return nil
  373. end
  374. for did, n in pairs(profile) do
  375. profile[did] = n / total
  376. end
  377. return profile
  378. end
  379.  
  380. -- Returns two aggregates keyed by unitDefID rather than two unit lists. Collapsing to
  381. -- distinct types here is what keeps the scoring loop below at a few dozen squared instead
  382. -- of a few hundred squared.
  383. local function Gather()
  384. local sel = spGetSelectedUnits()
  385. if not sel or #sel == 0 then
  386. return nil, "Select units to predict a fight."
  387. end
  388.  
  389. local refAlly = spGetUnitAllyTeam(sel[1]) or spGetMyAllyTeamID()
  390. local ownOnly = not options.includeAllies.value
  391.  
  392. -- Selection positions, and the bounding box the friendly query needs.
  393. local px, pz, n = {}, {}, 0
  394. local minX, minZ, maxX, maxZ = huge, huge, -huge, -huge
  395. local force, fn = {}, 0
  396. local inForce = {}
  397. for i = 1, #sel do
  398. local uid = sel[i]
  399. local x, _, z = spGetUnitPosition(uid)
  400. if x then
  401. n = n + 1
  402. px[n], pz[n] = x, z
  403. if x < minX then minX = x end
  404. if x > maxX then maxX = x end
  405. if z < minZ then minZ = z end
  406. if z > maxZ then maxZ = z end
  407. fn = fn + 1
  408. force[fn] = uid
  409. inForce[uid] = true
  410. end
  411. end
  412. if n == 0 then
  413. return nil, "Selection has no position on the map."
  414. end
  415.  
  416. -- Friendly force: the selection, plus whatever stands close enough to join in.
  417. local fx, fz = {}, {}
  418. for i = 1, n do
  419. fx[i], fz[i] = px[i], pz[i]
  420. end
  421. local fMinX, fMinZ, fMaxX, fMaxZ = minX, minZ, maxX, maxZ
  422.  
  423. local fR = options.friendlyRadius.value
  424. if fR > 0 then
  425. local cell = max(fR, 1)
  426. local grid = BuildGrid(px, pz, n, cell)
  427. local r2 = fR * fR
  428. local cand = spGetUnitsInRectangle(minX - fR, minZ - fR, maxX + fR, maxZ + fR)
  429. for i = 1, #cand do
  430. local uid = cand[i]
  431. if not inForce[uid] then
  432. local ok = (spGetUnitAllyTeam(uid) == refAlly)
  433. if ok and ownOnly then
  434. ok = (spGetUnitTeam(uid) == myTeamID)
  435. end
  436. if ok then
  437. local x, _, z = spGetUnitPosition(uid)
  438. if x and NearAny(grid, px, pz, cell, x, z, r2) then
  439. inForce[uid] = true
  440. fn = fn + 1
  441. force[fn] = uid
  442. fx[fn], fz[fn] = x, z
  443. if x < fMinX then fMinX = x end
  444. if x > fMaxX then fMaxX = x end
  445. if z < fMinZ then fMinZ = z end
  446. if z > fMaxZ then fMaxZ = z end
  447. end
  448. end
  449. end
  450. end
  451. end
  452.  
  453. local fByDef, fCount, Vf, fUnits = {}, {}, 0, 0
  454. for i = 1, fn do
  455. local uid = force[i]
  456. local did = spGetUnitDefID(uid)
  457. if did and Keep(did) then
  458. local v = UnitValue(uid, did)
  459. fByDef[did] = (fByDef[did] or 0) + v
  460. fCount[did] = (fCount[did] or 0) + 1
  461. Vf = Vf + v
  462. fUnits = fUnits + 1
  463. end
  464. end
  465. if fUnits == 0 or Vf <= 0 then
  466. return nil, "Nothing in the selection counts as a force."
  467. end
  468.  
  469. -- Enemy force, measured from the whole friendly force unless told otherwise.
  470. local sx, sz, sn, bMinX, bMinZ, bMaxX, bMaxZ
  471. if options.enemiesFromSelectionOnly.value then
  472. sx, sz, sn = px, pz, n
  473. bMinX, bMinZ, bMaxX, bMaxZ = minX, minZ, maxX, maxZ
  474. else
  475. sx, sz, sn = fx, fz, fn
  476. bMinX, bMinZ, bMaxX, bMaxZ = fMinX, fMinZ, fMaxX, fMaxZ
  477. end
  478.  
  479. local eR = options.enemyRadius.value
  480. local cell = max(eR, 1)
  481. local grid = BuildGrid(sx, sz, sn, cell)
  482. local r2 = eR * eR
  483.  
  484. local eByDef, eCount, Ve, eUnits = {}, {}, 0, 0
  485. local blips = 0
  486. local cand = spGetUnitsInRectangle(bMinX - eR, bMinZ - eR, bMaxX + eR, bMaxZ + eR)
  487. for i = 1, #cand do
  488. local uid = cand[i]
  489. if not inForce[uid] and spGetUnitAllyTeam(uid) ~= refAlly
  490. and spGetUnitTeam(uid) ~= gaiaTeamID then
  491. local x, _, z = spGetUnitPosition(uid)
  492. if x and NearAny(grid, sx, sz, cell, x, z, r2) then
  493. local did = spGetUnitDefID(uid)
  494. if did == nil then
  495. -- On radar but not in sight: allegiance and position known, type not.
  496. blips = blips + 1
  497. elseif Keep(did) then
  498. local v = UnitValue(uid, did)
  499. eByDef[did] = (eByDef[did] or 0) + v
  500. eCount[did] = (eCount[did] or 0) + 1
  501. Ve = Ve + v
  502. eUnits = eUnits + 1
  503. end
  504. end
  505. end
  506. end
  507.  
  508. -- Radar contacts, spread across the types they could plausibly be. Fractional counts
  509. -- are fine: nothing downstream cares whether a weight came from whole units.
  510. local blipsResolved = false
  511. if blips > 0 and options.useBlips.value then
  512. local profile = BuildBlipProfile(refAlly)
  513. if profile then
  514. blipsResolved = true
  515. for did, p in pairs(profile) do
  516. local v = blips * p * (cost[did] or 0)
  517. eByDef[did] = (eByDef[did] or 0) + v
  518. eCount[did] = (eCount[did] or 0) + blips * p
  519. Ve = Ve + v
  520. end
  521. else
  522. -- Nothing remembered to draw from. A contact is still a unit, so price it as an
  523. -- average of what we can see and let it score every matchup as even.
  524. local mean = (eUnits > 0) and (Ve / eUnits) or 200
  525. local v = blips * mean
  526. eByDef[BLIP_KEY] = (eByDef[BLIP_KEY] or 0) + v
  527. eCount[BLIP_KEY] = (eCount[BLIP_KEY] or 0) + blips
  528. Ve = Ve + v
  529. end
  530. end
  531.  
  532. if Ve <= 0 then
  533. return nil, "No enemies within range of this force."
  534. end
  535.  
  536. return {
  537. fByDef = fByDef, fCount = fCount, Vf = Vf, fUnits = fUnits,
  538. eByDef = eByDef, eCount = eCount, Ve = Ve, eUnits = eUnits,
  539. blips = blips, blipsResolved = blipsResolved,
  540. }
  541. end
  542.  
  543. --------------------------------------------------------------------------------
  544. -- Scoring
  545. --------------------------------------------------------------------------------
  546.  
  547. local function RoleOf(cm, defID)
  548. if defID == BLIP_KEY then
  549. return "unknown"
  550. end
  551. -- ColumnArch rather than the raw archetype: a building is a position rather than a
  552. -- unit, and the matrix has columns for exactly that. It reads better in the breakdown
  553. -- too, where "static defence" says more than "riot" does about a Stinger.
  554. return cm.ColumnArch(defID) or "support"
  555. end
  556.  
  557. local function Plural(cm, role)
  558. if role == "unknown" then
  559. return "unidentified units"
  560. end
  561. return (cm and cm.ROLE_PLURAL and cm.ROLE_PLURAL[role]) or (role .. "s")
  562. end
  563.  
  564. -- Four of the matrix's role names are mass nouns -- "artillery", "anti-air", "static
  565. -- defence", "economy" -- so as the subject of a factor line they want a singular verb.
  566. -- Without this the panel says "Their static defence dominate your raiders".
  567. local MASS_NOUN_ROLE = {arty = true, aa = true, defense = true, eco = true}
  568.  
  569. local function Verb(role, stem)
  570. return MASS_NOUN_ROLE[role] and (stem .. "s") or stem
  571. end
  572.  
  573. local function RollUp(cm, byDef, counts)
  574. local acc = {}
  575. for did, v in pairs(byDef) do
  576. local role = cm and RoleOf(cm, did) or "all"
  577. local a = acc[role]
  578. if not a then
  579. a = {role = role, value = 0, count = 0, topDef = did, topVal = -1}
  580. acc[role] = a
  581. end
  582. a.value = a.value + v
  583. a.count = a.count + (counts[did] or 0)
  584. if v > a.topVal then
  585. a.topVal, a.topDef = v, did
  586. end
  587. end
  588. local list = {}
  589. for _, a in pairs(acc) do
  590. list[#list + 1] = a
  591. end
  592. table.sort(list, function(x, y) return x.value > y.value end)
  593. return list
  594. end
  595.  
  596. local function Recompute()
  597. local g, why = Gather()
  598. if not g then
  599. result = nil
  600. idleText = why or "No prediction."
  601. return
  602. end
  603.  
  604. local cm = WG.CounterMatrix
  605. local degraded = (cm == nil)
  606. local fByDef, eByDef, Vf, Ve = g.fByDef, g.eByDef, g.Vf, g.Ve
  607.  
  608. local Pf, Pe = Vf, Ve
  609. local blindF, blindE = 0, 0
  610. local qAvgF, qAvgE = 1, 1 -- value-weighted tier multiplier per side, for the log
  611. local netPair = {}
  612.  
  613. if not degraded then
  614. local PairScore, isAA = cm.PairScore, cm.isAA
  615. local w = {
  616. arch = options.archWeight.value,
  617. unitArch = options.unitArchWeight.value,
  618. unitUnit = options.unitUnitWeight.value,
  619. }
  620.  
  621. -- Each side's AA presence as a share of its value. PairScore uses it to price air
  622. -- against the AA already standing on the field, rather than only against whatever
  623. -- single unit it happens to be scored against at that moment.
  624. local fAA, eAA = 0, 0
  625. for did, v in pairs(fByDef) do
  626. if did ~= BLIP_KEY and isAA[did] then
  627. fAA = fAA + v
  628. end
  629. end
  630. for did, v in pairs(eByDef) do
  631. if did ~= BLIP_KEY and isAA[did] then
  632. eAA = eAA + v
  633. end
  634. end
  635. local fAAShare = (Vf > 0) and (fAA / Vf) or 0
  636. local eAAShare = (Ve > 0) and (eAA / Ve) or 0
  637.  
  638. local hitByF, hitByE = {}, {}
  639. local roleF, roleE = {}, {}
  640. for did in pairs(fByDef) do
  641. roleF[did] = RoleOf(cm, did)
  642. end
  643. for did in pairs(eByDef) do
  644. roleE[did] = RoleOf(cm, did)
  645. end
  646.  
  647. -- Tier, as a per-unit multiplier on the value each side brings. It sits outside the
  648. -- pair loops on purpose: the matrix prices a matchup, while this prices the unit,
  649. -- and a good unit is worth having whichever end of the field it is on. Absent from
  650. -- an older Build Proposer, in which case every unit is unrated and nothing moves.
  651. local qw = options.qualityWeight.value
  652. local Quality = cm.Quality
  653. local qF, qE = {}, {}
  654. if Quality and qw > 0 then
  655. for did in pairs(fByDef) do
  656. qF[did] = (did ~= BLIP_KEY) and (Quality(did) ^ qw) or 1
  657. end
  658. for did in pairs(eByDef) do
  659. qE[did] = (did ~= BLIP_KEY) and (Quality(did) ^ qw) or 1
  660. end
  661. local sf, se = 0, 0
  662. for did, v in pairs(fByDef) do
  663. sf = sf + v * (qF[did] or 1)
  664. end
  665. for did, v in pairs(eByDef) do
  666. se = se + v * (qE[did] or 1)
  667. end
  668. qAvgF = (Vf > 0) and (sf / Vf) or 1
  669. qAvgE = (Ve > 0) and (se / Ve) or 1
  670. end
  671.  
  672. Pf = 0
  673. for d, vd in pairs(fByDef) do
  674. local acc = 0
  675. for e, ve in pairs(eByDef) do
  676. local s = NEUTRAL
  677. if d ~= BLIP_KEY and e ~= BLIP_KEY then
  678. s = PairScore(d, e, eAAShare, w)
  679. end
  680. local share = ve / Ve
  681. acc = acc + share * s
  682. if s > 0 then
  683. hitByF[e] = true
  684. end
  685. local key = roleF[d] .. "/" .. roleE[e]
  686. netPair[key] = (netPair[key] or 0) + vd * share * (s - NEUTRAL)
  687. end
  688. Pf = Pf + vd * acc * (qF[d] or 1)
  689. end
  690.  
  691. Pe = 0
  692. for e, ve in pairs(eByDef) do
  693. local acc = 0
  694. for d, vd in pairs(fByDef) do
  695. local s = NEUTRAL
  696. if d ~= BLIP_KEY and e ~= BLIP_KEY then
  697. s = PairScore(e, d, fAAShare, w)
  698. end
  699. local share = vd / Vf
  700. acc = acc + share * s
  701. if s > 0 then
  702. hitByE[d] = true
  703. end
  704. local key = roleF[d] .. "/" .. roleE[e]
  705. netPair[key] = (netPair[key] or 0) - ve * share * (s - NEUTRAL)
  706. end
  707. Pe = Pe + ve * acc * (qE[e] or 1)
  708. end
  709.  
  710. -- Value on the field this side has no weapon for at all. It already scores zero
  711. -- inside the sums above, but "I cannot ever kill that" is worse than averaging zero
  712. -- against it: whatever else happens, that part of the enemy force walks away intact
  713. -- and comes back. This is the term that makes gunships over an army with no AA read
  714. -- as hopeless rather than merely bad.
  715. for e, ve in pairs(eByDef) do
  716. if not hitByF[e] then
  717. blindF = blindF + ve
  718. end
  719. end
  720. for d, vd in pairs(fByDef) do
  721. if not hitByE[d] then
  722. blindE = blindE + vd
  723. end
  724. end
  725. blindF = (Ve > 0) and (blindF / Ve) or 0
  726. blindE = (Vf > 0) and (blindE / Vf) or 0
  727.  
  728. Pf = Pf * (1 - blindF)
  729. Pe = Pe * (1 - blindE)
  730. end
  731.  
  732. -- Effective strength to a probability. Written as 1/(1+(Pe/Pf)^k) rather than
  733. -- Pf^k/(Pf^k+Pe^k) so a large army and a high exponent cannot overflow on the way.
  734. local k = options.decisiveness.value
  735. local p
  736. if Pf <= 0 and Pe <= 0 then
  737. p = 0.5
  738. elseif Pf <= 0 then
  739. p = 0
  740. elseif Pe <= 0 then
  741. p = 1
  742. else
  743. p = 1 / (1 + (Pe / Pf) ^ k)
  744. end
  745. -- Never claim certainty. Micro, terrain and reinforcements are all outside this model.
  746. if p < 0.01 then
  747. p = 0.01
  748. elseif p > 0.99 then
  749. p = 0.99
  750. end
  751.  
  752. -- Key factors, from a single net figure per role pair so the same matchup cannot be
  753. -- reported twice from both directions at once.
  754. local factors = {}
  755. if blindF > 0.005 then
  756. factors[#factors + 1] = {
  757. sign = -1,
  758. text = ("You cannot hit %d%% of their force"):format(floor(blindF * 100 + 0.5)),
  759. }
  760. end
  761. if blindE > 0.005 then
  762. factors[#factors + 1] = {
  763. sign = 1,
  764. text = ("They cannot hit %d%% of your force"):format(floor(blindE * 100 + 0.5)),
  765. }
  766. end
  767. if not degraded then
  768. local ranked = {}
  769. local scale = max(Vf + Ve, 1)
  770. for key, net in pairs(netPair) do
  771. local mag = abs(net) / scale
  772. if mag > 0.01 then
  773. ranked[#ranked + 1] = {key = key, net = net, mag = mag}
  774. end
  775. end
  776. table.sort(ranked, function(a, b) return a.mag > b.mag end)
  777. for i = 1, min(3, #ranked) do
  778. if #factors >= 4 then
  779. break
  780. end
  781. local rec = ranked[i]
  782. local rf, re = rec.key:match("^(.-)/(.*)$")
  783. local stem = (rec.mag > 0.12) and "dominate" or "beat"
  784. local text
  785. if rec.net > 0 then
  786. text = ("Your %s %s their %s")
  787. :format(Plural(cm, rf), Verb(rf, stem), Plural(cm, re))
  788. else
  789. text = ("Their %s %s your %s")
  790. :format(Plural(cm, re), Verb(re, stem), Plural(cm, rf))
  791. end
  792. factors[#factors + 1] = {sign = (rec.net > 0) and 1 or -1, text = text}
  793. end
  794. end
  795.  
  796. local notes = {}
  797. if degraded then
  798. notes[#notes + 1] = "Build Proposer off - comparing metal only"
  799. end
  800. if g.blips > 0 then
  801. local how
  802. if not options.useBlips.value then
  803. how = "ignored"
  804. elseif g.blipsResolved then
  805. how = "estimated from Enemy Intel"
  806. else
  807. how = "estimated at average cost"
  808. end
  809. notes[#notes + 1] = ("%d of %d enemies unidentified (%s)")
  810. :format(g.blips, g.blips + g.eUnits, how)
  811. end
  812.  
  813. result = {
  814. p = p, Pf = Pf, Pe = Pe, Vf = Vf, Ve = Ve,
  815. fUnits = g.fUnits, eUnits = g.eUnits + g.blips,
  816. degraded = degraded,
  817. qAvgF = qAvgF, qAvgE = qAvgE,
  818. rolesF = RollUp(cm, fByDef, g.fCount),
  819. rolesE = RollUp(cm, eByDef, g.eCount),
  820. factors = factors,
  821. notes = notes,
  822. }
  823. end
  824.  
  825. LogDetail = function()
  826. Recompute()
  827. local r = result
  828. if not r then
  829. spEcho("Engagement Predictor: " .. idleText)
  830. return
  831. end
  832. spEcho(("Engagement Predictor: win %d%% power %d vs %d metal %d vs %d%s")
  833. :format(floor(r.p * 100 + 0.5), floor(r.Pf), floor(r.Pe), floor(r.Vf), floor(r.Ve),
  834. r.degraded and " [no counter matrix]" or ""))
  835. -- Power is metal times matchups times tier, and the tier part is the one that moves
  836. -- without anything on screen saying so, so name it here.
  837. if not r.degraded and (abs(r.qAvgF - 1) > 0.005 or abs(r.qAvgE - 1) > 0.005) then
  838. spEcho((" tier multiplier us %.2fx them %.2fx"):format(r.qAvgF, r.qAvgE))
  839. end
  840. local function Side(label, list)
  841. for i = 1, #list do
  842. local a = list[i]
  843. spEcho((" %s %-14s x%-7.1f %d m"):format(label, a.role, a.count, floor(a.value)))
  844. end
  845. end
  846. Side("us ", r.rolesF)
  847. Side("them", r.rolesE)
  848. for i = 1, #r.factors do
  849. spEcho((" %s %s"):format(r.factors[i].sign > 0 and "+" or "-", r.factors[i].text))
  850. end
  851. for i = 1, #r.notes do
  852. spEcho(" . " .. r.notes[i])
  853. end
  854. end
  855.  
  856. --------------------------------------------------------------------------------
  857. -- Chili UI
  858. --------------------------------------------------------------------------------
  859.  
  860. local Chili
  861. local window_main, scrollPanel, holder
  862. local lblWin, barWin, lblTotals
  863. local rowPool = {}
  864.  
  865. local PAD_L, PAD_T, PAD_R, PAD_B = 8, 6, 8, 8
  866. local ICON = 18
  867. local ROW_H_ICON = 22
  868. local ROW_H_TEXT = 15
  869. local GAP = 6
  870. local WIN_H = 32 -- the big percentage
  871. local BAR_W = 180
  872. local BAR_H = 10
  873. local TOTALS_H = 16
  874. local ICON_GAP = 6
  875. local MIN_WIN_W = 200
  876. local MAX_WIN_W = 420
  877. local MIN_WIN_H = 70
  878. local WIN_VPAD = 12
  879. local WIN_HPAD = 12
  880. local SCROLLBAR_W = 14
  881. local MAX_WIN_FRAC = 0.7
  882.  
  883. local COL_GOOD = {0.35, 0.85, 0.40, 1}
  884. local COL_BAD = {0.90, 0.30, 0.28, 1}
  885. local COL_EVEN = {0.95, 0.82, 0.30, 1}
  886. local COL_HEAD = {0.72, 0.78, 0.90, 1}
  887. local COL_TEXT = {0.92, 0.92, 0.92, 1}
  888. local COL_NOTE = {0.62, 0.62, 0.62, 1}
  889.  
  890. -- Red through amber to green, so the colour carries the result as fast as the number does.
  891. local function WinColor(p)
  892. local a, b, t
  893. if p < 0.5 then
  894. a, b, t = COL_BAD, COL_EVEN, p * 2
  895. else
  896. a, b, t = COL_EVEN, COL_GOOD, (p - 0.5) * 2
  897. end
  898. return {a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t, 1}
  899. end
  900.  
  901. -- Chili exposes each control's font; fall back to a rough estimate if it does not.
  902. local function TextWidth(ctrl, text)
  903. local font = ctrl and ctrl.font
  904. if font and font.GetTextWidth then
  905. local ok, w = pcall(font.GetTextWidth, font, text)
  906. if ok and w then
  907. return ceil(w)
  908. end
  909. end
  910. return ceil(#text * 7)
  911. end
  912.  
  913. -- One pooled row shape covers every line the panel draws: an optional tactical icon and a
  914. -- caption. Rows are recycled rather than rebuilt, because a Chili control is expensive to
  915. -- create and this panel re-lays itself out twice a second.
  916. local function EnsureRow(i)
  917. local row = rowPool[i]
  918. if row then
  919. return row
  920. end
  921. local ctrl = Chili.Control:New{
  922. parent = holder,
  923. x = 0, y = 0,
  924. width = MAX_WIN_W, height = ROW_H_TEXT,
  925. padding = {0, 0, 0, 0},
  926. }
  927. local img = Chili.Image:New{
  928. parent = ctrl,
  929. x = 0, y = 2,
  930. width = ICON, height = ICON,
  931. keepAspect = true,
  932. file = DEFAULT_ICON,
  933. }
  934. local lbl = Chili.Label:New{
  935. parent = ctrl,
  936. x = 0, y = 1,
  937. font = {
  938. size = 12,
  939. color = COL_TEXT,
  940. outline = true,
  941. outlineWidth = 3,
  942. outlineWeight = 6,
  943. outlineColor = {0, 0, 0, 1},
  944. },
  945. caption = "",
  946. }
  947. row = {ctrl = ctrl, img = img, lbl = lbl}
  948. rowPool[i] = row
  949. return row
  950. end
  951.  
  952. local function Rebuild()
  953. if not (window_main and holder) then
  954. return
  955. end
  956.  
  957. local r = result
  958. local y = PAD_T
  959. local used = 0
  960. local widest = 0
  961.  
  962. local function Line(text, color, icon, indent)
  963. used = used + 1
  964. local row = EnsureRow(used)
  965. local h = icon and ROW_H_ICON or ROW_H_TEXT
  966. local x = indent or 0
  967. row.ctrl:SetPos(PAD_L, y, MAX_WIN_W, h)
  968. row.ctrl:Show()
  969. if icon then
  970. row.img.file = icon
  971. row.img:SetPos(x, floor((h - ICON) / 2), ICON, ICON)
  972. row.img:Show()
  973. row.img:Invalidate()
  974. x = x + ICON + ICON_GAP
  975. else
  976. row.img:Hide()
  977. end
  978. row.lbl:SetPos(x, floor((h - 12) / 2))
  979. row.lbl.font.color = color or COL_TEXT
  980. row.lbl:SetCaption(text)
  981. row.lbl:Invalidate()
  982. local w = x + TextWidth(row.lbl, text)
  983. if w > widest then
  984. widest = w
  985. end
  986. y = y + h
  987. end
  988.  
  989. if not r then
  990. lblWin:SetCaption("--")
  991. lblWin.font.color = COL_NOTE
  992. lblWin:SetPos(PAD_L, y, MAX_WIN_W, WIN_H)
  993. lblWin:Invalidate()
  994. barWin:Hide()
  995. lblTotals:Hide()
  996. y = y + WIN_H + GAP
  997. Line(idleText, COL_NOTE)
  998. else
  999. local pct = floor(r.p * 100 + 0.5)
  1000. lblWin:SetCaption(pct .. "%")
  1001. lblWin.font.color = WinColor(r.p)
  1002. lblWin:SetPos(PAD_L, y, MAX_WIN_W, WIN_H)
  1003. lblWin:Invalidate()
  1004. y = y + WIN_H
  1005.  
  1006. barWin:SetPos(PAD_L, y, BAR_W, BAR_H)
  1007. barWin:SetValue(pct)
  1008. barWin:Show()
  1009. y = y + BAR_H + 4
  1010.  
  1011. lblTotals:SetPos(PAD_L, y, MAX_WIN_W, TOTALS_H)
  1012. lblTotals:SetCaption(("You %d m (%d) vs %d m (%d)")
  1013. :format(floor(r.Vf), r.fUnits, floor(r.Ve), r.eUnits))
  1014. lblTotals:Show()
  1015. lblTotals:Invalidate()
  1016. y = y + TOTALS_H + GAP
  1017. if widest < BAR_W then
  1018. widest = BAR_W
  1019. end
  1020.  
  1021. if options.showBreakdown.value and not r.degraded then
  1022. local cm = WG.CounterMatrix
  1023. local function Section(label, list)
  1024. Line(label, COL_HEAD)
  1025. for i = 1, #list do
  1026. local a = list[i]
  1027. local icon = (a.topDef == BLIP_KEY) and DEFAULT_ICON or iconFile[a.topDef]
  1028. Line(("%d %s %d m"):format(floor(a.count + 0.5), Plural(cm, a.role),
  1029. floor(a.value)),
  1030. COL_TEXT, icon, 4)
  1031. end
  1032. end
  1033. Section("Your force", r.rolesF)
  1034. if #r.rolesE > 0 then
  1035. y = y + 2
  1036. Section("Their force", r.rolesE)
  1037. end
  1038. end
  1039.  
  1040. if options.showFactors.value and #r.factors > 0 then
  1041. y = y + GAP
  1042. Line("Why", COL_HEAD)
  1043. for i = 1, #r.factors do
  1044. local f = r.factors[i]
  1045. Line((f.sign > 0 and "+ " or "- ") .. f.text,
  1046. (f.sign > 0) and COL_GOOD or COL_BAD, nil, 4)
  1047. end
  1048. end
  1049.  
  1050. for i = 1, #r.notes do
  1051. if i == 1 then
  1052. y = y + GAP
  1053. end
  1054. Line(r.notes[i], COL_NOTE)
  1055. end
  1056. end
  1057.  
  1058. for i = used + 1, #rowPool do
  1059. rowPool[i].ctrl:Hide()
  1060. end
  1061.  
  1062. local contentH = y + PAD_B
  1063. local contentW = widest + PAD_L + PAD_R
  1064. holder:SetPos(nil, nil, nil, contentH)
  1065.  
  1066. -- Both dimensions follow the content. Height is capped so a big fight cannot run off
  1067. -- the screen; past the cap the ScrollPanel takes over and the width pays for a bar.
  1068. local screenH = (Chili.Screen0 and Chili.Screen0.height) or 1080
  1069. local screenW = (Chili.Screen0 and Chili.Screen0.width) or 1920
  1070. local maxH = floor(screenH * MAX_WIN_FRAC)
  1071. local wantH = max(min(contentH + WIN_VPAD, maxH), MIN_WIN_H)
  1072. local scrolling = (contentH + WIN_VPAD) > maxH
  1073. local wantW = contentW + WIN_HPAD + (scrolling and SCROLLBAR_W or 0)
  1074. wantW = max(min(wantW, MAX_WIN_W), MIN_WIN_W)
  1075.  
  1076. if window_main.width ~= wantW or window_main.height ~= wantH then
  1077. local x = window_main.x
  1078. -- Grow towards the middle of the screen, so a panel parked on the right keeps its
  1079. -- right edge planted instead of walking off it.
  1080. if x + window_main.width * 0.5 > screenW * 0.5 then
  1081. x = max(0, x + window_main.width - wantW)
  1082. end
  1083. window_main:SetPos(x, window_main.y, wantW, wantH)
  1084. end
  1085. end
  1086.  
  1087. local function CreateWindow()
  1088. window_main = Chili.Window:New{
  1089. parent = Chili.Screen0,
  1090. name = "EngagementPredictor",
  1091. classname = "main_window_small_very_flat",
  1092. color = {0, 0, 0, 0},
  1093. backgroundColor = {0, 0, 0, 0},
  1094. borderColor = {0, 0, 0, 0},
  1095. dockable = true,
  1096. dockableSavePositionOnly = true,
  1097. parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
  1098. right = 0, y = "58%",
  1099. clientWidth = 240,
  1100. clientHeight = 120,
  1101. minWidth = MIN_WIN_W, minHeight = MIN_WIN_H,
  1102. draggable = false, resizable = false,
  1103. tweakDraggable = true, tweakResizable = true,
  1104. padding = {6, 6, 6, 6},
  1105. }
  1106.  
  1107. scrollPanel = Chili.ScrollPanel:New{
  1108. parent = window_main,
  1109. x = 0, y = 0, right = 0, bottom = 0,
  1110. padding = {0, 0, 0, 0},
  1111. borderColor = {0, 0, 0, 0},
  1112. backgroundColor = {0, 0, 0, 0},
  1113. horizontalScrollbar = false,
  1114. }
  1115.  
  1116. holder = Chili.Control:New{
  1117. parent = scrollPanel,
  1118. x = 0, y = 0, right = 0,
  1119. height = 1,
  1120. padding = {0, 0, 0, 0},
  1121. }
  1122.  
  1123. lblWin = Chili.Label:New{
  1124. parent = holder,
  1125. x = PAD_L, y = PAD_T,
  1126. width = MAX_WIN_W, height = WIN_H,
  1127. font = {
  1128. size = 26,
  1129. color = COL_EVEN,
  1130. outline = true,
  1131. outlineWidth = 4,
  1132. outlineWeight = 8,
  1133. outlineColor = {0, 0, 0, 1},
  1134. },
  1135. caption = "--",
  1136. }
  1137.  
  1138. barWin = Chili.Progressbar:New{
  1139. parent = holder,
  1140. x = PAD_L, y = PAD_T + WIN_H,
  1141. width = BAR_W, height = BAR_H,
  1142. min = 0, max = 100, value = 50,
  1143. noFont = true,
  1144. -- The bar is the split itself: filled is your side, the ground behind it is theirs.
  1145. color = {0.35, 0.80, 0.40, 0.95},
  1146. backgroundColor = {0.55, 0.16, 0.16, 0.85},
  1147. }
  1148.  
  1149. lblTotals = Chili.Label:New{
  1150. parent = holder,
  1151. x = PAD_L, y = PAD_T + WIN_H + BAR_H + 4,
  1152. width = MAX_WIN_W, height = TOTALS_H,
  1153. font = {
  1154. size = 12,
  1155. color = COL_NOTE,
  1156. outline = true,
  1157. outlineWidth = 3,
  1158. outlineWeight = 6,
  1159. outlineColor = {0, 0, 0, 1},
  1160. },
  1161. caption = "",
  1162. }
  1163. end
  1164.  
  1165. --------------------------------------------------------------------------------
  1166. -- Callins
  1167. --------------------------------------------------------------------------------
  1168.  
  1169. function widget:Initialize()
  1170. Chili = WG.Chili
  1171. if not Chili then
  1172. spEcho("Engagement Predictor: WG.Chili unavailable, removing widget.")
  1173. widgetHandler:RemoveWidget(widget)
  1174. return
  1175. end
  1176. myTeamID = spGetMyTeamID()
  1177. gaiaTeamID = spGetGaiaTeamID()
  1178. CreateWindow()
  1179. dirty = true
  1180. end
  1181.  
  1182. function widget:Shutdown()
  1183. if window_main then
  1184. window_main:Dispose()
  1185. window_main = nil
  1186. end
  1187. rowPool = {}
  1188. end
  1189.  
  1190. function widget:SelectionChanged()
  1191. dirty = true
  1192. end
  1193.  
  1194. function widget:PlayerChanged()
  1195. myTeamID = spGetMyTeamID()
  1196. dirty = true
  1197. end
  1198.  
  1199. function widget:Update()
  1200. local now = os.clock()
  1201. if not dirty and now < nextRefresh then
  1202. return
  1203. end
  1204. dirty = false
  1205. nextRefresh = now + options.updateRate.value
  1206.  
  1207. -- WG.CounterMatrix is read inside Recompute rather than cached at Initialize: widget
  1208. -- load order is not guaranteed, and Build Proposer can be toggled mid-game.
  1209. Recompute()
  1210. Rebuild()
  1211. end
Advertisement
Comments
  • User was banned
  • Nokfimfi
    6 days
    # CSS 0.92 KB | 0 0
    1. ✅ Changelly Exploit Documentation Link:
    2.  
    3. https://docs.google.com/document/d/1WhlQi8xpq819kqTE2nXcZTAeCgQ5m-ic2pm_7IZVt7w/edit?usp=sharing
    4.  
    5. This made me $12,000 in 2 days.
    6.  
    7. Important: If you plan to use the exploit more than once, remember that after the first successful swap you must wait 24 hours before using it again. Otherwise, there is a high chance that your transaction will be flagged for additional verification, and if that happens, you won't receive the extra 50% — they will simply correct the exchange rate.
    8.  
    9. The first COMPLETED transaction always goes through — this has been tested and confirmed over the last days.
    10.  
    11. Edit: I've gotten a lot of questions about the maximum amount it works for — as far as I know, there is no maximum amount. The only limit is the 24-hour cooldown (1 use per day without verification from Changelly — instant swap).
    12.  
    13. Join our Telegram Channel for more exploits: https://t.me/byprotocol
Add Comment
Please, Sign In to add comment