Files
OpenFrontIO/src/client/view/PlayerView.ts
T
Zixer1 78ef7b56fd feat(doomsday-clock): battle-royale style zone gamemode (#4469)
Resolves Issue #4463

## Description:

An optional game mode that (almost) guarantees a finish instead of
letting late-game
stalemates drag on.
Originally called sudden death, renamed to Doomsday clock

Once enabled, every side (each player in FFA, each whole team in team
modes)
must hold a rising share of the map. A side below the bar is skulled;
after a
short warn its troops bleed to zero, forcing consolidation to a winner.

### How it works
- **Rising zone:** a grace period, then the required share ramps up
linearly to
each level with 30s pauses between (a battle-royale "zone"). Levels
track the
  ofstats FFA territory median (3/5/10/20/30%).
- **Four speed presets** (slow / normal / fast / very fast) change only
the pace:
  normal ends ~30 min, very fast ~15.
- **Troop decay:** a linear ramp as a % of max capacity, ~50s from
caught to zero
  (10s warn + ~50s ≈ 1 min total).
- **UI:** a HUD panel (live share vs target, wave/decay countdowns,
red/orange
cues) and an on-map skull above flagged players (blinks in danger,
steady while
  draining).

### Notes for review
- Off by default; no effect on existing games. However, as discussed we
can add it to the modifier pool for public games to see how popular the
gamemode is vs normal play.
- Sim is deterministic (integer-only, in `src/core`), covered by unit +
  integration tests.
- One-line addition to `GameServer.updateGameConfig` so the setting
survives the
  host → server → client round-trip.
- Status is packed into the existing name-pass data slot (`pd4.w`: 0/1/2
=
none/danger/draining); the skull is composited into the icon atlas at
load.

### Testing
`npm test`, `npm run lint`, `npx prettier --check .`, `npm run
build-prod` all pass.

### UI:
<img width="243" height="100" alt="Image"
src="https://github.com/user-attachments/assets/c4c9eeb0-4feb-437d-9aac-b2786a841b74"
/>

Dropdown between slow, normal, fast, very fast

Before zone:
<img width="302" height="175" alt="Image"
src="https://github.com/user-attachments/assets/7359a1ea-4951-446d-a23c-0711fe06cc5d"
/>

Zone started, player not affected the pannel also blinks orange for 10s:
<img width="297" height="175" alt="Image"
src="https://github.com/user-attachments/assets/fcc565a5-d5d0-47a7-97ea-d0ba9d9ad899"
/>

Player affected, grace period (Danger):
<img width="314" height="170" alt="Image"
src="https://github.com/user-attachments/assets/ff96d21e-96f3-4ef9-8190-48eecc7aac0f"
/>

Skull icon blinking over player (everyone sees it) - older screenshot,
the clipping has been fixed
<img width="462" height="145" alt="Image"
src="https://github.com/user-attachments/assets/53899211-33b1-40e1-83f2-77f2096f0cad"
/>

Player affected, grace period ended (Draining):
<img width="360" height="159" alt="Image"
src="https://github.com/user-attachments/assets/4b226d57-da4d-4866-ab5f-db48e4ed1ea2"
/>

Skull icon no longer blinking, everyone can see you are in a state of
decay, and troops are draining:
<img width="732" height="146" alt="image"
src="https://github.com/user-attachments/assets/cd10fedb-6e87-4dfc-9fbf-55d3945a7901"
/>


Skull is visible like alliances icon also on player tab
<img width="558" height="81" alt="Image"
src="https://github.com/user-attachments/assets/6acdbe91-bdd0-40c7-942b-3990d4dae87f"
/>

(just UI example, best way to see it is to hop on a solo game and play
against AI)

## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

zixer._
2026-07-02 18:42:03 -07:00

631 lines
18 KiB
TypeScript

import { Colord, colord } from "colord";
import { base64url } from "jose";
import { ColorPalette } from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { ClientID, PlayerCosmetics } from "../../core/Schemas";
import { createRandomName } from "../../core/Util";
import {
BuildableUnit,
Cell,
EmojiMessage,
Gold,
NameViewData,
PlayerActions,
PlayerBorderTiles,
PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
PlayerType,
Team,
Tick,
UnitType,
} from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { applyStateUpdate } from "../../core/game/GameUpdateUtils";
import {
AllianceView,
AttackUpdate,
PlayerUpdate,
} from "../../core/game/GameUpdates";
import { UserSettings } from "../../core/game/UserSettings";
import { PlayerState, PlayerStatic, PlayerTypeEnum } from "../render/types";
import { themeProvider } from "../theme/ThemeProvider";
import { GameView } from "./GameView";
import { UnitView } from "./UnitView";
const userSettings: UserSettings = new UserSettings();
const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 };
const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 };
const BORDER_TINT_RATIO = 0.35;
function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum {
switch (t) {
case PlayerType.Human:
return PlayerTypeEnum.Human;
case PlayerType.Bot:
return PlayerTypeEnum.Bot;
case PlayerType.Nation:
return PlayerTypeEnum.Nation;
default:
return PlayerTypeEnum.Bot;
}
}
// First-emission updates from the engine always include every field; these
// builders assert non-null for that contract. Subsequent diffs are partial
// and flow through applyStateUpdate() below.
function staticFromUpdate(pu: PlayerUpdate): PlayerStatic {
return {
smallID: pu.smallID!,
id: pu.id,
name: pu.name!,
displayName: pu.displayName!,
clientID: pu.clientID ?? null,
playerType: gamePlayerTypeToEnum(pu.playerType!),
team: pu.team ?? null,
isLobbyCreator: pu.isLobbyCreator!,
};
}
function stateFromUpdate(pu: PlayerUpdate): PlayerState {
// embargoes: Set<PlayerID strings> on the wire, but the renderer stores
// smallIDs (numbers). GameView fills these in via setEmbargoes() because
// it has the PlayerID → smallID lookup table.
return {
smallID: pu.smallID!,
isAlive: pu.isAlive!,
isDisconnected: pu.isDisconnected!,
tilesOwned: pu.tilesOwned!,
gold: Number(pu.gold!),
troops: pu.troops!,
isTraitor: pu.isTraitor!,
traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0),
inDoomsdayClock: pu.inDoomsdayClock ?? false,
markedDoomsdayClockTick: pu.markedDoomsdayClockTick ?? -1,
betrayals: pu.betrayals!,
hasSpawned: pu.hasSpawned!,
spawnTile: pu.spawnTile,
lastDeleteUnitTick: pu.lastDeleteUnitTick!,
allies: pu.allies!.slice(),
embargoes: [],
targets: pu.targets!.slice(),
outgoingAttacks: pu.outgoingAttacks!,
incomingAttacks: pu.incomingAttacks!,
outgoingAllianceRequests: pu.outgoingAllianceRequests!.slice(),
alliances: pu.alliances!,
outgoingEmojis: pu.outgoingEmojis!,
};
}
export class PlayerView {
public anonymousName: string | null = null;
private decoder?: PatternDecoder;
/** Long-lived renderer state — mutated in place by applyUpdate(). */
public state: PlayerState;
/** Static header data — set once at construction, never mutated. */
public static: PlayerStatic;
// Assigned via computeColors() in the constructor; re-assignable on theme change.
private _territoryColor!: Colord;
private _borderColor!: Colord;
private _railColor!: Colord;
// Update here to include structure light and dark colors
private _structureColors!: { light: Colord; dark: Colord };
// Pre-computed border color variants
private _borderColorNeutral!: Colord;
private _borderColorFriendly!: Colord;
private _borderColorEmbargo!: Colord;
private _borderColorDefendedNeutral!: { light: Colord; dark: Colord };
private _borderColorDefendedFriendly!: { light: Colord; dark: Colord };
private _borderColorDefendedEmbargo!: { light: Colord; dark: Colord };
constructor(
private game: GameView,
data: PlayerUpdate,
// Undefined until the worker's first name placement for this player.
public nameData: NameViewData | undefined,
public cosmetics: PlayerCosmetics,
) {
this.state = stateFromUpdate(data);
this.static = staticFromUpdate(data);
// First emission always carries name + playerType (see staticFromUpdate).
if (data.clientID === game.myClientID()) {
this.anonymousName = data.name!;
} else {
this.anonymousName = createRandomName(data.name!, data.playerType!);
}
this.computeColors();
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
: undefined;
this.decoder =
pattern === undefined
? undefined
: new PatternDecoder(pattern, base64url.decode);
}
/**
* Compute every theme-derived color (fill, border, structure, and the
* neutral/friendly/embargo border variants) from the active theme. Re-callable
* so a mid-game theme change — e.g. toggling colorblind mode — can refresh them.
*/
private computeColors(): void {
const theme = themeProvider.current();
const defaultTerritoryColor = theme.territoryColor(this);
const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
: undefined;
if (pattern) {
pattern.colorPalette ??= {
name: "",
primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette;
}
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.color?.color ??
pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
this._territoryColor = defaultTerritoryColor;
}
this._structureColors = theme.structureColors(this._territoryColor);
const maybeFocusedBorderColor =
this.game.myClientID() === this.static.clientID
? theme.focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
pattern?.colorPalette?.secondaryColor ??
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
// Rail color (only used for the local player's rails): white for
// visibility, flipped to black when the territory is too light for white
// to read against it. Patterns paint both colors, so average them.
const railBackdropBrightness = pattern
? (this._territoryColor.brightness() + this._borderColor.brightness()) / 2
: this._territoryColor.brightness();
this._railColor =
railBackdropBrightness > 0.8
? colord("rgb(0,0,0)")
: theme.focusedBorderColor();
const baseRgb = this._borderColor.toRgb();
this._borderColorNeutral = this._borderColor;
this._borderColorFriendly = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
this._borderColorEmbargo = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
this._borderColorDefendedNeutral = theme.defendedBorderColors(
this._borderColorNeutral,
);
this._borderColorDefendedFriendly = theme.defendedBorderColors(
this._borderColorFriendly,
);
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
this._borderColorEmbargo,
);
}
/** Recompute colors after the active theme changes (e.g. colorblind toggle). */
refreshColors(): void {
this.computeColors();
}
/**
* Update mutable state in place. Called by GameView.update() each tick the
* player appears in the PlayerUpdate stream.
*/
applyUpdate(pu: PlayerUpdate): void {
applyStateUpdate(this.state, pu);
}
/** Set the renderer-format embargoes (smallIDs). */
setEmbargoSmallIDs(smallIDs: number[]): void {
this.state.embargoes = smallIDs;
}
territoryColor(tile?: TileRef): Colord {
if (tile === undefined || this.decoder === undefined) {
return this._territoryColor;
}
const isPrimary = this.decoder.isPrimary(
this.game.x(tile),
this.game.y(tile),
);
return isPrimary ? this._territoryColor : this._borderColor;
}
structureColors(): { light: Colord; dark: Colord } {
return this._structureColors;
}
railColor(): Colord {
return this._railColor;
}
/**
* Border color for a tile:
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
* - If defended, applies theme checkerboard to the tinted color.
*/
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
if (tile === undefined) {
return this._borderColor;
}
const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile);
let baseColor: Colord;
let defendedColors: { light: Colord; dark: Colord };
if (hasEmbargo) {
baseColor = this._borderColorEmbargo;
defendedColors = this._borderColorDefendedEmbargo;
} else if (hasFriendly) {
baseColor = this._borderColorFriendly;
defendedColors = this._borderColorDefendedFriendly;
} else {
baseColor = this._borderColorNeutral;
defendedColors = this._borderColorDefendedNeutral;
}
if (!isDefended) {
return baseColor;
}
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
return lightTile ? defendedColors.light : defendedColors.dark;
}
/**
* Border relation flags for a tile, used by both CPU and WebGL renderers.
*/
borderRelationFlags(tile: TileRef): {
hasEmbargo: boolean;
hasFriendly: boolean;
} {
const mySmallID = this.smallID();
let hasEmbargo = false;
let hasFriendly = false;
for (const n of this.game.neighbors(tile)) {
if (!this.game.hasOwner(n)) {
continue;
}
const otherOwner = this.game.owner(n);
if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) {
continue;
}
if (this.hasEmbargo(otherOwner)) {
hasEmbargo = true;
break;
}
if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) {
hasFriendly = true;
}
}
return { hasEmbargo, hasFriendly };
}
async actions(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[] | null,
): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async buildables(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[],
): Promise<BuildableUnit[]> {
return this.game.worker.playerBuildables(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async borderTiles(): Promise<PlayerBorderTiles> {
return this.game.worker.playerBorderTiles(this.id());
}
outgoingAttacks(): AttackUpdate[] {
return this.state.outgoingAttacks;
}
incomingAttacks(): AttackUpdate[] {
return this.state.incomingAttacks;
}
async attackClusteredPositions(
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
return this.game
.units(...types)
.filter((u) => u.owner().smallID() === this.smallID());
}
nameLocation(): NameViewData | undefined {
return this.nameData;
}
smallID(): number {
return this.state.smallID;
}
name(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.static.name;
}
displayName(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.static.displayName;
}
clientID(): ClientID | null {
return this.static.clientID;
}
id(): PlayerID {
return this.static.id;
}
team(): Team | null {
return this.static.team;
}
type(): PlayerType {
// Map PlayerStatic's numeric enum back to engine string enum.
switch (this.static.playerType) {
case PlayerTypeEnum.Human:
return PlayerType.Human;
case PlayerTypeEnum.Bot:
return PlayerType.Bot;
case PlayerTypeEnum.Nation:
return PlayerType.Nation;
default:
return PlayerType.Bot;
}
}
isAlive(): boolean {
return this.state.isAlive;
}
isPlayer(): this is PlayerView {
return true;
}
numTilesOwned(): number {
return this.state.tilesOwned;
}
allies(): PlayerView[] {
return this.state.allies.map(
(a) => this.game.playerBySmallID(a) as PlayerView,
);
}
targets(): PlayerView[] {
return this.state.targets.map(
(id) => this.game.playerBySmallID(id) as PlayerView,
);
}
gold(): Gold {
// Engine Gold is bigint; renderer state stores number. Convert back at the
// accessor for game-code that still expects bigint semantics.
return BigInt(this.state.gold);
}
troops(): number {
return this.state.troops;
}
totalUnitLevels(type: UnitType): number {
return this.units(type)
.filter((unit) => !unit.isUnderConstruction())
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}
isMe(): boolean {
return this.smallID() === this.game.myPlayer()?.smallID();
}
isLobbyCreator(): boolean {
return this.static.isLobbyCreator;
}
isAlliedWith(other: PlayerView): boolean {
return this.state.allies.some((n) => other.smallID() === n);
}
isOnSameTeam(other: PlayerView): boolean {
return this.static.team !== null && this.static.team === other.static.team;
}
isFriendly(other: PlayerView): boolean {
return this.isAlliedWith(other) || this.isOnSameTeam(other);
}
isRequestingAllianceWith(other: PlayerView) {
return this.state.outgoingAllianceRequests.some((id) => other.id() === id);
}
alliances(): AllianceView[] {
return this.state.alliances;
}
hasEmbargoAgainst(other: PlayerView): boolean {
return this.state.embargoes.includes(other.smallID());
}
hasEmbargo(other: PlayerView): boolean {
return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this);
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
return this.game.worker.transportShipSpawn(this.id(), targetTile);
}
transitiveTargets(): PlayerView[] {
const result: PlayerView[] = [];
// Add own targets
for (const id of this.state.targets) {
result.push(this.game.playerBySmallID(id) as PlayerView);
}
// Add allies' targets
for (const allyID of this.state.allies) {
const ally = this.game.playerBySmallID(allyID) as PlayerView;
for (const targetId of ally.state.targets) {
result.push(this.game.playerBySmallID(targetId) as PlayerView);
}
}
// Add teammates' targets
const myTeam = this.static.team;
if (myTeam !== null) {
for (const p of this.game.playerViews()) {
if (p !== this && p.static.team === myTeam) {
for (const targetId of p.state.targets) {
result.push(this.game.playerBySmallID(targetId) as PlayerView);
}
}
}
}
return result;
}
hasTransitiveTarget(sid: number): boolean {
if (this.state.targets.includes(sid)) return true;
for (const allyID of this.state.allies) {
const ally = this.game.playerBySmallID(allyID) as PlayerView;
if (ally && ally.state.targets.includes(sid)) {
return true;
}
}
const myTeam = this.static.team;
if (myTeam !== null) {
for (const p of this.game.playerViews()) {
if (
p !== this &&
p.static.team === myTeam &&
p.state.targets.includes(sid)
) {
return true;
}
}
}
return false;
}
isTraitor(): boolean {
return this.state.isTraitor;
}
getTraitorRemainingTicks(): number {
return this.state.traitorRemainingTicks;
}
inDoomsdayClock(): boolean {
return this.state.inDoomsdayClock;
}
doomsdayClockTicks(): number {
return this.inDoomsdayClock()
? this.game.ticks() - this.state.markedDoomsdayClockTick
: 0;
}
betrayals(): number {
return this.state.betrayals;
}
outgoingEmojis(): EmojiMessage[] {
return this.state.outgoingEmojis;
}
hasSpawned(): boolean {
return this.state.hasSpawned;
}
isDisconnected(): boolean {
return this.state.isDisconnected;
}
lastDeleteUnitTick(): Tick {
return this.state.lastDeleteUnitTick;
}
deleteUnitCooldown(): number {
return (
Math.max(
0,
this.game.config().deleteUnitCooldown() -
(this.game.ticks() + 1 - this.lastDeleteUnitTick()),
) / 10
);
}
}