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
This commit is contained in:
bijx
2026-07-02 00:38:09 -04:00
committed by GitHub
parent 1d5a6ae246
commit 006f1690a5
18 changed files with 561 additions and 34 deletions
+9
View File
@@ -231,6 +231,15 @@ export interface RenderSettings {
colorGreenR: number;
colorGreenG: number;
colorGreenB: number;
// Warship veterancy rank pips (gold lines at the sprite's bottom-right)
veterancyPipW: number;
veterancyPipH: number;
veterancyPipGap: number;
veterancyPipOffsetX: number;
veterancyPipOffsetY: number;
veterancyR: number;
veterancyG: number;
veterancyB: number;
};
unit: {
unitSize: number;
+78 -11
View File
@@ -1,17 +1,20 @@
/**
* BarPass — instanced health/progress bars above units and below structures.
* BarPass — instanced health/progress bars and warship veterancy pips.
*
* Two draw calls per frame:
* Three draw calls per frame (all share one program + instance buffer):
* 1. Health bars (11x3 tiles, above warships)
* 2. Progress bars (14x3 tiles, below structures — construction + missile readiness)
* 3. Veterancy pips (solid gold rank bars stacked at a warship's bottom-right)
*
* Data flow:
* UnitState.health / .missileTimerQueue / .constructionStartTick → CPU progress
* → instance VBO (x, y, progress) → GPU colored rectangle
* UnitState.veterancy → one instance per level (x, y, slot) → solid gold rect
*/
import type { Config } from "../../../../core/configuration/Config";
import { UnitType } from "../../../../core/game/Game";
import { maxHealthWithVeterancy } from "../../../../core/game/Veterancy";
import type { RendererConfig, UnitState } from "../../types";
import { UT_MISSILE_SILO, UT_SAM_LAUNCHER } from "../../types";
import type { RenderSettings } from "../RenderSettings";
@@ -46,6 +49,9 @@ export class BarPass {
private uColorOrange: WebGLUniformLocation;
private uColorYellow: WebGLUniformLocation;
private uColorGreen: WebGLUniformLocation;
private uSolid: WebGLUniformLocation;
private uSolidColor: WebGLUniformLocation;
private uPipStride: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: WebGLBuffer;
@@ -54,9 +60,12 @@ export class BarPass {
private healthCount = 0;
private progressData: Float32Array;
private progressCount = 0;
private veterancyData: Float32Array;
private veterancyCount = 0;
private mapW: number;
private warshipMaxHealth: number;
private veterancyHealthBonus: number;
constructor(
gl: WebGL2RenderingContext,
@@ -68,6 +77,7 @@ export class BarPass {
this.settings = settings;
this.mapW = header.mapWidth;
this.warshipMaxHealth = config.unitInfo(UnitType.Warship).maxHealth ?? 0;
this.veterancyHealthBonus = config.warshipVeterancyHealthBonus();
// --- Shader program ---
this.program = createProgram(gl, barVertSrc, barFragSrc);
@@ -80,10 +90,14 @@ export class BarPass {
this.uColorOrange = gl.getUniformLocation(this.program, "uColorOrange")!;
this.uColorYellow = gl.getUniformLocation(this.program, "uColorYellow")!;
this.uColorGreen = gl.getUniformLocation(this.program, "uColorGreen")!;
this.uSolid = gl.getUniformLocation(this.program, "uSolid")!;
this.uSolidColor = gl.getUniformLocation(this.program, "uSolidColor")!;
this.uPipStride = gl.getUniformLocation(this.program, "uPipStride")!;
// --- Instance data buffers (CPU-side) ---
this.healthData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE);
this.progressData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE);
this.veterancyData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE);
// --- VAO: unit quad + instanced data ---
this.vao = gl.createVertexArray()!;
@@ -123,16 +137,26 @@ export class BarPass {
): void {
this.healthCount = 0;
this.progressCount = 0;
this.veterancyCount = 0;
// --- Health bars (warships) ---
// --- Health bars + veterancy pips (warships) ---
// Only warships carry health among mobile units, so this loop is effectively
// warship-only.
for (const unit of mobileUnits.values()) {
if (
unit.health === null ||
unit.health <= 0 ||
unit.health >= this.warshipMaxHealth
)
continue;
this.pushHealth(unit, unit.health / this.warshipMaxHealth);
if (unit.health === null || unit.health <= 0) continue;
// Veteran warships have a higher effective max health, so a full veteran
// ship reads as full. Shared with the engine's UnitImpl.maxHealth().
const maxHealth = maxHealthWithVeterancy(
this.warshipMaxHealth,
unit.veterancy,
this.veterancyHealthBonus,
);
if (unit.health < maxHealth) {
this.pushHealth(unit, unit.health / maxHealth);
}
if (unit.veterancy > 0) {
this.pushVeterancy(unit);
}
}
// --- Progress bars (structures) ---
@@ -145,13 +169,19 @@ export class BarPass {
/** Render bars. Call once per frame after FX, before names. */
draw(cameraMat: Float32Array): void {
if (this.healthCount === 0 && this.progressCount === 0) return;
if (
this.healthCount === 0 &&
this.progressCount === 0 &&
this.veterancyCount === 0
)
return;
const gl = this.gl;
const b = this.settings.bar;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMat);
gl.uniform1f(this.uSolid, 0); // health/progress bars use the colored path
gl.uniform1f(this.uBorderWidth, b.borderWidth);
gl.uniform3f(this.uThresholds, b.threshold1, b.threshold2, b.threshold3);
gl.uniform3f(this.uColorRed, b.colorRedR, b.colorRedG, b.colorRedB);
@@ -196,6 +226,29 @@ export class BarPass {
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.progressCount);
}
// Veterancy pips (solid gold rank bars, bottom-right of warship sprites)
if (this.veterancyCount > 0) {
gl.uniform1f(this.uSolid, 1);
gl.uniform3f(this.uSolidColor, b.veterancyR, b.veterancyG, b.veterancyB);
gl.uniform1f(this.uPipStride, b.veterancyPipH + b.veterancyPipGap);
gl.uniform2f(this.uBarSize, b.veterancyPipW, b.veterancyPipH);
gl.uniform2f(
this.uBarOffset,
b.veterancyPipOffsetX,
b.veterancyPipOffsetY,
);
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.veterancyData.subarray(
0,
this.veterancyCount * FLOATS_PER_INSTANCE,
),
);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.veterancyCount);
}
gl.bindVertexArray(null);
}
@@ -216,6 +269,20 @@ export class BarPass {
this.healthCount++;
}
/** Emit one gold pip instance per veterancy level, stacked by slot index. */
private pushVeterancy(unit: UnitState): void {
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
for (let slot = 0; slot < unit.veterancy; slot++) {
if (this.veterancyCount >= this.maxBars) return;
const off = this.veterancyCount * FLOATS_PER_INSTANCE;
this.veterancyData[off] = x;
this.veterancyData[off + 1] = y;
this.veterancyData[off + 2] = slot; // vertical stack slot, read by the shader
this.veterancyCount++;
}
}
private pushProgress(unit: UnitState, progress: number): void {
if (this.progressCount >= this.maxBars) return;
const off = this.progressCount * FLOATS_PER_INSTANCE;
+9 -1
View File
@@ -188,7 +188,15 @@
"colorYellowB": 0.059,
"colorGreenR": 0.173,
"colorGreenG": 0.937,
"colorGreenB": 0.071
"colorGreenB": 0.071,
"veterancyPipW": 4,
"veterancyPipH": 1,
"veterancyPipGap": 1,
"veterancyPipOffsetX": 1.5,
"veterancyPipOffsetY": 3.5,
"veterancyR": 1.0,
"veterancyG": 0.84,
"veterancyB": 0.0
},
"unit": {
"unitSize": 13,
@@ -8,6 +8,8 @@ uniform vec3 uColorRed;
uniform vec3 uColorOrange;
uniform vec3 uColorYellow;
uniform vec3 uColorGreen;
uniform float uSolid; // 1.0 = veterancy pip: fill solid with uSolidColor
uniform vec3 uSolidColor;
in vec2 vLocalPos;
flat in float vProgress;
@@ -15,6 +17,12 @@ flat in float vProgress;
out vec4 fragColor;
void main() {
// Veterancy pips are simple solid-filled rectangles (no border/threshold).
if (uSolid > 0.5) {
fragColor = vec4(uSolidColor, 1.0);
return;
}
float x = vLocalPos.x;
float y = vLocalPos.y;
float w = uBarSize.x;
@@ -7,6 +7,8 @@ layout(location = 1) in vec3 aInstData; // x, y, progress
uniform mat3 uCamera;
uniform vec2 uBarSize; // (width, height) in world tiles
uniform vec2 uBarOffset; // offset from unit center in tiles
uniform float uSolid; // 1.0 = veterancy pip mode (aInstData.z is a stack slot)
uniform float uPipStride; // vertical spacing between stacked pips, in tiles
out vec2 vLocalPos; // [0, barWidth] x [0, barHeight]
flat out float vProgress;
@@ -17,7 +19,10 @@ void main() {
vProgress = aInstData.z;
vec2 center = vec2(worldX + 0.5, worldY + 0.5);
vec2 barOrigin = center + uBarOffset;
vec2 offset = uBarOffset;
// In pip mode each instance is one stacked rank bar; raise it by its slot.
offset.y -= uSolid * aInstData.z * uPipStride;
vec2 barOrigin = center + offset;
vec2 worldPos = barOrigin + aPos * uBarSize;
vec3 clip = uCamera * vec3(worldPos, 1.0);
+1
View File
@@ -96,6 +96,7 @@ export interface UnitState {
troops: number;
missileTimerQueue: number[];
level: number;
veterancy: number;
hasTrainStation: boolean;
trainType: number | null; // 0=Engine, 1=TailEngine, 2=Carriage
loaded: boolean | null;
+11
View File
@@ -65,6 +65,7 @@ function unitStateFromUpdate(u: UnitUpdate): UnitState {
troops: u.troops,
missileTimerQueue: u.missileTimerQueue,
level: u.level,
veterancy: u.warshipState?.veterancy ?? 0,
hasTrainStation: u.hasTrainStation,
trainType: trainTypeToNum(u.trainType),
loaded: u.loaded ?? null,
@@ -93,6 +94,7 @@ function applyUpdateInPlace(target: UnitState, u: UnitUpdate): void {
target.troops = u.troops;
target.missileTimerQueue = u.missileTimerQueue;
target.level = u.level;
target.veterancy = u.warshipState?.veterancy ?? 0;
target.hasTrainStation = u.hasTrainStation;
target.trainType = trainTypeToNum(u.trainType);
target.loaded = u.loaded ?? null;
@@ -230,6 +232,15 @@ export class UnitView {
health(): number {
return this.state.health ?? 0;
}
veterancy(): number {
return this.state.veterancy;
}
recordKill(_targetType: UnitType): void {
throw new Error("recordKill is not supported on UnitView");
}
recordTradeCapture(): void {
throw new Error("recordTradeCapture is not supported on UnitView");
}
isUnderConstruction(): boolean {
return this.state.underConstruction;
}
+33 -2
View File
@@ -919,8 +919,10 @@ export class Config {
return 5;
}
warshipRetreatHealthThreshold(): number {
return 750;
/** Health at or below which a warship retreats to repair, as a percent of its
* (veterancy-adjusted) max health, so the threshold scales with max health. */
warshipRetreatHealthPercent(): number {
return 75;
}
warshipPassiveHealing(): number {
@@ -935,6 +937,35 @@ export class Config {
return 0.75;
}
// --- Warship veterancy ---
/** Maximum veterancy level a warship can reach. */
warshipMaxVeterancy(): number {
return 3;
}
/** Max-health boost per veterancy level, as an integer percent of base max
* health. Integer-only to keep src/core deterministic (no float constants). */
warshipVeterancyHealthBonus(): number {
return 20;
}
/** Shell-damage boost per veterancy level, as an integer percent of the
* rolled damage. Integer-only to keep src/core deterministic. */
warshipVeterancyShellDamageBonus(): number {
return 20;
}
/** Transport ships a warship must destroy to gain one veterancy level. */
warshipVeterancyTransportKills(): number {
return 10;
}
/** Trade ships a warship must capture to gain one veterancy level. */
warshipVeterancyTradeCaptures(): number {
return 25;
}
defensePostShellAttackRate(): number {
return 100;
}
+23 -1
View File
@@ -52,7 +52,19 @@ export class ShellExecution implements Execution {
);
if (result.status === PathStatus.COMPLETE) {
this.active = false;
const targetType = this.target.type();
const targetWasActive = this.target.isActive();
this.target.modifyHealth(-this.effectOnTarget(), this._owner);
// Award veterancy to the firing warship when this shell lands the
// killing blow on an enemy warship or transport ship.
if (
targetWasActive &&
!this.target.isActive() &&
this.ownerUnit.isActive() &&
this.ownerUnit.type() === UnitType.Warship
) {
this.ownerUnit.recordKill(targetType);
}
this.shell.setReachedTarget();
this.shell.delete(false);
return;
@@ -67,7 +79,17 @@ export class ShellExecution implements Execution {
const baseDamage = damage ?? 250;
const roll = this.random.nextInt(1, 6);
const damageMultiplier = (roll - 1) * 25 + 200;
let damageMultiplier = (roll - 1) * 25 + 200;
// Veteran warships hit harder — scale the (integer) multiplier by the firing
// unit's veterancy. Integer percent math keeps src/core float-free.
const veterancy = this.ownerUnit.veterancy();
if (veterancy > 0) {
const bonusPercent = this.mg.config().warshipVeterancyShellDamageBonus();
damageMultiplier = Math.floor(
(damageMultiplier * (100 + veterancy * bonusPercent)) / 100,
);
}
return Math.round((baseDamage / 250) * damageMultiplier);
}
+12 -6
View File
@@ -150,11 +150,10 @@ export class WarshipExecution implements Execution {
}
private isFullyHealed(): boolean {
const maxHealth = this.mg.config().unitInfo(UnitType.Warship).maxHealth;
if (typeof maxHealth !== "number") {
if (!this.warship.hasHealth()) {
return true;
}
return this.warship.health() >= maxHealth;
return this.warship.health() >= this.warship.maxHealth();
}
private shouldStartRepairRetreat(
@@ -170,9 +169,14 @@ export class WarshipExecution implements Execution {
) {
return false;
}
if (
healthBeforeHealing >= this.mg.config().warshipRetreatHealthThreshold()
) {
// Percentage of (veterancy-adjusted) max health, so a tougher veteran ship
// retreats at the same relative health as a fresh one. Integer math.
const retreatThreshold = Math.floor(
(this.warship.maxHealth() *
this.mg.config().warshipRetreatHealthPercent()) /
100,
);
if (healthBeforeHealing >= retreatThreshold) {
return false;
}
const ports = this.warship.owner().units(UnitType.Port);
@@ -640,6 +644,7 @@ export class WarshipExecution implements Execution {
if (dist <= 5) {
this.warship.owner().captureUnit(target);
this.warship.recordTradeCapture();
this.warship.setTargetUnit(undefined);
this.warship.touch();
return;
@@ -659,6 +664,7 @@ export class WarshipExecution implements Execution {
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.owner().captureUnit(target);
this.warship.recordTradeCapture();
this.warship.setTargetUnit(undefined);
this.warship.touch();
return;
+14
View File
@@ -32,6 +32,10 @@ export type WarshipState = {
retreatPort?: TileRef;
isInCombat?: boolean;
lastCombatTick: number;
// Veterancy level (0max) plus a shared integer progress meter fed by
// transport kills and trade captures (see UnitImpl.addVeterancyProgress).
veterancy: number;
veterancyProgress: number;
};
export type TransportShipState = {
@@ -480,8 +484,18 @@ export interface Unit {
transportShipState(): TransportShipState;
updateTransportShipState(update: Partial<TransportShipState>): void;
health(): number;
/** Effective max health, including any warship veterancy bonus. */
maxHealth(): number;
modifyHealth(delta: number, attacker?: Player): void;
// Warship veterancy
/** Current veterancy level from warshipState (0 for non-warships). */
veterancy(): number;
/** Record this warship destroying an enemy unit (drives veterancy gain). */
recordKill(targetType: UnitType): void;
/** Record this warship capturing a trade ship (drives veterancy gain). */
recordTradeCapture(): void;
// Troops
setTroops(troops: number): void;
troops(): number;
+94 -1
View File
@@ -16,6 +16,7 @@ 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;
@@ -71,6 +72,8 @@ export class UnitImpl implements Unit {
state: "patrolling",
patrolTile: params.patrolTile,
lastCombatTick: -100,
veterancy: 0,
veterancyProgress: 0,
};
}
this._targetUnit =
@@ -220,12 +223,22 @@ export class UnitImpl implements Unit {
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.info().maxHealth ?? 1),
toInt(this.maxHealth()),
);
if (nextHealth === previousHealth) {
@@ -371,6 +384,8 @@ export class UnitImpl implements Unit {
patrolTile: merged.patrolTile,
retreatPort: merged.retreatPort,
lastCombatTick: this._warshipState.lastCombatTick,
veterancy: this._warshipState.veterancy,
veterancyProgress: this._warshipState.veterancyProgress,
};
this.mg.addUpdate(this.toUpdate());
}
@@ -520,6 +535,84 @@ export class UnitImpl implements Unit {
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());
+23
View File
@@ -0,0 +1,23 @@
// Shared warship-veterancy math. Lives in src/core (integer percent math, no
// floats) so the engine and the renderer derive identical effective max health.
/**
* Effective max health for a warship at a given veterancy level.
*
* Each veterancy level adds `healthBonusPercent`% of base max health, floored to
* an integer to keep src/core deterministic. Returns `baseMaxHealth` unchanged
* at veterancy 0 (and therefore for any non-veteran or non-warship unit).
*/
export function maxHealthWithVeterancy(
baseMaxHealth: number,
veterancy: number,
healthBonusPercent: number,
): number {
if (veterancy <= 0) {
return baseMaxHealth;
}
return (
baseMaxHealth +
Math.floor((baseMaxHealth * veterancy * healthBonusPercent) / 100)
);
}
+11 -11
View File
@@ -325,7 +325,7 @@ describe("Warship", () => {
}
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipRetreatHealthPercent = () => 60;
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
@@ -362,7 +362,7 @@ describe("Warship", () => {
game.config().warshipPassiveHealing = () => 0;
game.config().warshipPortHealingBonusPerLevel = () => 6;
game.config().warshipDockingRange = () => 5;
game.config().warshipRetreatHealthThreshold = () => 900;
game.config().warshipRetreatHealthPercent = () => 90;
const portTile = game.ref(coastX, 10);
player1.buildUnit(UnitType.Port, portTile, {});
@@ -395,7 +395,7 @@ describe("Warship", () => {
test("Warship waits at port when capacity is full", async () => {
game.config().warshipPassiveHealing = () => 0;
game.config().warshipDockingRange = () => 5;
game.config().warshipRetreatHealthThreshold = () => 900;
game.config().warshipRetreatHealthPercent = () => 90;
const portTile = game.ref(coastX, 10);
const warship1Tile = game.ref(coastX + 1, 11);
@@ -448,7 +448,7 @@ describe("Warship", () => {
game.config().warshipPassiveHealing = () => 0;
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipDockingRange = () => 5;
game.config().warshipRetreatHealthThreshold = () => 900;
game.config().warshipRetreatHealthPercent = () => 90;
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
@@ -524,7 +524,7 @@ describe("Warship", () => {
});
test("Warship cancels retreat if no friendly port is reachable by water", async () => {
game.config().warshipRetreatHealthThreshold = () => 900;
game.config().warshipRetreatHealthPercent = () => 90;
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
@@ -551,7 +551,7 @@ describe("Warship", () => {
test("Low-health warship retreats AND fires at nearby enemy warship", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipRetreatHealthPercent = () => 60;
game.config().warshipTargettingRange = () => 5;
game.config().warshipShellAttackRate = () => 10_000;
@@ -587,7 +587,7 @@ describe("Warship", () => {
test("Retreating warship aggroes nearby enemy transport before continuing retreat", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipRetreatHealthPercent = () => 60;
game.config().warshipTargettingRange = () => 5;
game.config().warshipShellAttackRate = () => 10_000;
@@ -634,7 +634,7 @@ describe("Warship", () => {
test("Manual MoveWarshipExecution cancels retreat and keeps manual order", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipRetreatHealthPercent = () => 60;
const homePortTile = game.ref(coastX, 10);
player1.buildUnit(UnitType.Port, homePortTile, {});
@@ -668,7 +668,7 @@ describe("Warship", () => {
test("Manual MoveWarshipExecution suppresses auto-retreat for 5 seconds before retreat starts", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipRetreatHealthPercent = () => 60;
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
@@ -760,7 +760,7 @@ describe("Warship", () => {
test("Docked warship is not targeted by enemy warship", async () => {
game.config().warshipPassiveHealing = () => 0;
game.config().warshipDockingRange = () => 5;
game.config().warshipRetreatHealthThreshold = () => 900;
game.config().warshipRetreatHealthPercent = () => 90;
game.config().warshipTargettingRange = () => 20;
const portTile = game.ref(coastX, 10);
@@ -800,7 +800,7 @@ describe("Warship", () => {
test("Retreating warship continues moving to port after firing back", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipRetreatHealthPercent = () => 60;
game.config().warshipTargettingRange = () => 5;
game.config().warshipShellAttackRate = () => 10_000;
+226
View File
@@ -0,0 +1,226 @@
import { ShellExecution } from "../src/core/execution/ShellExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
Unit,
UnitType,
} from "../src/core/game/Game";
import { setup } from "./util/Setup";
const coastX = 7;
let game: Game;
let attacker: Player;
let defender: Player;
describe("Warship veterancy", () => {
beforeEach(async () => {
game = await setup(
"half_land_half_ocean",
{ infiniteGold: true, instantBuild: true },
[
new PlayerInfo("attacker", PlayerType.Human, null, "player_1_id"),
new PlayerInfo("defender", PlayerType.Human, null, "player_2_id"),
],
);
attacker = game.player("player_1_id");
defender = game.player("player_2_id");
});
function buildWarship(player: Player, x: number, y: number): Unit {
return player.buildUnit(UnitType.Warship, game.ref(x, y), {
patrolTile: game.ref(x, y),
});
}
test("killing an enemy warship grants one veterancy level", () => {
const ship = buildWarship(attacker, coastX, 10);
expect(ship.veterancy()).toBe(0);
ship.recordKill(UnitType.Warship);
expect(ship.veterancy()).toBe(1);
});
test("veterancy is capped at the configured maximum", () => {
const ship = buildWarship(attacker, coastX, 10);
const max = game.config().warshipMaxVeterancy();
for (let i = 0; i < max + 3; i++) {
ship.recordKill(UnitType.Warship);
}
expect(ship.veterancy()).toBe(max);
});
test("destroying transport ships alone fills a level at the threshold", () => {
const ship = buildWarship(attacker, coastX, 10);
const threshold = game.config().warshipVeterancyTransportKills();
for (let i = 0; i < threshold - 1; i++) {
ship.recordKill(UnitType.TransportShip);
}
expect(ship.veterancy()).toBe(0);
ship.recordKill(UnitType.TransportShip);
expect(ship.veterancy()).toBe(1);
});
test("capturing trade ships alone fills a level at the threshold", () => {
const ship = buildWarship(attacker, coastX, 10);
const threshold = game.config().warshipVeterancyTradeCaptures();
for (let i = 0; i < threshold - 1; i++) {
ship.recordTradeCapture();
}
expect(ship.veterancy()).toBe(0);
ship.recordTradeCapture();
expect(ship.veterancy()).toBe(1);
});
test("transports and captures share one progress meter", () => {
const ship = buildWarship(attacker, coastX, 10);
// Defaults: 10 transports OR 25 captures = 1 level, so a transport is worth
// 1/10 of a level and a capture 1/25. Mixed progress combines.
for (let i = 0; i < 5; i++) ship.recordKill(UnitType.TransportShip);
for (let i = 0; i < 12; i++) ship.recordTradeCapture();
expect(ship.veterancy()).toBe(0); // 5/10 + 12/25 = 0.98 < 1
ship.recordTradeCapture();
expect(ship.veterancy()).toBe(1); // 5/10 + 13/25 = 1.02 ≥ 1
});
test("a warship kill resets transport/capture progress", () => {
const ship = buildWarship(attacker, coastX, 10);
const threshold = game.config().warshipVeterancyTransportKills();
// Build up 9/10 of a level from transports (no level yet).
for (let i = 0; i < threshold - 1; i++) {
ship.recordKill(UnitType.TransportShip);
}
expect(ship.veterancy()).toBe(0);
// A warship kill grants a level AND wipes the partial progress.
ship.recordKill(UnitType.Warship);
expect(ship.veterancy()).toBe(1);
// Had progress carried, this transport would have completed level 2.
// Since it reset, we're still at level 1.
ship.recordKill(UnitType.TransportShip);
expect(ship.veterancy()).toBe(1);
});
test("partial progress carries past a level-up", () => {
const ship = buildWarship(attacker, coastX, 10);
const threshold = game.config().warshipVeterancyTradeCaptures();
// One past the threshold → level 1 with 1 capture's worth carried over.
for (let i = 0; i < threshold + 1; i++) ship.recordTradeCapture();
expect(ship.veterancy()).toBe(1);
// The carried progress means one fewer capture completes level 2.
for (let i = 0; i < threshold - 1; i++) ship.recordTradeCapture();
expect(ship.veterancy()).toBe(2);
});
test("veterancy raises max health but does not instantly heal", () => {
const ship = buildWarship(attacker, coastX, 10);
const base = game.config().unitInfo(UnitType.Warship).maxHealth!;
const bonusPercent = game.config().warshipVeterancyHealthBonus();
// Drop below full so a (removed) instant heal would be observable.
ship.modifyHealth(-100);
expect(ship.maxHealth()).toBe(base);
expect(ship.health()).toBe(base - 100);
ship.recordKill(UnitType.Warship); // veterancy 1
// The cap rises, but current health is unchanged — the ship heals toward
// the new max normally, it does not jump on level-up.
expect(ship.maxHealth()).toBe(
base + Math.floor((base * 1 * bonusPercent) / 100),
);
expect(ship.health()).toBe(base - 100);
});
test("non-warships never gain veterancy", () => {
const transport = defender.buildUnit(
UnitType.TransportShip,
game.ref(coastX, 10),
{},
);
transport.recordKill(UnitType.Warship);
transport.recordTradeCapture();
expect(transport.veterancy()).toBe(0);
});
test("shell damage scales with the firing warship's veterancy", () => {
const maxVet = game.config().warshipMaxVeterancy();
const bonusPercent = game.config().warshipVeterancyShellDamageBonus();
const target = buildWarship(defender, coastX + 5, 10);
const baseShooter = buildWarship(attacker, coastX, 10);
const vetShooter = buildWarship(attacker, coastX + 1, 10);
for (let i = 0; i < maxVet; i++) {
vetShooter.recordKill(UnitType.Warship);
}
expect(vetShooter.veterancy()).toBe(maxVet);
const boostedValues = new Set<number>();
for (let i = 0; i < 30; i++) {
// Advance the tick so each pair of shells rolls a different seed.
game.executeNextTick();
const baseShell = new ShellExecution(
baseShooter.tile(),
attacker,
baseShooter,
target,
);
const vetShell = new ShellExecution(
vetShooter.tile(),
attacker,
vetShooter,
target,
);
baseShell.init(game, game.ticks());
vetShell.init(game, game.ticks());
const dBase = baseShell.getEffectOnTargetForTesting();
const dVet = vetShell.getEffectOnTargetForTesting();
// Same seed → same roll. Base damage is 250, so dBase equals the rolled
// multiplier and the veteran's shot is the integer-boosted value.
expect(dVet).toBe(
Math.floor((dBase * (100 + maxVet * bonusPercent)) / 100),
);
boostedValues.add(dVet);
}
// The roll varied across ticks (not a constant).
expect(boostedValues.size).toBeGreaterThan(1);
});
test("a shell landing the killing blow awards veterancy to the firing warship", () => {
const shooter = buildWarship(attacker, coastX, 10);
const target = buildWarship(defender, coastX + 1, 10);
// Leave the target on its last sliver of health so any shell finishes it.
target.modifyHealth(-(target.health() - 1));
expect(target.health()).toBe(1);
game.addExecution(
new ShellExecution(shooter.tile(), attacker, shooter, target),
);
for (let i = 0; i < 30 && target.isActive(); i++) {
game.executeNextTick();
}
expect(target.isActive()).toBe(false);
expect(shooter.veterancy()).toBe(1);
});
});
@@ -40,6 +40,7 @@ function unit(overrides: Partial<UnitState> = {}): UnitState {
troops: 0,
missileTimerQueue: [],
level: 1,
veterancy: 0,
hasTrainStation: false,
trainType: null,
loaded: null,
@@ -71,6 +71,7 @@ function nuke(overrides: Partial<UnitState> = {}): UnitState {
troops: 0,
missileTimerQueue: [],
level: 1,
veterancy: 0,
hasTrainStation: false,
trainType: null,
loaded: null,
@@ -67,6 +67,7 @@ function unit(overrides: Partial<UnitState> = {}): UnitState {
troops: 0,
missileTimerQueue: [],
level: 1,
veterancy: 0,
hasTrainStation: false,
trainType: null,
loaded: null,