mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 12:30:51 +00:00
**Add approved & assigned issue number here:** Resolves #(to be filed — happy to open the matching issue; flagging directly since this is live in prod team lobbies) ## Description: **Bug.** In Team mode the Doomsday Clock threshold is scaled by the team's *alive* headcount (`base × members.length`, capped at the whole map). In real team lobbies this breaks the mechanic: - With ~16 players per team, wave 1 already demands `4 % × 16 = 64 %+` of the map per team while all humans combined hold ~35 % (bots own the rest). Every non-leading team is skulled ~1 minute into wave 1 and drained to the 5 % floor ~90 s later — even on **slow**, whose squeeze is designed to run to 45:00. The game is decided by whoever leads at grace-end. - The bar/forecast **drops when a teammate dies** (players see "will rise to 68 %" → "44 %" mid-game), and disconnected-but-alive teammates silently inflate it. - Past the 100 % cap, the per-capita scaling stops meaning anything anyway. **Evidence.** Prod game `JEUieLzK` (`Team, playerTeams: 4, 69 players, doomsdayClock slow, bots: 400, no timer`): ended **16:47** via the 95 % team-domination check. From the archived record: 25 deaths before 10:00, only 8 after — the drain cripples rather than kills, so the collapse is invisible in death stats but decisive in outcome. Reconstruction with the real schedule + drain constants: every non-leading team skulled ~11:00–11:30 and pinned at the floor by ~12:30; the crown-exempt leader rolled to 95 % by 16:47. **Why flat is correct.** The wave levels encode a viable-**side** count — `floor(100 / wave%)` sides fit above the bar (25 → 11 → 6 → 3 → 2 → 1). Elimination and victory happen at side granularity (a team is out when the whole team is out; the winner is a team), so the slots must be counted in sides. A team now faces exactly the bar a solo player faces, judged on its combined territory — and each speed preset means the same thing in teams as it does in FFA. **Changes:** - `DoomsdayClock.ts`: remove `doomsdayClockSideRequiredTiles`; the sim and the HUD both use `doomsdayClockRequiredTiles` (hoisted out of the per-side loop — it no longer varies per side). - `DoomsdayClockExecution.ts`: flat per-side bar; comments updated. - `DoomsdayClockPanel.ts`: drop `scalePct`/headcount from the readout — the zone forecast becomes one universal, monotonic number for every player (fixes the 68 % → 44 % whipsaw). `sideStats` → `sideTiles`. - Tests: headcount-scaling expectations replaced with flat-bar + death-invariance regression tests. (History note: an earlier commit restricted team lobbies to normal/fast presets; it was reverted in-branch — with the flat bar the presets carry the same meaning as in FFA, so the full rotation stays. Squash-merge leaves the fix only.) FFA behaviour is unchanged throughout (sides of size 1: flat ≡ current). Deterministic integer math untouched (strictly fewer ops). `npm test` fully green. Rule comparison on `JEUieLzK`'s shape (real death ticks + constants; modeled share trajectories): | rule | trailing teams skulled | all non-leaders at 5 % floor | |---|---|---| | current (× alive) | 11:01–11:27 | **12:34** ← matches the real 16:47 blowout | | flat per-side bar | 26:01–30:42 | 31:49 (late-game backstop, as designed) | ## Please complete the following: - [ ] I have added screenshots for all UI updates — _no layout/element changes; the existing readout shows unscaled values (numbers only)_ - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file — _no new strings; existing `doomsday_clock.*` keys reused with the same params_ - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer
244 lines
9.7 KiB
TypeScript
244 lines
9.7 KiB
TypeScript
import { html, LitElement } from "lit";
|
|
import { customElement, property } from "lit/decorators.js";
|
|
import { assetUrl } from "../../core/AssetUrls";
|
|
import {
|
|
doomsdayClockDrain,
|
|
doomsdayClockRequiredTiles,
|
|
doomsdayClockWaveState,
|
|
} from "../../core/game/DoomsdayClock";
|
|
import { GameMode, PlayerType, Team } from "../../core/game/Game";
|
|
import { themeProvider } from "../theme/ThemeProvider";
|
|
import { renderTroops, translateText } from "../Utils";
|
|
import { GameView } from "../view";
|
|
|
|
const doomsdayClockIcon = assetUrl("images/DoomsdayClockSkull.svg");
|
|
|
|
/**
|
|
* The Doomsday Clock readout: a self-contained panel showing the rising bar, the
|
|
* side's share vs the threshold, the stage (Stable/Unstable/Collapsing) and the
|
|
* wave countdown. Embedded by game-right-sidebar so it stacks (centered) under
|
|
* the game timer; it hides only when the mode is off or after a winner. A
|
|
* spectator, replay viewer, or eliminated / not-spawned player still sees the
|
|
* zone-only readout (rising bar + wave countdown, no personal status line).
|
|
*/
|
|
@customElement("doomsday-clock-panel")
|
|
export class DoomsdayClockPanel extends LitElement {
|
|
@property({ attribute: false }) game!: GameView;
|
|
@property({ attribute: false }) hasWinner = false;
|
|
// Bumped by the parent each tick so the countdown + bar advance every second.
|
|
@property({ attribute: false }) refreshKey = 0;
|
|
|
|
// Light DOM so Tailwind classes apply and it stacks in the parent's flex.
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
private secondsToHms(d: number): string {
|
|
const pad = (n: number) => (n < 10 ? `0${n}` : n);
|
|
const h = Math.floor(d / 3600);
|
|
const m = Math.floor((d % 3600) / 60);
|
|
const s = Math.floor((d % 3600) % 60);
|
|
return h !== 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
|
|
}
|
|
|
|
// The player's "side" (matching the sim): themselves in FFA, their whole team
|
|
// otherwise. The sim judges a side on its combined territory against the same
|
|
// bar as a solo player, so the HUD only needs the tile sum.
|
|
private sideTiles(me: ReturnType<GameView["myPlayer"]>): number {
|
|
if (!me) return 0;
|
|
const ffa = this.game.config().gameConfig().gameMode === GameMode.FFA;
|
|
const myTeam = me.team();
|
|
if (ffa || myTeam === null) return me.numTilesOwned();
|
|
return this.game
|
|
.playerViews()
|
|
.filter(
|
|
(p) =>
|
|
p.team() === myTeam && p.isAlive() && p.type() !== PlayerType.Bot,
|
|
)
|
|
.reduce((sum, p) => sum + p.numTilesOwned(), 0);
|
|
}
|
|
|
|
// Localized team name (e.g. "Red"), matching TeamStats; falls back to the raw
|
|
// team id for numbered teams.
|
|
private teamDisplayName(team: Team): string {
|
|
const key = `team_colors.${team.toLowerCase()}`;
|
|
const translated = translateText(key);
|
|
return translated !== key ? translated : team;
|
|
}
|
|
|
|
// The team's on-map color as a hex string, for the readout label.
|
|
private teamColor(team: Team): string {
|
|
return themeProvider.current().teamColor(team).toHex();
|
|
}
|
|
|
|
render() {
|
|
const sd = this.game?.config().doomsdayClockConfig();
|
|
const me = this.game?.myPlayer();
|
|
// Show whenever the mode is on and there's no winner yet. A spectator, a
|
|
// replay viewer, or a not-spawned / eliminated player has no live side, so
|
|
// `live` is false and they get the zone-only readout (rising bar + countdown).
|
|
const visible = !!sd?.enabled && !this.hasWinner;
|
|
this.style.display = visible ? "block" : "none";
|
|
if (!visible || !sd) return html``;
|
|
|
|
const live = !!me && me.isAlive();
|
|
const elapsed = Math.floor(this.game.elapsedGameSeconds());
|
|
const land = this.game.numLandTiles() - this.game.numTilesWithFallout();
|
|
const myTeam = me?.team() ?? null;
|
|
const yourTiles = this.sideTiles(me);
|
|
// Same bar for every side, solo or team (same as the sim) — so the zone
|
|
// readout is one universal, monotonic number for every player.
|
|
const requiredTiles = doomsdayClockRequiredTiles(sd.speed, land, elapsed);
|
|
const wave = doomsdayClockWaveState(sd.speed, elapsed);
|
|
// Match the sim: no land -> no bar, no percentages (avoid div-by-zero / >100%).
|
|
const requiredPct = land > 0 ? (requiredTiles / land) * 100 : 0;
|
|
const yourPct = land > 0 ? (yourTiles / land) * 100 : 0;
|
|
const flagged = me?.inDoomsdayClock() ?? false;
|
|
const secondsUnder = Math.floor((me?.doomsdayClockTicks() ?? 0) / 10);
|
|
const draining = flagged && secondsUnder >= sd.warnSeconds;
|
|
// Safe but within 10% (relative) of the bar: e.g. at 9% when the bar is 10%,
|
|
// or 0.9% when it's 1%. About to be caught, so it blinks red too.
|
|
const nearDanger =
|
|
live && !flagged && requiredTiles > 0 && yourPct <= requiredPct * 1.1;
|
|
// In danger (caught/draining) or about to be: everything red.
|
|
const redAlert = flagged || nearDanger;
|
|
|
|
// The zone's own progress, independent of your status. Shown while stable
|
|
// AND while collapsing, so you can still see the bar rising as you bleed.
|
|
const zoneDetail = wave.done
|
|
? translateText("doomsday_clock.final", {
|
|
pct: wave.currentPercent,
|
|
})
|
|
: wave.growing
|
|
? translateText("doomsday_clock.growing", {
|
|
pct: wave.targetPercent,
|
|
time: this.secondsToHms(wave.secondsToTarget),
|
|
})
|
|
: translateText("doomsday_clock.next_wave", {
|
|
pct: wave.targetPercent,
|
|
time: this.secondsToHms(wave.secondsToNextGrowth),
|
|
});
|
|
|
|
// Status word + detail line. Spectators, replay viewers, and eliminated /
|
|
// not-spawned players have no live side, so they see just the zone readout
|
|
// (no personal status word). Detail defaults to the zone readout for everyone.
|
|
let status = "";
|
|
let statusClass = "";
|
|
let detail = zoneDetail;
|
|
if (live && draining && me) {
|
|
// 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(
|
|
maxTroops,
|
|
secondsUnder - sd.warnSeconds,
|
|
sd,
|
|
);
|
|
status = translateText("doomsday_clock.collapsing", {
|
|
rate: renderTroops(Math.max(0, Math.min(me.troops() - floor, chunk))),
|
|
});
|
|
statusClass = "text-red-400 font-bold";
|
|
} else if (live && flagged) {
|
|
// Caught below a wave: count down the cooldown before decay begins.
|
|
status = translateText("doomsday_clock.unstable");
|
|
statusClass = "text-red-400 font-bold";
|
|
detail = translateText("doomsday_clock.decay_in", {
|
|
secs: Math.max(0, sd.warnSeconds - secondsUnder),
|
|
});
|
|
} else if (live) {
|
|
status = translateText("doomsday_clock.stable");
|
|
statusClass = nearDanger ? "text-orange-300 font-bold" : "text-green-400";
|
|
}
|
|
|
|
// Panel edge cue: red pulse when in/near danger, orange pulse in the 10s
|
|
// window around a wave firing.
|
|
const edge = redAlert
|
|
? "sd-pulse-red"
|
|
: wave.waveFlash
|
|
? "sd-pulse-orange"
|
|
: "";
|
|
const panel =
|
|
"w-fit flex flex-col gap-1.5 py-2 px-4 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-bl-lg text-white text-sm";
|
|
|
|
return html`
|
|
<style>
|
|
@keyframes sd-red {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(248, 113, 113, 0);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.95);
|
|
}
|
|
}
|
|
@keyframes sd-orange {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(251, 146, 60, 0);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.9);
|
|
}
|
|
}
|
|
.sd-pulse-red {
|
|
animation: sd-red 1s ease-in-out infinite;
|
|
}
|
|
.sd-pulse-orange {
|
|
animation: sd-orange 1.8s ease-in-out infinite;
|
|
}
|
|
</style>
|
|
<div class="${panel} ${edge}">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<span
|
|
class="flex items-center gap-1.5 font-bold tracking-wide text-red-400"
|
|
>
|
|
<img src=${doomsdayClockIcon} alt="" width="20" height="20" />
|
|
${translateText("doomsday_clock.title")}
|
|
</span>
|
|
<span class=${statusClass}>${status}</span>
|
|
</div>
|
|
<div class="relative h-2.5 w-52 overflow-hidden rounded bg-gray-600/60">
|
|
<!-- your held share (green) vs the target threshold (red bar): the gap
|
|
between them shows how far you are from safe. -->
|
|
<div
|
|
class="absolute inset-y-0 left-0 bg-green-400"
|
|
style="width:${Math.min(100, yourPct)}%"
|
|
></div>
|
|
<div
|
|
class="absolute inset-y-0 w-0.5 bg-red-500"
|
|
style="left:${Math.min(100, requiredPct)}%"
|
|
></div>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-3 text-gray-300">
|
|
<span>
|
|
${translateText("doomsday_clock.hold", {
|
|
pct: requiredPct.toFixed(1),
|
|
})}
|
|
</span>
|
|
${live
|
|
? myTeam !== null
|
|
? html`<span style=${`color:${this.teamColor(myTeam)}`}>
|
|
${translateText("doomsday_clock.your_team", {
|
|
team: this.teamDisplayName(myTeam),
|
|
pct: yourPct.toFixed(1),
|
|
})}
|
|
</span>`
|
|
: html`<span
|
|
class=${redAlert ? "text-red-300" : "text-green-300"}
|
|
>
|
|
${translateText("doomsday_clock.you", {
|
|
pct: yourPct.toFixed(1),
|
|
})}
|
|
</span>`
|
|
: ""}
|
|
</div>
|
|
${detail
|
|
? html`<div class="text-xs text-gray-400">${detail}</div>`
|
|
: ""}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|