Files
OpenFrontIO/src/core/game/UnitImpl.ts
T
bijxandGitHub 006f1690a5 Warship veterancy (#4433)
## Description:

Warship veterancy! This is an idea inspired by the unit veterancy
feature of games like C&C: Red Alert 2 in which unit eliminations
increases the level of individual units. I've been trying to build this
mechanic for months with different ideas, and I finally landed on this
being one of the more balanced implementation.

Warships can earn up to three levels, represented by the gold bar
insignia in the bottom right of their warship sprite.

<img width="622" height="202" alt="image"
src="https://github.com/user-attachments/assets/a8c31a45-4ae9-41a9-b054-9c4a7f4ab1f1"
/>

A veterancy bar grants 20% health from the base amount, and a 20%
increase in shell damage applied _after_ the random damage roll. For
example, a level 3 warship will apply a 60% damage boost on top of the
random shell damage value (something between 200-325. If the random
value is 250, the final damage output will be `250 * 1.60 = 400`.

There are three ways to achieve a veteran level:

1. **Eliminate another warship:** any time a warship neutralizes another
warship, it immediately get's a veterancy increase.


https://github.com/user-attachments/assets/6a9e0958-5171-4ca3-94f6-9c2300a12f8b

2. **Eliminate transport boats:** Destroying 10 transport boats will
level a warship to the next veterancy bar.


https://github.com/user-attachments/assets/619ce0c0-033c-4e0b-9c64-b41eabaa791b

3. **Steal trade ships:** If the warship captures 25 trade ships, it
will earn a veterancy bar.


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

bijx
2026-07-01 21:38:09 -07:00

660 lines
18 KiB
TypeScript

import { simpleHash, toInt, withinInt } from "../Util";
import {
AllUnitParams,
MessageType,
Player,
Tick,
TrainType,
TrajectoryTile,
TransportShipState,
Unit,
UnitInfo,
UnitType,
WarshipState,
} from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { GameUpdateType, UnitUpdate } from "./GameUpdates";
import { PlayerImpl } from "./PlayerImpl";
import { maxHealthWithVeterancy } from "./Veterancy";
export class UnitImpl implements Unit {
private _active = true;
private _targetTile: TileRef | undefined;
private _targetUnit: Unit | undefined;
private _health: bigint;
private _lastTile: TileRef;
private _transportShipState: TransportShipState | undefined = undefined;
private _warshipState: WarshipState | undefined = undefined;
private _targetedBySAM = false;
private _reachedTarget = false;
private _wasDestroyedByEnemy: boolean = false;
private _destroyer: Player | undefined = undefined;
private _lastSetSafeFromPirates: number; // Only for trade ships
private _underConstruction: boolean = false;
private _lastOwner: PlayerImpl | null = null;
private _troops: number;
// Number of missiles in cooldown, if empty all missiles are ready.
private _missileTimerQueue: number[] = [];
private _hasTrainStation: boolean = false;
private _level: number = 1;
private _targetable: boolean = true;
private _loaded: boolean | undefined;
private _trainType: TrainType | undefined;
// Nuke only
private _trajectoryIndex: number = 0;
private _trajectory: TrajectoryTile[];
private _deletionAt: number | null = null;
constructor(
private _type: UnitType,
private mg: GameImpl,
private _tile: TileRef,
private _id: number,
public _owner: PlayerImpl,
params: AllUnitParams = {},
) {
this._lastTile = _tile;
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._targetTile =
"targetTile" in params ? (params.targetTile ?? undefined) : undefined;
this._trajectory = "trajectory" in params ? (params.trajectory ?? []) : [];
this._troops = "troops" in params ? (params.troops ?? 0) : 0;
this._lastSetSafeFromPirates =
"lastSetSafeFromPirates" in params
? (params.lastSetSafeFromPirates ?? 0)
: 0;
if (this._type === UnitType.TransportShip) {
this._transportShipState = { isRetreating: false, troops: 0 };
}
if ("patrolTile" in params) {
this._warshipState = {
state: "patrolling",
patrolTile: params.patrolTile,
lastCombatTick: -100,
veterancy: 0,
veterancyProgress: 0,
};
}
this._targetUnit =
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
this._loaded =
"loaded" in params ? (params.loaded ?? undefined) : undefined;
this._trainType = "trainType" in params ? params.trainType : undefined;
switch (this._type) {
case UnitType.Warship:
case UnitType.Port:
case UnitType.MissileSilo:
case UnitType.DefensePost:
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Factory:
this.mg.stats().unitBuild(_owner, this._type);
}
}
setTargetable(targetable: boolean): void {
if (this._targetable !== targetable) {
this._targetable = targetable;
this.mg.addUpdate(this.toUpdate());
}
}
isTargetable(): boolean {
return this._targetable;
}
isUnit(): this is Unit {
return true;
}
touch(): void {
this.mg.addUpdate(this.toUpdate());
}
setTileTarget(tile: TileRef | undefined): void {
this._targetTile = tile;
}
tileTarget(): TileRef | undefined {
return this._targetTile;
}
id() {
return this._id;
}
toUpdate(): UnitUpdate {
return {
type: GameUpdateType.Unit,
unitType: this._type,
id: this._id,
troops: this._troops,
ownerID: this._owner.smallID(),
lastOwnerID: this._lastOwner?.smallID(),
isActive: this._active,
reachedTarget: this._reachedTarget,
warshipState:
this._warshipState !== undefined
? { ...this.warshipState() }
: undefined,
transportShipState:
this._transportShipState !== undefined
? this.transportShipState()
: undefined,
pos: this._tile,
markedForDeletion: this._deletionAt ?? false,
targetable: this._targetable,
lastPos: this._lastTile,
health: this.hasHealth() ? Number(this._health) : undefined,
underConstruction: this._underConstruction,
targetUnitId: this._targetUnit?.id() ?? undefined,
targetTile: this.targetTile() ?? undefined,
missileTimerQueue: this._missileTimerQueue,
level: this.level(),
hasTrainStation: this._hasTrainStation,
trainType: this._trainType,
loaded: this._loaded,
};
}
type(): UnitType {
return this._type;
}
lastTile(): TileRef {
return this._lastTile;
}
move(tile: TileRef): void {
if (tile === null) {
throw new Error("tile cannot be null");
}
this._lastTile = this._tile;
this._tile = tile;
this.mg.onUnitMoved(this);
}
setTroops(troops: number): void {
const nextTroops = Math.max(0, troops);
if (this._troops === nextTroops) {
return;
}
this._troops = nextTroops;
this.mg.addUpdate(this.toUpdate());
}
troops(): number {
return this._troops;
}
health(): number {
return Number(this._health);
}
hasHealth(): boolean {
return this.info().maxHealth !== undefined;
}
tile(): TileRef {
return this._tile;
}
owner(): PlayerImpl {
return this._owner;
}
info(): UnitInfo {
return this.mg.unitInfo(this._type);
}
setOwner(newOwner: PlayerImpl): void {
this.clearPendingDeletion();
switch (this._type) {
case UnitType.Warship:
case UnitType.Port:
case UnitType.MissileSilo:
case UnitType.DefensePost:
case UnitType.SAMLauncher:
case UnitType.City:
case UnitType.Factory:
this.mg.stats().unitCapture(newOwner, this._type);
this.mg.stats().unitLose(this._owner, this._type);
break;
}
this._lastOwner = this._owner;
this._lastOwner._units = this._lastOwner._units.filter((u) => u !== this);
this._owner = newOwner;
this._owner._units.push(this);
this.mg.addUpdate(this.toUpdate());
}
maxHealth(): number {
const base = this.info().maxHealth ?? 1;
// veterancy() is 0 for non-warships, so this returns base for them.
return maxHealthWithVeterancy(
base,
this.veterancy(),
this.mg.config().warshipVeterancyHealthBonus(),
);
}
modifyHealth(delta: number, attacker?: Player): void {
const previousHealth = this._health;
const nextHealth = withinInt(
this._health + toInt(delta),
0n,
toInt(this.maxHealth()),
);
if (nextHealth === previousHealth) {
return;
}
if (
attacker !== undefined &&
delta < 0 &&
this._warshipState !== undefined
) {
this._warshipState.lastCombatTick = this.mg.ticks();
}
this._health = nextHealth;
this.mg.addUpdate(this.toUpdate());
if (this._health === 0n) {
this.delete(true, attacker);
}
}
clearPendingDeletion(): void {
this._deletionAt = null;
}
isMarkedForDeletion(): boolean {
return this._deletionAt !== null;
}
markForDeletion(): void {
if (!this.isActive()) {
return;
}
this._deletionAt =
this.mg.ticks() + this.mg.config().deletionMarkDuration();
this.mg.addUpdate(this.toUpdate());
}
isOverdueDeletion(): boolean {
if (!this.isActive()) {
return false;
}
return this._deletionAt !== null && this.mg.ticks() - this._deletionAt > 0;
}
delete(displayMessage?: boolean, destroyer?: Player): void {
if (!this.isActive()) {
throw new Error(`cannot delete ${this} not active`);
}
// Record whether this unit was destroyed by an enemy (vs. arrived / retreated)
this._wasDestroyedByEnemy = destroyer !== undefined;
this._destroyer = destroyer ?? undefined;
this._owner._units = this._owner._units.filter((b) => b !== this);
this._active = false;
this.mg.addUpdate(this.toUpdate());
this.mg.removeUnit(this);
if (displayMessage !== false) {
this.displayMessageOnDeleted();
}
if (destroyer !== undefined) {
switch (this._type) {
case UnitType.TransportShip:
this.mg
.stats()
.boatDestroyTroops(destroyer, this._owner, this._troops);
break;
case UnitType.TradeShip:
this.mg.stats().boatDestroyTrade(destroyer, this._owner);
break;
case UnitType.City:
case UnitType.DefensePost:
case UnitType.MissileSilo:
case UnitType.Port:
case UnitType.SAMLauncher:
case UnitType.Warship:
case UnitType.Factory:
this.mg.stats().unitDestroy(destroyer, this._type);
this.mg.stats().unitLose(this.owner(), this._type);
break;
}
}
}
private displayMessageOnDeleted(): void {
// Only warships and transport ships are worth notifying about; everything
// else is either visible on the map or too low-stakes to surface.
if (
this._type !== UnitType.Warship &&
this._type !== UnitType.TransportShip
) {
return;
}
this.mg.displayMessage(
"events_display.unit_destroyed",
MessageType.UNIT_DESTROYED,
this.owner().id(),
undefined,
{ unit: this._type },
this.id(),
);
}
isActive(): boolean {
return this._active;
}
wasDestroyedByEnemy(): boolean {
return this._wasDestroyedByEnemy;
}
destroyer(): Player | undefined {
return this._destroyer;
}
warshipState(): WarshipState {
if (this._warshipState === undefined) {
throw new Error("warshipState called on non-warship unit");
}
this._warshipState.isInCombat = this.isInCombat();
return this._warshipState;
}
updateWarshipState(update: Partial<WarshipState>): void {
if (this._warshipState === undefined) {
throw new Error("updateWarshipState called on non-warship unit");
}
if (update.isInCombat) {
this.markInCombat();
}
const merged = { ...this._warshipState, ...update };
if (
merged.state === this._warshipState.state &&
merged.patrolTile === this._warshipState.patrolTile &&
merged.retreatPort === this._warshipState.retreatPort
)
return;
this._warshipState = {
state: merged.state,
patrolTile: merged.patrolTile,
retreatPort: merged.retreatPort,
lastCombatTick: this._warshipState.lastCombatTick,
veterancy: this._warshipState.veterancy,
veterancyProgress: this._warshipState.veterancyProgress,
};
this.mg.addUpdate(this.toUpdate());
}
isInCombat(): boolean {
return this.mg.ticks() - this._warshipState!.lastCombatTick <= 3;
}
private markInCombat(): void {
const wasInCombat = this.isInCombat();
this._warshipState!.lastCombatTick = this.mg.ticks();
if (!wasInCombat) {
this.mg.addUpdate(this.toUpdate());
}
}
transportShipState(): TransportShipState {
if (this._transportShipState === undefined) {
throw new Error("transportShipState called on non-transport-ship unit");
}
return {
isRetreating: this._transportShipState.isRetreating,
troops: this._troops,
};
}
updateTransportShipState(update: Partial<TransportShipState>): void {
if (this._transportShipState === undefined) {
throw new Error(
"updateTransportShipState called on non-transport-ship unit",
);
}
let changed = false;
if (
update.isRetreating !== undefined &&
this._transportShipState.isRetreating !== update.isRetreating
) {
this._transportShipState = {
...this._transportShipState,
isRetreating: update.isRetreating,
};
changed = true;
}
if (changed) {
this.mg.addUpdate(this.toUpdate());
}
}
isUnderConstruction(): boolean {
return this._underConstruction;
}
setUnderConstruction(underConstruction: boolean): void {
if (this._underConstruction !== underConstruction) {
this._underConstruction = underConstruction;
this.mg.addUpdate(this.toUpdate());
}
}
hash(): number {
return this.tile() + simpleHash(this.type()) * this._id;
}
toString(): string {
return `Unit:${this._type},owner:${this.owner().name()}`;
}
launch(): void {
this._missileTimerQueue.push(this.mg.ticks());
this.mg.addUpdate(this.toUpdate());
}
ticksLeftInCooldown(): Tick | undefined {
return this._missileTimerQueue[0];
}
isInCooldown(): boolean {
return this._missileTimerQueue.length === this._level;
}
missileTimerQueue(): number[] {
return this._missileTimerQueue;
}
reloadMissile(): void {
this._missileTimerQueue.shift();
this.mg.addUpdate(this.toUpdate());
}
setTargetTile(targetTile: TileRef | undefined) {
this._targetTile = targetTile;
}
targetTile(): TileRef | undefined {
return this._targetTile;
}
setTrajectoryIndex(i: number): void {
const max = this._trajectory.length - 1;
this._trajectoryIndex = i < 0 ? 0 : i > max ? max : i;
}
trajectoryIndex(): number {
return this._trajectoryIndex;
}
trajectory(): TrajectoryTile[] {
return this._trajectory;
}
setTargetUnit(target: Unit | undefined): void {
this._targetUnit = target;
}
targetUnit(): Unit | undefined {
return this._targetUnit;
}
setTargetedBySAM(targeted: boolean): void {
this._targetedBySAM = targeted;
}
targetedBySAM(): boolean {
return this._targetedBySAM;
}
setReachedTarget(): void {
this._reachedTarget = true;
}
reachedTarget(): boolean {
return this._reachedTarget;
}
setSafeFromPirates(): void {
this._lastSetSafeFromPirates = this.mg.ticks();
}
isSafeFromPirates(): boolean {
return (
this.mg.ticks() - this._lastSetSafeFromPirates <
this.mg.config().safeFromPiratesCooldownMax()
);
}
level(): number {
return this._level;
}
veterancy(): number {
return this._warshipState?.veterancy ?? 0;
}
/** Raise veterancy by one level (capped), which raises max health. The ship
* is NOT instantly healed — it heals toward the higher cap normally.
* No-op for non-warships or at the cap. */
private increaseVeterancy(): void {
if (this._warshipState === undefined) {
return;
}
if (
this._warshipState.veterancy >= this.mg.config().warshipMaxVeterancy()
) {
return;
}
this._warshipState.veterancy++;
this.mg.addUpdate(this.toUpdate());
}
recordKill(targetType: UnitType): void {
if (this._warshipState === undefined) {
return;
}
if (targetType === UnitType.Warship) {
// Final blow on an enemy warship: instant level, and the partial
// transport/capture progress toward the next level is wiped.
this._warshipState.veterancyProgress = 0;
this.increaseVeterancy();
} else if (targetType === UnitType.TransportShip) {
this.addVeterancyProgress(UnitType.TransportShip);
}
}
recordTradeCapture(): void {
if (this._warshipState === undefined) {
return;
}
this.addVeterancyProgress(UnitType.TradeShip);
}
/**
* Add partial progress toward the next veterancy level from a non-kill source.
*
* Transports and captures share one integer progress meter. One level =
* transportThreshold * captureThreshold points; a transport is worth
* `captureThreshold` points and a capture is worth `transportThreshold`
* points. That makes `transportThreshold` transports OR `captureThreshold`
* captures (or any mix) fill exactly one level — all integer math, no floats.
* Overflow carries into the next level (only a warship kill resets it).
*/
private addVeterancyProgress(source: UnitType): void {
if (this._warshipState === undefined) {
return;
}
const maxVeterancy = this.mg.config().warshipMaxVeterancy();
if (this._warshipState.veterancy >= maxVeterancy) {
return;
}
const transportThreshold = this.mg
.config()
.warshipVeterancyTransportKills();
const captureThreshold = this.mg.config().warshipVeterancyTradeCaptures();
const pointsPerLevel = transportThreshold * captureThreshold;
this._warshipState.veterancyProgress +=
source === UnitType.TransportShip ? captureThreshold : transportThreshold;
while (
this._warshipState.veterancyProgress >= pointsPerLevel &&
this._warshipState.veterancy < maxVeterancy
) {
this._warshipState.veterancyProgress -= pointsPerLevel;
this.increaseVeterancy();
}
if (this._warshipState.veterancy >= maxVeterancy) {
this._warshipState.veterancyProgress = 0;
}
}
setTrainStation(trainStation: boolean): void {
this._hasTrainStation = trainStation;
this.mg.addUpdate(this.toUpdate());
}
hasTrainStation(): boolean {
return this._hasTrainStation;
}
increaseLevel(): void {
this._level++;
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
this._missileTimerQueue.push(this.mg.ticks());
}
this.mg.addUpdate(this.toUpdate());
}
decreaseLevel(destroyer?: Player): void {
this._level--;
if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) {
this._missileTimerQueue.pop();
}
if (this._level <= 0) {
this.delete(true, destroyer);
return;
}
this.mg.addUpdate(this.toUpdate());
}
trainType(): TrainType | undefined {
return this._trainType;
}
isLoaded(): boolean | undefined {
return this._loaded;
}
setLoaded(loaded: boolean): void {
if (this._loaded !== loaded) {
this._loaded = loaded;
this.mg.addUpdate(this.toUpdate());
}
}
}