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/passes/BarPass.ts b/src/client/render/gl/passes/BarPass.ts index c7d9507116..7a29a6e498 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 c3c4e12698..c05f9027c1 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); 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..c31aa6fdc5 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 f2247497c4..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 { @@ -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 b28101e163..e7c2feb28d 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 bd29e55211..fb5f709558 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 a6e3a5b847..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 = { @@ -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 5071d51f0c..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; @@ -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 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/WarshipVeterancy.test.ts b/tests/WarshipVeterancy.test.ts new file mode 100644 index 0000000000..a3efd9bb9f --- /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 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,