From 4f4f9185f1159420b1b84d72139c5a1d725cb788 Mon Sep 17 00:00:00 2001 From: bijx Date: Sat, 27 Jun 2026 22:27:41 -0400 Subject: [PATCH 01/10] render + shading --- src/client/render/gl/RenderSettings.ts | 9 +++++++++ src/client/render/gl/render-settings.json | 10 +++++++++- src/client/render/gl/shaders/bar/bar.frag.glsl | 8 ++++++++ src/client/render/gl/shaders/bar/bar.vert.glsl | 7 ++++++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 044a684085..02d369deda 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/render-settings.json b/src/client/render/gl/render-settings.json index fb689c6d7e..310998a2e6 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 9ebb15ab62..0de8ec0c39 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 237b906ff9..2dc31261a4 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); From 7c40ca9f51b4fc5f0b2696ed6195544734c848d2 Mon Sep 17 00:00:00 2001 From: bijx Date: Sat, 27 Jun 2026 22:28:05 -0400 Subject: [PATCH 02/10] Enhance BarPass to include warship veterancy pips and adjust health calculations. Updated UnitState and UnitView to support veterancy data, ensuring proper rendering and health bonus calculations for veteran warships. --- src/client/render/gl/passes/BarPass.ts | 87 ++++++++++++++++++++++---- src/client/render/types/Renderer.ts | 1 + src/client/view/UnitView.ts | 19 ++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/client/render/gl/passes/BarPass.ts b/src/client/render/gl/passes/BarPass.ts index c7d9507116..266463cc2d 100644 --- a/src/client/render/gl/passes/BarPass.ts +++ b/src/client/render/gl/passes/BarPass.ts @@ -1,13 +1,15 @@ /** - * 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"; @@ -46,6 +48,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 +59,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 +76,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 +89,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 +136,25 @@ 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. Round to match the + // engine's UnitImpl.maxHealth() so a full veteran ship reads as full. + const maxHealth = Math.round( + this.warshipMaxHealth * + (1 + 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 +167,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 +224,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 +267,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/types/Renderer.ts b/src/client/render/types/Renderer.ts index 3da08ab0b7..15d0c58d27 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 4c0d077b3f..8df2c7b72b 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.veterancy, 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.veterancy; target.hasTrainStation = u.hasTrainStation; target.trainType = trainTypeToNum(u.trainType); target.loaded = u.loaded ?? null; @@ -230,6 +232,23 @@ export class UnitView { health(): number { return this.state.health ?? 0; } + maxHealth(): number { + const base = this.gameView.config().unitInfo(this.type()).maxHealth ?? 1; + if (this.type() === UnitType.Warship && this.state.veterancy > 0) { + const bonus = this.gameView.config().warshipVeterancyHealthBonus(); + return Math.round(base * (1 + this.state.veterancy * bonus)); + } + return base; + } + 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; } From f1ce6923a9b5f5c7c17c324cf384f90edbe00849 Mon Sep 17 00:00:00 2001 From: bijx Date: Sat, 27 Jun 2026 22:28:40 -0400 Subject: [PATCH 03/10] Add warship veterancy system: Introduced methods for managing veterancy levels, health bonuses, and requirements for warships in Config, Game, and GameUpdates. Updated interfaces to support veterancy tracking and calculations. --- src/core/configuration/Config.ts | 27 +++++++++++++++++++++++++++ src/core/game/Game.ts | 10 ++++++++++ src/core/game/GameUpdates.ts | 1 + 3 files changed, 38 insertions(+) diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f2247497c4..5d25a16801 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -935,6 +935,33 @@ 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 a fraction of base max health. */ + warshipVeterancyHealthBonus(): number { + return 0.2; + } + + /** Shell-damage boost per veterancy level, as a fraction of the rolled damage. */ + warshipVeterancyShellDamageBonus(): number { + return 0.2; + } + + /** 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/game/Game.ts b/src/core/game/Game.ts index 63e3d160ec..accd980ead 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -480,8 +480,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 (0 = none). Always 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/GameUpdates.ts b/src/core/game/GameUpdates.ts index 1cc35996c5..99780c1ba7 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -189,6 +189,7 @@ export interface UnitUpdate { underConstruction?: boolean; missileTimerQueue: number[]; level: number; + veterancy: number; hasTrainStation: boolean; trainType?: TrainType; // Only for trains loaded?: boolean; // Only for trains From 395aa3c7829bb19fc0325be1dae59cb34ad5c12f Mon Sep 17 00:00:00 2001 From: bijx Date: Sat, 27 Jun 2026 22:29:02 -0400 Subject: [PATCH 04/10] Implement warship veterancy features: Added methods for tracking veterancy levels, health bonuses, and kill records for warships. Updated ShellExecution and WarshipExecution to incorporate veterancy mechanics, enhancing gameplay dynamics. Adjusted UnitImpl to manage veterancy state and calculations effectively. --- src/core/execution/ShellExecution.ts | 23 ++++++++- src/core/execution/WarshipExecution.ts | 7 +-- src/core/game/UnitImpl.ts | 71 +++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index b28101e163..5011b5a9ce 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; @@ -69,7 +81,16 @@ export class ShellExecution implements Execution { const roll = this.random.nextInt(1, 6); const damageMultiplier = (roll - 1) * 25 + 200; - return Math.round((baseDamage / 250) * damageMultiplier); + let damageDealt = (baseDamage / 250) * damageMultiplier; + + // Veteran warships hit harder — scale by the firing unit's veterancy. + const veterancy = this.ownerUnit.veterancy(); + if (veterancy > 0) { + const bonus = this.mg.config().warshipVeterancyShellDamageBonus(); + damageDealt *= 1 + veterancy * bonus; + } + + return Math.round(damageDealt); } public getEffectOnTargetForTesting(): number { diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index bd29e55211..78806131a2 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( @@ -640,6 +639,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 +659,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/UnitImpl.ts b/src/core/game/UnitImpl.ts index 5071d51f0c..73f0311490 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -37,6 +37,10 @@ export class UnitImpl implements Unit { private _missileTimerQueue: number[] = []; private _hasTrainStation: boolean = false; private _level: number = 1; + // Warship veterancy: level (0–max) plus running tallies toward the next level. + private _veterancy: number = 0; + private _transportKills: number = 0; + private _tradeCaptures: number = 0; private _targetable: boolean = true; private _loaded: boolean | undefined; private _trainType: TrainType | undefined; @@ -148,6 +152,7 @@ export class UnitImpl implements Unit { targetTile: this.targetTile() ?? undefined, missileTimerQueue: this._missileTimerQueue, level: this.level(), + veterancy: this._veterancy, hasTrainStation: this._hasTrainStation, trainType: this._trainType, loaded: this._loaded, @@ -220,12 +225,21 @@ export class UnitImpl implements Unit { this.mg.addUpdate(this.toUpdate()); } + maxHealth(): number { + const base = this.info().maxHealth ?? 1; + if (this._type === UnitType.Warship && this._veterancy > 0) { + const bonus = this.mg.config().warshipVeterancyHealthBonus(); + return Math.round(base * (1 + this._veterancy * bonus)); + } + return base; + } + 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) { @@ -520,6 +534,61 @@ export class UnitImpl implements Unit { return this._level; } + veterancy(): number { + return this._veterancy; + } + + /** Raise veterancy by one level (capped), boosting max health and healing + * by the per-level bonus. No-op for non-warships or at the cap. */ + private increaseVeterancy(): void { + if (this._type !== UnitType.Warship) { + return; + } + if (this._veterancy >= this.mg.config().warshipMaxVeterancy()) { + return; + } + this._veterancy++; + // Reward the new level by healing the per-level bonus amount, then re-clamp + // to the now-higher max health. + const base = this.info().maxHealth ?? 0; + const bonus = base * this.mg.config().warshipVeterancyHealthBonus(); + this._health = withinInt( + this._health + toInt(bonus), + 0n, + toInt(this.maxHealth()), + ); + this.mg.addUpdate(this.toUpdate()); + } + + recordKill(targetType: UnitType): void { + if (this._type !== UnitType.Warship) { + return; + } + if (targetType === UnitType.Warship) { + // A killing blow on an enemy warship grants a level outright. + this.increaseVeterancy(); + } else if (targetType === UnitType.TransportShip) { + this._transportKills++; + const threshold = this.mg.config().warshipVeterancyTransportKills(); + if (this._transportKills >= threshold) { + this._transportKills -= threshold; + this.increaseVeterancy(); + } + } + } + + recordTradeCapture(): void { + if (this._type !== UnitType.Warship) { + return; + } + this._tradeCaptures++; + const threshold = this.mg.config().warshipVeterancyTradeCaptures(); + if (this._tradeCaptures >= threshold) { + this._tradeCaptures -= threshold; + this.increaseVeterancy(); + } + } + setTrainStation(trainStation: boolean): void { this._hasTrainStation = trainStation; this.mg.addUpdate(this.toUpdate()); From be276dcef873c6e2b9823c5435cf49b4f0d52452 Mon Sep 17 00:00:00 2001 From: bijx Date: Sat, 27 Jun 2026 22:29:16 -0400 Subject: [PATCH 05/10] tests --- tests/WarshipVeterancy.test.ts | 172 ++++++++++++++++++ .../client/render/frame/TrailManager.test.ts | 1 + .../frame/derive/nuke-telegraphs.test.ts | 1 + .../render/frame/derive/player-status.test.ts | 1 + 4 files changed, 175 insertions(+) create mode 100644 tests/WarshipVeterancy.test.ts diff --git a/tests/WarshipVeterancy.test.ts b/tests/WarshipVeterancy.test.ts new file mode 100644 index 0000000000..a9641b8574 --- /dev/null +++ b/tests/WarshipVeterancy.test.ts @@ -0,0 +1,172 @@ +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 grants veterancy 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 grants veterancy 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("veterancy boosts max health and heals to the new max", () => { + const ship = buildWarship(attacker, coastX, 10); + const base = game.config().unitInfo(UnitType.Warship).maxHealth!; + const bonus = game.config().warshipVeterancyHealthBonus(); + + expect(ship.maxHealth()).toBe(base); + expect(ship.health()).toBe(base); + + ship.recordKill(UnitType.Warship); // veterancy 1 + + expect(ship.maxHealth()).toBe(Math.round(base * (1 + bonus))); + expect(ship.health()).toBe(Math.round(base * (1 + bonus))); + }); + + 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 bonusPerLevel = 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, so the veteran's shot is exactly the boosted value. + expect(dVet).toBe(Math.round(dBase * (1 + maxVet * bonusPerLevel))); + 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 356d399608..c5e455ec21 100644 --- a/tests/client/render/frame/TrailManager.test.ts +++ b/tests/client/render/frame/TrailManager.test.ts @@ -37,6 +37,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 2669a61f55..224a20831e 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 f6b44b332b..1d1b3adbf5 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, From ec9f5461811595574aa8f40ac55e8e000a4f24bb Mon Sep 17 00:00:00 2001 From: bijx Date: Sat, 27 Jun 2026 22:29:26 -0400 Subject: [PATCH 06/10] Add veterancy property to unit update function: Initialized veterancy to 0 in makeUnitUpdate for improved warship management. --- tests/util/viewStubs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/util/viewStubs.ts b/tests/util/viewStubs.ts index af8e3b75d4..842023d894 100644 --- a/tests/util/viewStubs.ts +++ b/tests/util/viewStubs.ts @@ -159,6 +159,7 @@ export function makeUnitUpdate( markedForDeletion: false, missileTimerQueue: [], level: 1, + veterancy: 0, hasTrainStation: false, ...overrides, }; From 914368ffdc6cd8cc4dcf4aa78905f444eddeeb74 Mon Sep 17 00:00:00 2001 From: bijx Date: Sat, 27 Jun 2026 23:06:22 -0400 Subject: [PATCH 07/10] fixed fractional bonus issue by coderabbit --- src/client/render/gl/passes/BarPass.ts | 14 ++++++++------ src/client/view/UnitView.ts | 7 +++++-- src/core/configuration/Config.ts | 10 ++++++---- src/core/execution/ShellExecution.ts | 15 ++++++++------- src/core/game/UnitImpl.ts | 10 ++++++---- tests/WarshipVeterancy.test.ts | 16 ++++++++++------ 6 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/client/render/gl/passes/BarPass.ts b/src/client/render/gl/passes/BarPass.ts index 266463cc2d..4fcdde9495 100644 --- a/src/client/render/gl/passes/BarPass.ts +++ b/src/client/render/gl/passes/BarPass.ts @@ -143,12 +143,14 @@ export class BarPass { // warship-only. for (const unit of mobileUnits.values()) { if (unit.health === null || unit.health <= 0) continue; - // Veteran warships have a higher effective max health. Round to match the - // engine's UnitImpl.maxHealth() so a full veteran ship reads as full. - const maxHealth = Math.round( - this.warshipMaxHealth * - (1 + unit.veterancy * this.veterancyHealthBonus), - ); + // Veteran warships have a higher effective max health. Mirror the engine's + // integer UnitImpl.maxHealth() so a full veteran ship reads as full. + const maxHealth = + this.warshipMaxHealth + + Math.floor( + (this.warshipMaxHealth * unit.veterancy * this.veterancyHealthBonus) / + 100, + ); if (unit.health < maxHealth) { this.pushHealth(unit, unit.health / maxHealth); } diff --git a/src/client/view/UnitView.ts b/src/client/view/UnitView.ts index 8df2c7b72b..5b36198fcc 100644 --- a/src/client/view/UnitView.ts +++ b/src/client/view/UnitView.ts @@ -235,8 +235,11 @@ export class UnitView { maxHealth(): number { const base = this.gameView.config().unitInfo(this.type()).maxHealth ?? 1; if (this.type() === UnitType.Warship && this.state.veterancy > 0) { - const bonus = this.gameView.config().warshipVeterancyHealthBonus(); - return Math.round(base * (1 + this.state.veterancy * bonus)); + // Mirror the engine's integer UnitImpl.maxHealth(). + const bonusPercent = this.gameView.config().warshipVeterancyHealthBonus(); + return ( + base + Math.floor((base * this.state.veterancy * bonusPercent) / 100) + ); } return base; } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 5d25a16801..4d159a62e2 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -942,14 +942,16 @@ export class Config { return 3; } - /** Max-health boost per veterancy level, as a fraction of base max health. */ + /** 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 0.2; + return 20; } - /** Shell-damage boost per veterancy level, as a fraction of the rolled damage. */ + /** Shell-damage boost per veterancy level, as an integer percent of the + * rolled damage. Integer-only to keep src/core deterministic. */ warshipVeterancyShellDamageBonus(): number { - return 0.2; + return 20; } /** Transport ships a warship must destroy to gain one veterancy level. */ diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index 5011b5a9ce..e7c2feb28d 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -79,18 +79,19 @@ 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; - let damageDealt = (baseDamage / 250) * damageMultiplier; - - // Veteran warships hit harder — scale by the firing unit's veterancy. + // 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 bonus = this.mg.config().warshipVeterancyShellDamageBonus(); - damageDealt *= 1 + veterancy * bonus; + const bonusPercent = this.mg.config().warshipVeterancyShellDamageBonus(); + damageMultiplier = Math.floor( + (damageMultiplier * (100 + veterancy * bonusPercent)) / 100, + ); } - return Math.round(damageDealt); + return Math.round((baseDamage / 250) * damageMultiplier); } public getEffectOnTargetForTesting(): number { diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 73f0311490..25635fbee1 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -228,8 +228,9 @@ export class UnitImpl implements Unit { maxHealth(): number { const base = this.info().maxHealth ?? 1; if (this._type === UnitType.Warship && this._veterancy > 0) { - const bonus = this.mg.config().warshipVeterancyHealthBonus(); - return Math.round(base * (1 + this._veterancy * bonus)); + // Integer percent math — keep src/core float-free for determinism. + const bonusPercent = this.mg.config().warshipVeterancyHealthBonus(); + return base + Math.floor((base * this._veterancy * bonusPercent) / 100); } return base; } @@ -549,9 +550,10 @@ export class UnitImpl implements Unit { } this._veterancy++; // Reward the new level by healing the per-level bonus amount, then re-clamp - // to the now-higher max health. + // to the now-higher max health. Integer percent math (no float constants). const base = this.info().maxHealth ?? 0; - const bonus = base * this.mg.config().warshipVeterancyHealthBonus(); + const bonusPercent = this.mg.config().warshipVeterancyHealthBonus(); + const bonus = Math.floor((base * bonusPercent) / 100); this._health = withinInt( this._health + toInt(bonus), 0n, diff --git a/tests/WarshipVeterancy.test.ts b/tests/WarshipVeterancy.test.ts index a9641b8574..9bfcb91366 100644 --- a/tests/WarshipVeterancy.test.ts +++ b/tests/WarshipVeterancy.test.ts @@ -83,15 +83,16 @@ describe("Warship veterancy", () => { test("veterancy boosts max health and heals to the new max", () => { const ship = buildWarship(attacker, coastX, 10); const base = game.config().unitInfo(UnitType.Warship).maxHealth!; - const bonus = game.config().warshipVeterancyHealthBonus(); + const bonusPercent = game.config().warshipVeterancyHealthBonus(); expect(ship.maxHealth()).toBe(base); expect(ship.health()).toBe(base); ship.recordKill(UnitType.Warship); // veterancy 1 - expect(ship.maxHealth()).toBe(Math.round(base * (1 + bonus))); - expect(ship.health()).toBe(Math.round(base * (1 + bonus))); + const expected = base + Math.floor((base * 1 * bonusPercent) / 100); + expect(ship.maxHealth()).toBe(expected); + expect(ship.health()).toBe(expected); }); test("non-warships never gain veterancy", () => { @@ -109,7 +110,7 @@ describe("Warship veterancy", () => { test("shell damage scales with the firing warship's veterancy", () => { const maxVet = game.config().warshipMaxVeterancy(); - const bonusPerLevel = game.config().warshipVeterancyShellDamageBonus(); + const bonusPercent = game.config().warshipVeterancyShellDamageBonus(); const target = buildWarship(defender, coastX + 5, 10); const baseShooter = buildWarship(attacker, coastX, 10); @@ -142,8 +143,11 @@ describe("Warship veterancy", () => { const dBase = baseShell.getEffectOnTargetForTesting(); const dVet = vetShell.getEffectOnTargetForTesting(); - // Same seed → same roll, so the veteran's shot is exactly the boosted value. - expect(dVet).toBe(Math.round(dBase * (1 + maxVet * bonusPerLevel))); + // 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); } From c7af9af5dd2b8245b016ff4ba2c5f3d69349366c Mon Sep 17 00:00:00 2001 From: bijx Date: Sun, 28 Jun 2026 03:35:59 -0400 Subject: [PATCH 08/10] remove instant heal --- src/core/game/UnitImpl.ts | 15 +++------------ tests/WarshipVeterancy.test.ts | 15 ++++++++++----- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 25635fbee1..d8b63ceb66 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -539,8 +539,9 @@ export class UnitImpl implements Unit { return this._veterancy; } - /** Raise veterancy by one level (capped), boosting max health and healing - * by the per-level bonus. No-op for non-warships or at the cap. */ + /** 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._type !== UnitType.Warship) { return; @@ -549,16 +550,6 @@ export class UnitImpl implements Unit { return; } this._veterancy++; - // Reward the new level by healing the per-level bonus amount, then re-clamp - // to the now-higher max health. Integer percent math (no float constants). - const base = this.info().maxHealth ?? 0; - const bonusPercent = this.mg.config().warshipVeterancyHealthBonus(); - const bonus = Math.floor((base * bonusPercent) / 100); - this._health = withinInt( - this._health + toInt(bonus), - 0n, - toInt(this.maxHealth()), - ); this.mg.addUpdate(this.toUpdate()); } diff --git a/tests/WarshipVeterancy.test.ts b/tests/WarshipVeterancy.test.ts index 9bfcb91366..600974142a 100644 --- a/tests/WarshipVeterancy.test.ts +++ b/tests/WarshipVeterancy.test.ts @@ -80,19 +80,24 @@ describe("Warship veterancy", () => { expect(ship.veterancy()).toBe(1); }); - test("veterancy boosts max health and heals to the new max", () => { + 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); + expect(ship.health()).toBe(base - 100); ship.recordKill(UnitType.Warship); // veterancy 1 - const expected = base + Math.floor((base * 1 * bonusPercent) / 100); - expect(ship.maxHealth()).toBe(expected); - expect(ship.health()).toBe(expected); + // 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", () => { From 02854dc5f23ad1540a10fee9cd176e3ef09a6dc6 Mon Sep 17 00:00:00 2001 From: bijx Date: Mon, 29 Jun 2026 01:11:13 -0400 Subject: [PATCH 09/10] Refactor warship veterancy system to consolidate transport kills and trade captures into a shared progress meter. Update related tests to reflect new behavior and ensure proper functionality of veterancy level progression. --- src/core/game/UnitImpl.ts | 57 +++++++++++++++++++++++++--------- tests/WarshipVeterancy.test.ts | 49 +++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index d8b63ceb66..e73ba1cbe8 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -37,10 +37,10 @@ export class UnitImpl implements Unit { private _missileTimerQueue: number[] = []; private _hasTrainStation: boolean = false; private _level: number = 1; - // Warship veterancy: level (0–max) plus running tallies toward the next level. + // Warship veterancy: level (0–max) plus a shared integer progress meter fed + // by transport kills and trade captures (see addVeterancyProgress). private _veterancy: number = 0; - private _transportKills: number = 0; - private _tradeCaptures: number = 0; + private _veterancyProgress: number = 0; private _targetable: boolean = true; private _loaded: boolean | undefined; private _trainType: TrainType | undefined; @@ -558,15 +558,12 @@ export class UnitImpl implements Unit { return; } if (targetType === UnitType.Warship) { - // A killing blow on an enemy warship grants a level outright. + // Final blow on an enemy warship: instant level, and the partial + // transport/capture progress toward the next level is wiped. + this._veterancyProgress = 0; this.increaseVeterancy(); } else if (targetType === UnitType.TransportShip) { - this._transportKills++; - const threshold = this.mg.config().warshipVeterancyTransportKills(); - if (this._transportKills >= threshold) { - this._transportKills -= threshold; - this.increaseVeterancy(); - } + this.addVeterancyProgress(UnitType.TransportShip); } } @@ -574,12 +571,44 @@ export class UnitImpl implements Unit { if (this._type !== UnitType.Warship) { return; } - this._tradeCaptures++; - const threshold = this.mg.config().warshipVeterancyTradeCaptures(); - if (this._tradeCaptures >= threshold) { - this._tradeCaptures -= threshold; + 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._type !== UnitType.Warship) { + return; + } + const maxVeterancy = this.mg.config().warshipMaxVeterancy(); + if (this._veterancy >= maxVeterancy) { + return; + } + const transportThreshold = this.mg + .config() + .warshipVeterancyTransportKills(); + const captureThreshold = this.mg.config().warshipVeterancyTradeCaptures(); + const pointsPerLevel = transportThreshold * captureThreshold; + this._veterancyProgress += + source === UnitType.TransportShip ? captureThreshold : transportThreshold; + while ( + this._veterancyProgress >= pointsPerLevel && + this._veterancy < maxVeterancy + ) { + this._veterancyProgress -= pointsPerLevel; this.increaseVeterancy(); } + if (this._veterancy >= maxVeterancy) { + this._veterancyProgress = 0; + } } setTrainStation(trainStation: boolean): void { diff --git a/tests/WarshipVeterancy.test.ts b/tests/WarshipVeterancy.test.ts index 600974142a..a3efd9bb9f 100644 --- a/tests/WarshipVeterancy.test.ts +++ b/tests/WarshipVeterancy.test.ts @@ -54,7 +54,7 @@ describe("Warship veterancy", () => { expect(ship.veterancy()).toBe(max); }); - test("destroying transport ships grants veterancy at the threshold", () => { + test("destroying transport ships alone fills a level at the threshold", () => { const ship = buildWarship(attacker, coastX, 10); const threshold = game.config().warshipVeterancyTransportKills(); @@ -67,7 +67,7 @@ describe("Warship veterancy", () => { expect(ship.veterancy()).toBe(1); }); - test("capturing trade ships grants veterancy at the threshold", () => { + test("capturing trade ships alone fills a level at the threshold", () => { const ship = buildWarship(attacker, coastX, 10); const threshold = game.config().warshipVeterancyTradeCaptures(); @@ -80,6 +80,51 @@ describe("Warship veterancy", () => { 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!; From 52ee262cf49892b8f4153d997847bf7a326bf47c Mon Sep 17 00:00:00 2001 From: bijx Date: Mon, 29 Jun 2026 19:05:53 -0400 Subject: [PATCH 10/10] fix pr comments --- src/client/render/gl/passes/BarPass.ts | 16 ++++---- src/client/view/UnitView.ts | 15 +------ src/core/configuration/Config.ts | 6 ++- src/core/execution/WarshipExecution.ts | 11 ++++-- src/core/game/Game.ts | 6 ++- src/core/game/GameUpdates.ts | 1 - src/core/game/UnitImpl.ts | 54 +++++++++++++------------- src/core/game/Veterancy.ts | 23 +++++++++++ tests/Warship.test.ts | 22 +++++------ tests/util/viewStubs.ts | 1 - 10 files changed, 89 insertions(+), 66 deletions(-) create mode 100644 src/core/game/Veterancy.ts diff --git a/src/client/render/gl/passes/BarPass.ts b/src/client/render/gl/passes/BarPass.ts index 4fcdde9495..7a29a6e498 100644 --- a/src/client/render/gl/passes/BarPass.ts +++ b/src/client/render/gl/passes/BarPass.ts @@ -14,6 +14,7 @@ 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"; @@ -143,14 +144,13 @@ export class BarPass { // warship-only. for (const unit of mobileUnits.values()) { if (unit.health === null || unit.health <= 0) continue; - // Veteran warships have a higher effective max health. Mirror the engine's - // integer UnitImpl.maxHealth() so a full veteran ship reads as full. - const maxHealth = - this.warshipMaxHealth + - Math.floor( - (this.warshipMaxHealth * unit.veterancy * this.veterancyHealthBonus) / - 100, - ); + // 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); } diff --git a/src/client/view/UnitView.ts b/src/client/view/UnitView.ts index 5b36198fcc..c31aa6fdc5 100644 --- a/src/client/view/UnitView.ts +++ b/src/client/view/UnitView.ts @@ -65,7 +65,7 @@ function unitStateFromUpdate(u: UnitUpdate): UnitState { troops: u.troops, missileTimerQueue: u.missileTimerQueue, level: u.level, - veterancy: u.veterancy, + veterancy: u.warshipState?.veterancy ?? 0, hasTrainStation: u.hasTrainStation, trainType: trainTypeToNum(u.trainType), loaded: u.loaded ?? null, @@ -94,7 +94,7 @@ function applyUpdateInPlace(target: UnitState, u: UnitUpdate): void { target.troops = u.troops; target.missileTimerQueue = u.missileTimerQueue; target.level = u.level; - target.veterancy = u.veterancy; + target.veterancy = u.warshipState?.veterancy ?? 0; target.hasTrainStation = u.hasTrainStation; target.trainType = trainTypeToNum(u.trainType); target.loaded = u.loaded ?? null; @@ -232,17 +232,6 @@ export class UnitView { health(): number { return this.state.health ?? 0; } - maxHealth(): number { - const base = this.gameView.config().unitInfo(this.type()).maxHealth ?? 1; - if (this.type() === UnitType.Warship && this.state.veterancy > 0) { - // Mirror the engine's integer UnitImpl.maxHealth(). - const bonusPercent = this.gameView.config().warshipVeterancyHealthBonus(); - return ( - base + Math.floor((base * this.state.veterancy * bonusPercent) / 100) - ); - } - return base; - } veterancy(): number { return this.state.veterancy; } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 4d159a62e2..f2e00fbd8e 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 { diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 78806131a2..fb5f709558 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -169,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); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 18bf9c310b..f58bf3f14a 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 = { @@ -485,7 +489,7 @@ export interface Unit { modifyHealth(delta: number, attacker?: Player): void; // Warship veterancy - /** Current veterancy level (0 = none). Always 0 for non-warships. */ + /** 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; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 99780c1ba7..1cc35996c5 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -189,7 +189,6 @@ export interface UnitUpdate { underConstruction?: boolean; missileTimerQueue: number[]; level: number; - veterancy: number; hasTrainStation: boolean; trainType?: TrainType; // Only for trains loaded?: boolean; // Only for trains diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index e73ba1cbe8..1daabb9a3b 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; @@ -37,10 +38,6 @@ export class UnitImpl implements Unit { private _missileTimerQueue: number[] = []; private _hasTrainStation: boolean = false; private _level: number = 1; - // Warship veterancy: level (0–max) plus a shared integer progress meter fed - // by transport kills and trade captures (see addVeterancyProgress). - private _veterancy: number = 0; - private _veterancyProgress: number = 0; private _targetable: boolean = true; private _loaded: boolean | undefined; private _trainType: TrainType | undefined; @@ -75,6 +72,8 @@ export class UnitImpl implements Unit { state: "patrolling", patrolTile: params.patrolTile, lastCombatTick: -100, + veterancy: 0, + veterancyProgress: 0, }; } this._targetUnit = @@ -152,7 +151,6 @@ export class UnitImpl implements Unit { targetTile: this.targetTile() ?? undefined, missileTimerQueue: this._missileTimerQueue, level: this.level(), - veterancy: this._veterancy, hasTrainStation: this._hasTrainStation, trainType: this._trainType, loaded: this._loaded, @@ -227,12 +225,12 @@ export class UnitImpl implements Unit { maxHealth(): number { const base = this.info().maxHealth ?? 1; - if (this._type === UnitType.Warship && this._veterancy > 0) { - // Integer percent math — keep src/core float-free for determinism. - const bonusPercent = this.mg.config().warshipVeterancyHealthBonus(); - return base + Math.floor((base * this._veterancy * bonusPercent) / 100); - } - return base; + // 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 { @@ -386,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()); } @@ -536,31 +536,33 @@ export class UnitImpl implements Unit { } veterancy(): number { - return this._veterancy; + 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._type !== UnitType.Warship) { + if (this._warshipState === undefined) { return; } - if (this._veterancy >= this.mg.config().warshipMaxVeterancy()) { + if ( + this._warshipState.veterancy >= this.mg.config().warshipMaxVeterancy() + ) { return; } - this._veterancy++; + this._warshipState.veterancy++; this.mg.addUpdate(this.toUpdate()); } recordKill(targetType: UnitType): void { - if (this._type !== UnitType.Warship) { + 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._veterancyProgress = 0; + this._warshipState.veterancyProgress = 0; this.increaseVeterancy(); } else if (targetType === UnitType.TransportShip) { this.addVeterancyProgress(UnitType.TransportShip); @@ -568,7 +570,7 @@ export class UnitImpl implements Unit { } recordTradeCapture(): void { - if (this._type !== UnitType.Warship) { + if (this._warshipState === undefined) { return; } this.addVeterancyProgress(UnitType.TradeShip); @@ -585,11 +587,11 @@ export class UnitImpl implements Unit { * Overflow carries into the next level (only a warship kill resets it). */ private addVeterancyProgress(source: UnitType): void { - if (this._type !== UnitType.Warship) { + if (this._warshipState === undefined) { return; } const maxVeterancy = this.mg.config().warshipMaxVeterancy(); - if (this._veterancy >= maxVeterancy) { + if (this._warshipState.veterancy >= maxVeterancy) { return; } const transportThreshold = this.mg @@ -597,17 +599,17 @@ export class UnitImpl implements Unit { .warshipVeterancyTransportKills(); const captureThreshold = this.mg.config().warshipVeterancyTradeCaptures(); const pointsPerLevel = transportThreshold * captureThreshold; - this._veterancyProgress += + this._warshipState.veterancyProgress += source === UnitType.TransportShip ? captureThreshold : transportThreshold; while ( - this._veterancyProgress >= pointsPerLevel && - this._veterancy < maxVeterancy + this._warshipState.veterancyProgress >= pointsPerLevel && + this._warshipState.veterancy < maxVeterancy ) { - this._veterancyProgress -= pointsPerLevel; + this._warshipState.veterancyProgress -= pointsPerLevel; this.increaseVeterancy(); } - if (this._veterancy >= maxVeterancy) { - this._veterancyProgress = 0; + if (this._warshipState.veterancy >= maxVeterancy) { + this._warshipState.veterancyProgress = 0; } } diff --git a/src/core/game/Veterancy.ts b/src/core/game/Veterancy.ts new file mode 100644 index 0000000000..862ecbeeb0 --- /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 ea2dcd0511..0e19585cb1 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/util/viewStubs.ts b/tests/util/viewStubs.ts index 842023d894..af8e3b75d4 100644 --- a/tests/util/viewStubs.ts +++ b/tests/util/viewStubs.ts @@ -159,7 +159,6 @@ export function makeUnitUpdate( markedForDeletion: false, missileTimerQueue: [], level: 1, - veterancy: 0, hasTrainStation: false, ...overrides, };