From 006f1690a5100815671b630857e56d2512459a28 Mon Sep 17 00:00:00 2001 From: bijx Date: Thu, 2 Jul 2026 00:38:09 -0400 Subject: [PATCH] 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. image 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 --- src/client/render/gl/RenderSettings.ts | 9 + src/client/render/gl/passes/BarPass.ts | 89 ++++++- src/client/render/gl/render-settings.json | 10 +- .../render/gl/shaders/bar/bar.frag.glsl | 8 + .../render/gl/shaders/bar/bar.vert.glsl | 7 +- src/client/render/types/Renderer.ts | 1 + src/client/view/UnitView.ts | 11 + src/core/configuration/Config.ts | 35 ++- src/core/execution/ShellExecution.ts | 24 +- src/core/execution/WarshipExecution.ts | 18 +- src/core/game/Game.ts | 14 ++ src/core/game/UnitImpl.ts | 95 +++++++- src/core/game/Veterancy.ts | 23 ++ tests/Warship.test.ts | 22 +- tests/WarshipVeterancy.test.ts | 226 ++++++++++++++++++ .../client/render/frame/TrailManager.test.ts | 1 + .../frame/derive/nuke-telegraphs.test.ts | 1 + .../render/frame/derive/player-status.test.ts | 1 + 18 files changed, 561 insertions(+), 34 deletions(-) create mode 100644 src/core/game/Veterancy.ts create mode 100644 tests/WarshipVeterancy.test.ts diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 044a68408..02d369ded 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -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; diff --git a/src/client/render/gl/passes/BarPass.ts b/src/client/render/gl/passes/BarPass.ts index c7d950711..7a29a6e49 100644 --- a/src/client/render/gl/passes/BarPass.ts +++ b/src/client/render/gl/passes/BarPass.ts @@ -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; diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index c3c4e1269..c05f9027c 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -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, diff --git a/src/client/render/gl/shaders/bar/bar.frag.glsl b/src/client/render/gl/shaders/bar/bar.frag.glsl index 9ebb15ab6..0de8ec0c3 100644 --- a/src/client/render/gl/shaders/bar/bar.frag.glsl +++ b/src/client/render/gl/shaders/bar/bar.frag.glsl @@ -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; diff --git a/src/client/render/gl/shaders/bar/bar.vert.glsl b/src/client/render/gl/shaders/bar/bar.vert.glsl index 237b906ff..2dc31261a 100644 --- a/src/client/render/gl/shaders/bar/bar.vert.glsl +++ b/src/client/render/gl/shaders/bar/bar.vert.glsl @@ -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); diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 3da08ab0b..15d0c58d2 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -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; diff --git a/src/client/view/UnitView.ts b/src/client/view/UnitView.ts index 4c0d077b3..c31aa6fdc 100644 --- a/src/client/view/UnitView.ts +++ b/src/client/view/UnitView.ts @@ -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; } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f2247497c..f2e00fbd8 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -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; } diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index b28101e16..e7c2feb28 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -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); } diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index bd29e5521..fb5f70955 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -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; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index a6e3a5b84..f58bf3f14 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -32,6 +32,10 @@ export type WarshipState = { retreatPort?: TileRef; isInCombat?: boolean; lastCombatTick: number; + // Veterancy level (0–max) 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): 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; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 5071d51f0..1daabb9a3 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -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()); diff --git a/src/core/game/Veterancy.ts b/src/core/game/Veterancy.ts new file mode 100644 index 000000000..862ecbeeb --- /dev/null +++ b/src/core/game/Veterancy.ts @@ -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) + ); +} diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index ea2dcd051..0e19585cb 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -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; diff --git a/tests/WarshipVeterancy.test.ts b/tests/WarshipVeterancy.test.ts new file mode 100644 index 000000000..a3efd9bb9 --- /dev/null +++ b/tests/WarshipVeterancy.test.ts @@ -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(); + 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); + }); +}); diff --git a/tests/client/render/frame/TrailManager.test.ts b/tests/client/render/frame/TrailManager.test.ts index 212c38da1..efa8c006e 100644 --- a/tests/client/render/frame/TrailManager.test.ts +++ b/tests/client/render/frame/TrailManager.test.ts @@ -40,6 +40,7 @@ function unit(overrides: Partial = {}): UnitState { troops: 0, missileTimerQueue: [], level: 1, + veterancy: 0, hasTrainStation: false, trainType: null, loaded: null, diff --git a/tests/client/render/frame/derive/nuke-telegraphs.test.ts b/tests/client/render/frame/derive/nuke-telegraphs.test.ts index 2669a61f5..224a20831 100644 --- a/tests/client/render/frame/derive/nuke-telegraphs.test.ts +++ b/tests/client/render/frame/derive/nuke-telegraphs.test.ts @@ -71,6 +71,7 @@ function nuke(overrides: Partial = {}): UnitState { troops: 0, missileTimerQueue: [], level: 1, + veterancy: 0, hasTrainStation: false, trainType: null, loaded: null, diff --git a/tests/client/render/frame/derive/player-status.test.ts b/tests/client/render/frame/derive/player-status.test.ts index f6b44b332..1d1b3adbf 100644 --- a/tests/client/render/frame/derive/player-status.test.ts +++ b/tests/client/render/frame/derive/player-status.test.ts @@ -67,6 +67,7 @@ function unit(overrides: Partial = {}): UnitState { troops: 0, missileTimerQueue: [], level: 1, + veterancy: 0, hasTrainStation: false, trainType: null, loaded: null,