Skip to content

feat: scripted unit xp upgrades#7531

Draft
efrec wants to merge 42 commits into
beyond-all-reason:masterfrom
efrec:feat/unit-veterancies
Draft

feat: scripted unit xp upgrades#7531
efrec wants to merge 42 commits into
beyond-all-reason:masterfrom
efrec:feat/unit-veterancies

Conversation

@efrec

@efrec efrec commented Apr 25, 2026

Copy link
Copy Markdown
Collaborator

This is step one toward creating new veterancies at rank-up, rather than continuously at each XP gain, by scripting the new wanted effects and adding a measure of support for unit script behaviors. GDD

  1. Game-side experience bonuses. Replace engine XP with game XP. Make it tweakable and (chiefly) easy to balance test before/after. Minimize balance impact.
  2. Game-side rank bonuses. Replace continuous XP with upgrades on rank-up. This has a large balance component.
  3. Unify unit attribute modifiers. We will have codified sources for each modifier, tied to each rank and effect, so can track concrete effects in unit_attributes instead of handling them dynamically (as continuous XP would require us to do).

Work done

  • Replaces engine XP bonuses with game-side bonuses. This is reversible via modoption (veterancy_upgrades = 0) to allow for balance A/B tests without jumping versions.
  • Adds scaling for autoheal and damage on XP gain.
  • Adds scaling for script-based weapon reload timers on XP gain.
  • Rework the GUI displays for scaling unit stats.

As part of the above:

  • The Whistler and Lasher gain reload time reduction from XP. Previously, this did not work.
  • Other units with a restore_delay equal to their maximum reload time may need script changes similar to Whistler/Lasher.

Future considerations

There are a lot of TODOs throughout the unit_veterancy_upgrades file. Surfacing a few here:

  • We cannot script unit power from Lua.
  • We need to review unit scripts for animation timers based on the max reload time.
  • Weapon-less weapondefs (submunitions e.g. clusters) are not scaled in the damages veterancy.
  • Impulse and cratering scale with damage in the damages veterancy. Maybe unwanted.
  • Very few weapons scale their vfx with damage for the damages veterancy to be visible to players.
  • The range veterancy upgrade does not compensate with increases to TTL, projectile speed, etc.
  • We cannot modify: predictSpeedMod, leadLimit, targetMoveError, movingAccuracy, wobble. This limits what we can do in the acc_weight veterancy.

Testing

Heatrays and the Beamer can gain +damage% instead of -reload%, which works well. There are other units with a constant rate of fire, also, like Razorbacks that could gain a similar bonus.

Damage increase is an interesting tradeoff with reload time decrease:
image

This is performant (too optimized for the need) and will be even lighter when upgrades are changed to be on rank-up. Here's a thousand units firing heat rays at each other:
image

@github-actions

github-actions Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Integration Test Results

15 tests  ±0   7 ✅ ±0   3s ⏱️ ±0s
 1 suites ±0   8 💤 ±0 
 1 files   ±0   0 ❌ ±0 

Results for commit 4394276. ± Comparison against base commit ddff6cd.

♻️ This comment has been updated with latest results.

@efrec efrec marked this pull request as ready for review April 25, 2026 21:05
@efrec efrec marked this pull request as draft April 26, 2026 07:42
@efrec efrec force-pushed the feat/unit-veterancies branch 2 times, most recently from 77aba43 to 571ca74 Compare April 26, 2026 19:42
@efrec

efrec commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator Author

UI changes for +damage% alongside -reload%:
image

@efrec efrec marked this pull request as ready for review April 27, 2026 04:34
@efrec efrec force-pushed the feat/unit-veterancies branch 3 times, most recently from 1fd21e1 to 4a21c3c Compare April 29, 2026 11:52
efrec added 3 commits April 29, 2026 18:17
- This modifies the modrules to disable engine-based xp bonuses.
- It does not yet replace xp bonuses with rank-based bonuses.
- This replaces the armmav/gunslinger's +range gadget also.
@efrec efrec force-pushed the feat/unit-veterancies branch from 4a21c3c to e56c4cb Compare April 30, 2026 21:40
Comment thread luarules/gadgets/unit_veterancy_upgrades.lua Outdated
Comment thread luarules/gadgets/unit_veterancy_upgrades.lua
Comment thread luarules/gadgets/unit_veterancy_upgrades.lua Outdated
Comment thread luarules/gadgets/unit_veterancy_upgrades.lua Outdated
@efrec

efrec commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

In addition to game-side XP upgrade, we may want to track unit XP ourselves. That depends on what we want out of the finished veterancy system.

We currently use engine-default XP. So:

  • We don't gate XP gains behind line of sight, radar, etc.
  • We don't cap the XP gains from a single shot.
  • We don't grant XP for last-hitting on kills (and we track "kills" in a couple ways that disagree across the repo).
  • We don't ignore damage to neutral units.
  • We do ignore overkill damage, damage to units in death anims (assumed to be overkill), (non-paralysis) damage to crashing aircraft, and collision and crushing damages.
  • We use the engine's power->xp ratio (0.1 * powerUnit/powerAttacker).
  • +health% from unit XP decreases the XP gain rate of the unit's attackers.
  • More quirks besides.

Which can produce odd results:

  • Blind-firing over a hill produces battle-hardened Tremors.
  • Shooting a newbie Tremor produces more XP than the veteran ones.
  • High-range units and high-burst units gain high XP.
  • At the same time, a Tick hits max rank in just a few shots on an AFUS.

Though these are mostly minor effects.

efrec added 4 commits May 11, 2026 21:57
This is barely functional as-is. I don't know that I value including it.

- First, we cannot tune many of the values scaled via ownerExpAccWeight: predictSpeedMod, targetMoveError, movingAccuracy, wobble.
- Second, BAR balances many units' "accuracy" via predictBoost and leadLimit, making them ultra-accurate against immobile targets with speed-based inaccuracy vs moving ones.
- Third, we probably think that ownerExpAccWeight does not scale sprayAngle, because that is what the docs say, but CWeapon::SprayAngleExperience disagrees.
@efrec efrec force-pushed the feat/unit-veterancies branch from e4c72a4 to ec84982 Compare May 17, 2026 19:30
@efrec

efrec commented May 17, 2026

Copy link
Copy Markdown
Collaborator Author

I added test scaling options, reload_then_burst and reload_then_damage, to handle cases where reload time < burst duration after XP gains.

Without this, the Razorback's 0.6s reload can't gain the full XP bonus (-50% ish) because its burst duration is 0.4s. With it, damage can continue to scale with XP:

image

Comment thread luarules/gadgets/unit_veterancy_upgrades.lua Outdated
Comment thread luarules/gadgets/unit_veterancy_upgrades.lua Outdated
Comment thread luarules/gadgets/unit_veterancy_upgrades.lua Outdated
Comment thread luarules/gadgets/unit_veterancy_upgrades.lua Outdated
Comment thread scripts/Units/cormist.bos
FireWeapon1()
{
start-script ResetFire();
start-script ScriptReload();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This (and FireWeapon2 below) calls ScriptReload without a weapon number, and ScriptReload only seems to sleep for weapon 1 or 2 - so wouldn't it skip both and not wait when nothing's passed? armsam passes the number, so I'm guessing cormist just didn't get the same change. The ResetAiming() up in AimWeapon1 looks like it might be the same thing.

Comment thread scripts/Units/armsam.bos
piece flare1, flare2, base, turret, lwheel,rwheel, mlauncher;

static-var restore_delay, gun_1, fired, aiming;
static-var reload_time_max, reload_time_1, reload_time_2, gun_1, fired, aiming;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

From what I can tell these only get set once the unit earns XP (through the SetReloadTime/SetMaxReloadTime calls) - so wouldn't a freshly built unit have them at 0 and end up sleeping for 0 in ScriptReload/ResetAiming/RestoreAfterDelay until its first XP? Might be worth setting them in Create()? (cormist too.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not sure how this will resolve, with #7961 handling attributes being sent to scripts, but veterancies needing it also.

Comment thread luarules/gadgets/unit_veterancy_upgrades.lua Outdated
Comment thread luaui/Widgets/gui_attackrange_gl4.lua Outdated
d[i] = math_round(damages[i] * damageMult)
end
spSetUnitWeaponDamages(unitID, weaponNum, d)
spSetUnitRulesParam(unitID, "veterancy_damages_multiplier", damageMult)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This value looks like it's stored per-unit but gets written once for each weapon, so wouldn't whichever weapon is last be the one that sticks? Probably fine for plain damages since every weapon scales the same, but reload_then_damages looks like it can give each weapon a different amount - and the stats panels seem to read this one value for all of them, so I'd expect them to show the wrong number for every weapon except the last.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

True, these will need unique handling per-weapon. Will do.

The gui for handling these stats is in bad shape. I might have been hoping that the contradictions in it become so strong that we have no choice but to start replacing it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah no possibility to keep a lot of the gui code. I won't have time for that during daylight-hours, will address it.

Comment thread luarules/gadgets/unit_veterancy_upgrades.lua
-- Without this, many XP gains may be too small to reach g:UnitExperience.
-- We still do not capture some updates, e.g. nuclear explosions vs walls.
-- TODO: Move this into the game setup? Or something? Why in a gadget?
Spring.SetExperienceGrade(0.01)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This runs before the check below that can remove the gadget - so if nothing ends up using veterancy, wouldn't the gadget pull itself out after it's already changed the game-wide XP setting? Might be better after that check (matches your "why in a gadget" note).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I wanted to make the setting independent. Experience grading is a general behavior that shouldn't depend on this gadget. It doesn't really belong in the file at all, then, but there was no good existing place for it.

I did not know if we would create a gadget for game-side XP tracking, either. If we do, then that is an okay place. It is still odd to include any game configuration in any gadget file imo.

add = function(unitDef, upgrades)
-- Shares its scaling customparams with `reload`/`damage`, but does not check `damageScale`:
local unitReloadScale = getScale(unitDef, "reload", reloadScale)
local unitDamageScale = getScale(unitDef, "damage", unitReloadScale)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case I think I'm seeing: if a unit turns on damage scaling but leaves reload scaling at 0, would a weapon whose reload is longer than its burst actually get the damage bump? The part that raises damage looks like it only runs once the reload's been pulled down to the burst length, which doesn't seem possible with reload scaling off - and the config still seems to get accepted, so it might just quietly do nothing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Maybe a downgrade path for reload_then_xyz => xyz would be good for this. I don't mind saying "Configure it correctly" to our unit designers but modders will want something that is more robust.

Comment thread luaui/Widgets/gui_unit_stats.lua Outdated
efrec and others added 13 commits June 19, 2026 07:31
Not in the "global collectors" sense so it's another mini-pattern in a file that is doomed to be full of mini-patterns. But moving away from the toy code for the demo, we need to get this much work done, and then address how patternless we are.

I thought of this earlier as being a "config table", just a name for explicit construction in a declared way, with "mixins", just a name for reused code that is no longer a helper/util but is driving the overall pattern/design. And the real consumer of that data is applyVeterancyEffects, which needs an abstract upgrade to apply.

There is still some naming tension there, applyVeterancyEffects instead of applyVeterancyUprade.

The organization is now a bit worse. Leaving a note in the commit msg to shame myself into organizing more, I guess.
I wish I had the definitions at the top but Lua is a little hostile to organizing things that way due to lexical scoping. Something to ponder maybe but nbd.
We have a dozen or so engine tickets to split off from this (maybe once we confirm more of the spec for the veterancy rework)
@efrec efrec marked this pull request as draft June 24, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants