mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 15:29:26 +00:00
balance(doomsday): floor the drain at 5% of max instead of wiping to zero (#4587)
## Description: The Doomsday Clock drain wiped a caught side's troops (and warship health) to **zero**, an effective death sentence, so a brief dip below the bar was unrecoverable. Floor the drain at **5% of each max** (`drainFloorPercent`) for both troops and warships: a doomed side is crippled, not eliminated, and recovers if it climbs back above the bar. The rising territory bar still forces a finish (a 5%-strength side is trivial to conquer, and the leader is drain-exempt). - Config: `drainFloorPercent` (5), applied to both drains. - Execution: clamp troop + warship-health removal to what sits above the floor (integer-only, deterministic). - HUD: the Collapsing rate is shown net of the floor. - Tests: drain-to-zero / warship-scuttle cases updated to the floor + added coverage. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory
This commit is contained in:
@@ -138,15 +138,18 @@ export class DoomsdayClockPanel extends LitElement {
|
||||
let statusClass = "";
|
||||
let detail = zoneDetail;
|
||||
if (live && draining && me) {
|
||||
// Drain is a % of max-troop capacity, capped at current troops; show the
|
||||
// actual per-second loss (renderTroops handles the /10 display unit).
|
||||
// Drain is a % of max-troop capacity that stops at the floor
|
||||
// (drainFloorPercent of max); show the actual per-second loss, i.e. only
|
||||
// what sits above the floor (renderTroops handles the /10 display unit).
|
||||
const maxTroops = this.game.config().maxTroops(me);
|
||||
const floor = Math.floor((maxTroops * sd.drainFloorPercent) / 100);
|
||||
const chunk = doomsdayClockDrain(
|
||||
this.game.config().maxTroops(me),
|
||||
maxTroops,
|
||||
secondsUnder - sd.warnSeconds,
|
||||
sd,
|
||||
);
|
||||
status = translateText("doomsday_clock.collapsing", {
|
||||
rate: renderTroops(Math.min(me.troops(), chunk)),
|
||||
rate: renderTroops(Math.max(0, Math.min(me.troops() - floor, chunk))),
|
||||
});
|
||||
statusClass = "text-red-400 font-bold";
|
||||
} else if (live && flagged) {
|
||||
|
||||
@@ -84,21 +84,27 @@ export const SAM_CONSTRUCTION_TICKS = 30 * 10;
|
||||
// Doomsday Clock tunables (anti-stall). Off unless enabled in GameConfig.
|
||||
// Times in seconds. The required map share rises in waves (levels + times in
|
||||
// DoomsdayClock.ts, chosen by `speed`). A side caught below the bar gets a
|
||||
// warnSeconds cooldown ("Danger, decay in Xs"), then troops bleed to zero: the
|
||||
// warn (30s) + the linear drain (~90s from full troops, sooner with fewer troops
|
||||
// or a shrinking territory) make ~2 minutes from caught to wiped out.
|
||||
// warnSeconds cooldown ("Danger, decay in Xs"), then troops bleed DOWN TO A
|
||||
// FLOOR (drainFloorPercent of max), not to zero: the warn (30s) + the linear
|
||||
// drain (~90s from full troops, sooner with fewer troops or a shrinking
|
||||
// territory) make ~2 minutes from caught to the floor. A doomed side is crippled
|
||||
// to 5% of max, not eliminated, so a brief dip below the bar is recoverable (the
|
||||
// drain stops the moment it climbs back); the rising bar still guarantees a
|
||||
// finish by squeezing territory and leaving the doomed side easy to conquer.
|
||||
const DOOMSDAY_CLOCK_DEFAULTS = {
|
||||
enabled: false,
|
||||
speed: "normal" as DoomsdayClockSpeed,
|
||||
warnSeconds: 30, // cooldown (the flashing danger cue) before decay begins
|
||||
drainStartPercent: 2, // starts bleeding at once (already beats troop income)
|
||||
drainMaxPercent: 5,
|
||||
drainRampSeconds: 90, // ramps LINEARLY to the max over this long (~1:30 to zero)
|
||||
drainRampSeconds: 90, // ramps LINEARLY to the max over this long
|
||||
drainFloorPercent: 5, // drain stops here: crippled to 5% of max, never wiped
|
||||
// Warships bleed on their OWN gentler start + a STEEP (convex) ramp to a much
|
||||
// higher ceiling. A ship caught when its side is first doomed lasts about as
|
||||
// long as troops (the low start + no income ≈ the troop net rate), but the rate
|
||||
// curves up sharply (warshipDrainCurveExponent), so once a side has been under
|
||||
// the clock the full ramp, ships sink in ~2s (50%/s). Ships only.
|
||||
// the clock the full ramp, ships drop to the same floor in ~2s (50%/s), not
|
||||
// sunk. Ships only.
|
||||
warshipDrainStartPercent: 1,
|
||||
warshipDrainMaxPercent: 50,
|
||||
warshipDrainCurveExponent: 8, // >1 = convex: stays gentle early, then spikes
|
||||
@@ -138,6 +144,7 @@ export class Config {
|
||||
drainStartPercent: d.drainStartPercent,
|
||||
drainMaxPercent: d.drainMaxPercent,
|
||||
drainRampSeconds: d.drainRampSeconds,
|
||||
drainFloorPercent: d.drainFloorPercent,
|
||||
warshipDrainStartPercent: d.warshipDrainStartPercent,
|
||||
warshipDrainMaxPercent: d.warshipDrainMaxPercent,
|
||||
warshipDrainCurveExponent: d.warshipDrainCurveExponent,
|
||||
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
*
|
||||
* A side below the bar is marked (inDoomsdayClock -> blinking skull on the client)
|
||||
* and, after the warn window, every member bleeds an escalating percentage of
|
||||
* their troops until the side recovers or hits zero. Climbing back above the bar
|
||||
* clears the mark and stops the drain.
|
||||
* their troops (and warship health) until the side recovers or reaches the floor
|
||||
* (drainFloorPercent of each max). The drain cripples, it does not wipe: a doomed
|
||||
* side settles at 5% of max, not zero, so a brief dip below the bar is
|
||||
* recoverable. Climbing back above the bar clears the mark and stops the drain;
|
||||
* the rising bar still forces a finish by squeezing territory.
|
||||
*
|
||||
* Deterministic: integer-only. The threshold is one floored integer ratio (see
|
||||
* DoomsdayClock.ts) and the drain a floored percentage, no floating-point. Off
|
||||
@@ -76,9 +79,9 @@ export class DoomsdayClockExecution implements Execution {
|
||||
// The leading side (the crown holder in FFA, the top team otherwise) is
|
||||
// never doomed. Doomsday Clock culls the challengers toward the leader, so the
|
||||
// leader always keeps its army: the game can never freeze with every
|
||||
// remaining side bled to zero, and the final wave squeezes out everyone but
|
||||
// the leader -> a single winner. First side with the most tiles wins ties
|
||||
// (deterministic: sides are built in a fixed order).
|
||||
// remaining side crippled at the floor, and the final wave squeezes out
|
||||
// everyone but the leader -> a single winner. First side with the most tiles
|
||||
// wins ties (deterministic: sides are built in a fixed order).
|
||||
const sideTiles = sides.map((members) =>
|
||||
members.reduce((sum, m) => sum + m.numTilesOwned(), 0),
|
||||
);
|
||||
@@ -105,28 +108,38 @@ export class DoomsdayClockExecution implements Execution {
|
||||
const secondsUnder = Math.floor(m.doomsdayClockTicks() / 10);
|
||||
if (secondsUnder >= cfg.warnSeconds) {
|
||||
const secondsPastWarn = secondsUnder - cfg.warnSeconds;
|
||||
const chunk = doomsdayClockDrain(
|
||||
mg.config().maxTroops(m),
|
||||
secondsPastWarn,
|
||||
cfg,
|
||||
// The drain floors at drainFloorPercent of each max: a doomed side is
|
||||
// crippled to 5%, not wiped to zero, so recovery is possible if it
|
||||
// climbs back above the bar. Clamp the removal to what sits above the
|
||||
// floor (integer-only, so the lockstep sim stays deterministic).
|
||||
const maxTroops = mg.config().maxTroops(m);
|
||||
const troopFloor = Math.floor(
|
||||
(maxTroops * cfg.drainFloorPercent) / 100,
|
||||
);
|
||||
const chunk = doomsdayClockDrain(maxTroops, secondsPastWarn, cfg);
|
||||
m.removeTroops(
|
||||
Math.min(chunk, Math.max(0, m.troops() - troopFloor)),
|
||||
);
|
||||
m.removeTroops(chunk); // caps at current troops
|
||||
// The navy bleeds on the same ramp but toward warshipDrainCfg's far
|
||||
// higher ceiling (see above), so a doomed side's fleet is scuttled
|
||||
// fast at full attrition. A percentage of each warship's (veterancy-
|
||||
// adjusted) max health; passing no attacker makes each destruction
|
||||
// environmental, never a credited kill (see UnitImpl.delete). Healing
|
||||
// is suppressed for flagged owners in WarshipExecution.healWarship so
|
||||
// the decay actually lands.
|
||||
// higher ceiling (see above), so a doomed side's fleet is battered
|
||||
// fast at full attrition, down to the SAME floor (drainFloorPercent of
|
||||
// each warship's veterancy-adjusted max health) rather than sunk. No
|
||||
// attacker is passed, so any loss is environmental, never a credited
|
||||
// kill (see UnitImpl.delete). Healing is suppressed for flagged owners
|
||||
// in WarshipExecution.healWarship so the decay actually lands.
|
||||
for (const ws of m.units(UnitType.Warship)) {
|
||||
ws.modifyHealth(
|
||||
-doomsdayClockDrain(
|
||||
ws.maxHealth(),
|
||||
secondsPastWarn,
|
||||
warshipDrainCfg,
|
||||
cfg.warshipDrainCurveExponent,
|
||||
),
|
||||
const shipFloor = Math.floor(
|
||||
(ws.maxHealth() * cfg.drainFloorPercent) / 100,
|
||||
);
|
||||
const removable = Math.max(0, ws.health() - shipFloor);
|
||||
if (removable <= 0) continue;
|
||||
const dmg = doomsdayClockDrain(
|
||||
ws.maxHealth(),
|
||||
secondsPastWarn,
|
||||
warshipDrainCfg,
|
||||
cfg.warshipDrainCurveExponent,
|
||||
);
|
||||
ws.modifyHealth(-Math.min(dmg, removable));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ function sdConfig(over: Partial<SDConfig> = {}): SDConfig {
|
||||
drainStartPercent: 10,
|
||||
drainMaxPercent: 80,
|
||||
drainRampSeconds: 3,
|
||||
drainFloorPercent: 5, // drain stops at 5% of max (matches production default)
|
||||
warshipDrainStartPercent: 10, // fixture: same start as troops (linear curve below)
|
||||
warshipDrainMaxPercent: 100, // ships ramp to a higher ceiling than troops
|
||||
warshipDrainCurveExponent: 1, // fixture uses a linear ramp for exact numbers
|
||||
@@ -234,13 +235,18 @@ describe("DoomsdayClockExecution (logic)", () => {
|
||||
expect(b.troops()).toBe(570);
|
||||
});
|
||||
|
||||
it("drains an unrecovered player all the way to zero", () => {
|
||||
it("drains an unrecovered player down to the 5% floor, never zero", () => {
|
||||
// b (maxTroops 1000) is caught and never recovers. The drain cripples it to
|
||||
// drainFloorPercent of max (5% of 1000 = 50) and stops there, not at zero.
|
||||
const { game, b } = twoPlayerGame(400, 50);
|
||||
const exec = makeExec(game);
|
||||
for (let t = WAVE_TICK; t <= WAVE_TICK + 1000; t += 10)
|
||||
runAt(exec, game, t);
|
||||
expect(b.troops()).toBe(0);
|
||||
expect(b.troops()).toBe(50); // 5% floor, not wiped
|
||||
expect(b.inDoomsdayClock()).toBe(true);
|
||||
// Holds at the floor on further ticks (no drift below it).
|
||||
runAt(exec, game, WAVE_TICK + 1010);
|
||||
expect(b.troops()).toBe(50);
|
||||
});
|
||||
|
||||
it("clears the mark and stops draining when a player climbs back above the bar", () => {
|
||||
@@ -319,8 +325,8 @@ describe("DoomsdayClockExecution (logic)", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Warship decay: a flagged (sub-threshold, non-leader) side's warships bleed HP
|
||||
// on the troop start + ramp but toward a much higher ceiling, so at full
|
||||
// attrition they sink in ~2s. Destroyed with no attacker (never a credited
|
||||
// kill); the leader's fleet is spared.
|
||||
// attrition they drop to the 5% floor in ~2s (never sunk). Damage carries no
|
||||
// attacker (environmental, never a credited kill); the leader's fleet is spared.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DoomsdayClockExecution (warship decay)", () => {
|
||||
@@ -348,28 +354,40 @@ describe("DoomsdayClockExecution (warship decay)", () => {
|
||||
expect(ship.health()).toBe(900); // warship: identical at the start (same 10%)
|
||||
});
|
||||
|
||||
it("scuttles a warship in one tick at full attrition (its own high ceiling)", () => {
|
||||
// Once the side is fully ramped, a fresh full-HP warship is destroyed in a
|
||||
// single tick at warshipDrainMaxPercent (100% here). The troop max (80%)
|
||||
// would leave it at 200 HP, so destruction proves the ship uses its own
|
||||
// higher ceiling, not the troop rate.
|
||||
it("batters a warship down to the 5% floor at full attrition, never sunk", () => {
|
||||
// Once the side is fully ramped, a fresh full-HP warship would take its whole
|
||||
// ceiling (100% here) in one tick, but the floor (5% of maxHealth 1000 = 50)
|
||||
// clamps it: the ship drops to 50 HP and survives instead of being destroyed.
|
||||
const { game, b } = warshipGame([]);
|
||||
const exec = makeExec(game);
|
||||
runAt(exec, game, WAVE_TICK); // flag b (starts the side's attrition clock)
|
||||
const fresh = new FakeWarship(1000, 1000);
|
||||
b.warships = [fresh]; // appears once the side is fully ramped
|
||||
runAt(exec, game, WAVE_TICK + 70); // secondsPastWarn 6 >= ramp 3 -> max
|
||||
expect(fresh.destroyed).toBe(true);
|
||||
expect(fresh.health()).toBe(50); // 5% floor
|
||||
expect(fresh.destroyed).toBe(false); // crippled, not sunk
|
||||
});
|
||||
|
||||
it("destroys warships with no attacker, so decay never scores a kill", () => {
|
||||
const ship = new FakeWarship(50, 1000); // less HP than one tick of drain
|
||||
it("decays HP with no attacker (environmental) and stops at the floor", () => {
|
||||
const ship = new FakeWarship(1000, 1000);
|
||||
const { game } = warshipGame([ship]);
|
||||
const exec = makeExec(game);
|
||||
runAt(exec, game, WAVE_TICK);
|
||||
runAt(exec, game, WAVE_TICK + 10); // 10% of 1000 = 100 dmg > 50 hp
|
||||
expect(ship.destroyed).toBe(true);
|
||||
runAt(exec, game, WAVE_TICK + 10); // 10% of 1000 = 100 dmg
|
||||
expect(ship.health()).toBe(900); // took damage
|
||||
expect(ship.attackerWasPassed).toBe(false); // environmental, no kill credit
|
||||
expect(ship.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a warship already at the floor untouched", () => {
|
||||
const ship = new FakeWarship(50, 1000); // exactly the 5% floor
|
||||
const { game } = warshipGame([ship]);
|
||||
const exec = makeExec(game);
|
||||
runAt(exec, game, WAVE_TICK);
|
||||
runAt(exec, game, WAVE_TICK + 70); // full attrition
|
||||
expect(ship.health()).toBe(50); // nothing left above the floor to remove
|
||||
expect(ship.destroyed).toBe(false);
|
||||
expect(ship.attackerWasPassed).toBe(false);
|
||||
});
|
||||
|
||||
it("spares the leader's warships", () => {
|
||||
@@ -700,16 +718,16 @@ describe("DoomsdayClockExecution (integration)", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default-config wipe time. Uses the resolved DOOMSDAY_CLOCK_DEFAULTS (no drain
|
||||
// overrides) with real troop income (PlayerExecution) flowing every tick, so it
|
||||
// pins the advertised "~1 minute from caught to wiped". A pure-drain analysis
|
||||
// (ignoring income) under-counts this to ~45s; income offsets the early bleed.
|
||||
// Default-config floor behavior. Uses the resolved DOOMSDAY_CLOCK_DEFAULTS (no
|
||||
// drain overrides) with real troop income (PlayerExecution) flowing every tick.
|
||||
// The drain cripples a caught side to drainFloorPercent (5%) of its max and
|
||||
// stops there: it never reaches zero, and income keeps it hovering at the floor.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DoomsdayClockExecution (default drain, with income)", () => {
|
||||
it("wipes a full-troop side in ~2 minutes (warn + linear drain)", async () => {
|
||||
// Only enabled + speed set -> drain uses the defaults (warn 30s, 2%->5% /90s).
|
||||
// veryfast is chosen purely so the bar rises fast enough to catch the sliver.
|
||||
it("cripples a full-troop side down to ~5% of max, never wiped", async () => {
|
||||
// Only enabled + speed set -> drain uses the defaults (warn 30s, 2%->5% /90s,
|
||||
// floor 5%). veryfast is chosen so the bar rises fast enough to catch the sliver.
|
||||
const game = await setup(
|
||||
"plains",
|
||||
{
|
||||
@@ -730,7 +748,7 @@ describe("DoomsdayClockExecution (default drain, with income)", () => {
|
||||
game.addExecution(new DoomsdayClockExecution());
|
||||
|
||||
// Run until the rising bar catches the sliver, then fill it to a full stack
|
||||
// so we measure the worst-case (longest) wipe from that moment.
|
||||
// so the drain works from the worst case (a full army down to the floor).
|
||||
let caughtTick = -1;
|
||||
for (let i = 0; i < 8000; i++) {
|
||||
// grace is 600s now -> the bar only rises after ~6000 ticks
|
||||
@@ -741,20 +759,17 @@ describe("DoomsdayClockExecution (default drain, with income)", () => {
|
||||
}
|
||||
}
|
||||
expect(caughtTick).toBeGreaterThan(0);
|
||||
small.setTroops(game.config().maxTroops(small));
|
||||
const maxTroops = game.config().maxTroops(small);
|
||||
const floor = Math.floor((maxTroops * 5) / 100);
|
||||
small.setTroops(maxTroops);
|
||||
|
||||
let zeroTick = -1;
|
||||
for (let i = 0; i < 2500; i++) {
|
||||
game.executeNextTick();
|
||||
if (small.troops() <= 0) {
|
||||
zeroTick = game.ticks();
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(zeroTick).toBeGreaterThan(0);
|
||||
const seconds = (zeroTick - caughtTick) / 10;
|
||||
// ~30s warn + the slower drain, income included: about two minutes.
|
||||
expect(seconds).toBeGreaterThan(90);
|
||||
expect(seconds).toBeLessThan(150);
|
||||
// Run well past the warn + ramp (30s + 90s) so the drain reaches steady state.
|
||||
for (let i = 0; i < 2500; i++) game.executeNextTick();
|
||||
|
||||
expect(small.inDoomsdayClock()).toBe(true);
|
||||
expect(small.troops()).toBeGreaterThan(0); // never wiped to zero
|
||||
expect(small.troops()).toBeGreaterThanOrEqual(floor - 1); // never below the 5% floor
|
||||
// Settles at the floor (income keeps it a little above, within one drain step).
|
||||
expect(small.troops()).toBeLessThan(floor + Math.floor(maxTroops * 0.05));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user