mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 00:28:14 +00:00
## Description: Warship veterancy! This is an idea inspired by the unit veterancy feature of games like C&C: Red Alert 2 in which unit eliminations increases the level of individual units. I've been trying to build this mechanic for months with different ideas, and I finally landed on this being one of the more balanced implementation. Warships can earn up to three levels, represented by the gold bar insignia in the bottom right of their warship sprite. <img width="622" height="202" alt="image" src="https://github.com/user-attachments/assets/a8c31a45-4ae9-41a9-b054-9c4a7f4ab1f1" /> A veterancy bar grants 20% health from the base amount, and a 20% increase in shell damage applied _after_ the random damage roll. For example, a level 3 warship will apply a 60% damage boost on top of the random shell damage value (something between 200-325. If the random value is 250, the final damage output will be `250 * 1.60 = 400`. There are three ways to achieve a veteran level: 1. **Eliminate another warship:** any time a warship neutralizes another warship, it immediately get's a veterancy increase. https://github.com/user-attachments/assets/6a9e0958-5171-4ca3-94f6-9c2300a12f8b 2. **Eliminate transport boats:** Destroying 10 transport boats will level a warship to the next veterancy bar. https://github.com/user-attachments/assets/619ce0c0-033c-4e0b-9c64-b41eabaa791b 3. **Steal trade ships:** If the warship captures 25 trade ships, it will earn a veterancy bar. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: bijx
364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
/**
|
|
* computePlayerStatus has two modes:
|
|
*
|
|
* - Replay mode (no localPlayerSmallID): only crown / traitor / disconnected /
|
|
* nukeActive flags are populated. All relative flags are false.
|
|
* - Live mode (localPlayerSmallID set): also fills alliance / target / embargo,
|
|
* and nukeTargetsMe if a tileState buffer is supplied.
|
|
*
|
|
* The function only emits an entry per player when at least one flag is true
|
|
* (the NamePass treats missing entries as "all flags off"). Tests assert
|
|
* both presence and absence of entries.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { computePlayerStatus } from "../../../../../src/client/render/frame/derive/PlayerStatus";
|
|
import type {
|
|
PlayerState,
|
|
UnitState,
|
|
} from "../../../../../src/client/render/types";
|
|
import {
|
|
UT_ATOM_BOMB,
|
|
UT_WARSHIP,
|
|
} from "../../../../../src/client/render/types";
|
|
|
|
function ps(overrides: Partial<PlayerState> = {}): PlayerState {
|
|
return {
|
|
smallID: 1,
|
|
isAlive: true,
|
|
isDisconnected: false,
|
|
tilesOwned: 0,
|
|
gold: 0,
|
|
troops: 0,
|
|
isTraitor: false,
|
|
traitorRemainingTicks: 0,
|
|
betrayals: 0,
|
|
hasSpawned: true,
|
|
lastDeleteUnitTick: 0,
|
|
allies: [],
|
|
embargoes: [],
|
|
targets: [],
|
|
outgoingAttacks: [],
|
|
incomingAttacks: [],
|
|
outgoingAllianceRequests: [],
|
|
alliances: [],
|
|
outgoingEmojis: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function unit(overrides: Partial<UnitState> = {}): UnitState {
|
|
return {
|
|
id: 1,
|
|
unitType: UT_WARSHIP,
|
|
ownerID: 1,
|
|
lastOwnerID: null,
|
|
pos: 0,
|
|
lastPos: 0,
|
|
isActive: true,
|
|
reachedTarget: false,
|
|
retreating: false,
|
|
targetable: true,
|
|
markedForDeletion: false,
|
|
health: null,
|
|
underConstruction: false,
|
|
targetUnitId: null,
|
|
targetTile: null,
|
|
troops: 0,
|
|
missileTimerQueue: [],
|
|
level: 1,
|
|
veterancy: 0,
|
|
hasTrainStation: false,
|
|
trainType: null,
|
|
loaded: null,
|
|
constructionStartTick: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function playersMap(...players: PlayerState[]): Map<number, PlayerState> {
|
|
return new Map(players.map((p) => [p.smallID, p]));
|
|
}
|
|
|
|
function unitsMap(...us: UnitState[]): Map<number, UnitState> {
|
|
return new Map(us.map((u) => [u.id, u]));
|
|
}
|
|
|
|
describe("computePlayerStatus — replay mode (no localPlayerSmallID)", () => {
|
|
it("returns empty map when no flags are set", () => {
|
|
const players = playersMap(ps({ smallID: 1 }));
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.size).toBe(0);
|
|
});
|
|
|
|
it("crown goes to the alive player with most tiles owned", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, tilesOwned: 100 }),
|
|
ps({ smallID: 2, tilesOwned: 500 }), // king
|
|
ps({ smallID: 3, tilesOwned: 250 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(2)?.crown).toBe(true);
|
|
// Players 1 and 3 don't have crown and no other flags → no entry emitted.
|
|
expect(status.has(1)).toBe(false);
|
|
expect(status.has(3)).toBe(false);
|
|
});
|
|
|
|
it("dead players don't get the crown even if they had the most tiles", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, tilesOwned: 1000, isAlive: false }),
|
|
ps({ smallID: 2, tilesOwned: 100 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(2)?.crown).toBe(true);
|
|
expect(status.has(1)).toBe(false);
|
|
});
|
|
|
|
it("traitor + traitorRemainingTicks flow through", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, isTraitor: true, traitorRemainingTicks: 42 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(1)?.traitor).toBe(true);
|
|
expect(status.get(1)?.traitorRemainingTicks).toBe(42);
|
|
});
|
|
|
|
it("disconnected flag flows through", () => {
|
|
const players = playersMap(ps({ smallID: 1, isDisconnected: true }));
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(1)?.disconnected).toBe(true);
|
|
});
|
|
|
|
it("nukeActive: any in-flight nuke marks its owner", () => {
|
|
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
|
const units = unitsMap(
|
|
unit({ id: 10, ownerID: 2, unitType: UT_ATOM_BOMB, isActive: true }),
|
|
);
|
|
const status = computePlayerStatus(players, units);
|
|
expect(status.get(2)?.nukeActive).toBe(true);
|
|
expect(status.has(1)).toBe(false);
|
|
});
|
|
|
|
it("inactive nukes don't trigger nukeActive", () => {
|
|
const players = playersMap(ps({ smallID: 1 }));
|
|
const units = unitsMap(
|
|
unit({ id: 10, ownerID: 1, unitType: UT_ATOM_BOMB, isActive: false }),
|
|
);
|
|
const status = computePlayerStatus(players, units);
|
|
expect(status.has(1)).toBe(false);
|
|
});
|
|
|
|
it("relative flags (alliance/target/embargo/nukeTargetsMe) are always false in replay mode", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2], targets: [2], embargoes: [2] }),
|
|
ps({ smallID: 2, tilesOwned: 1 }), // crown so an entry exists
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(2)?.alliance).toBe(false);
|
|
expect(status.get(2)?.target).toBe(false);
|
|
expect(status.get(2)?.embargo).toBe(false);
|
|
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("computePlayerStatus — live mode (localPlayerSmallID set)", () => {
|
|
it("alliance: local has them as ally → alliance true", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }), // me
|
|
ps({ smallID: 2 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
expect(status.get(2)?.alliance).toBe(true);
|
|
});
|
|
|
|
it("target: local has them in targets → target true", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, targets: [2] }), // me
|
|
ps({ smallID: 2 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
expect(status.get(2)?.target).toBe(true);
|
|
});
|
|
|
|
it("embargo is bilateral: true if I embargo them OR they embargo me", () => {
|
|
// I embargo them.
|
|
let status = computePlayerStatus(
|
|
playersMap(ps({ smallID: 1, embargoes: [2] }), ps({ smallID: 2 })),
|
|
unitsMap(),
|
|
{ localPlayerSmallID: 1 },
|
|
);
|
|
expect(status.get(2)?.embargo).toBe(true);
|
|
|
|
// They embargo me.
|
|
status = computePlayerStatus(
|
|
playersMap(ps({ smallID: 1 }), ps({ smallID: 2, embargoes: [1] })),
|
|
unitsMap(),
|
|
{ localPlayerSmallID: 1 },
|
|
);
|
|
expect(status.get(2)?.embargo).toBe(true);
|
|
|
|
// Neither.
|
|
status = computePlayerStatus(
|
|
playersMap(ps({ smallID: 1 }), ps({ smallID: 2, tilesOwned: 1 })),
|
|
unitsMap(),
|
|
{ localPlayerSmallID: 1 },
|
|
);
|
|
// Player 2 only has crown — embargo should be false.
|
|
expect(status.get(2)?.embargo).toBe(false);
|
|
});
|
|
|
|
it("relative flags are NOT set for the local player itself (no self-relationships)", () => {
|
|
const players = playersMap(
|
|
ps({
|
|
smallID: 1,
|
|
tilesOwned: 100,
|
|
allies: [1],
|
|
targets: [1],
|
|
embargoes: [1],
|
|
}),
|
|
ps({ smallID: 2 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
// Player 1 (local) gets crown but no relative flags vs. self.
|
|
expect(status.get(1)?.crown).toBe(true);
|
|
expect(status.get(1)?.alliance).toBe(false);
|
|
expect(status.get(1)?.target).toBe(false);
|
|
expect(status.get(1)?.embargo).toBe(false);
|
|
});
|
|
|
|
it("nukeTargetsMe: requires tileState — without it, stays false", () => {
|
|
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
|
const units = unitsMap(
|
|
unit({
|
|
id: 10,
|
|
ownerID: 2,
|
|
unitType: UT_ATOM_BOMB,
|
|
isActive: true,
|
|
targetTile: 5,
|
|
}),
|
|
);
|
|
const status = computePlayerStatus(players, units, {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
expect(status.get(2)?.nukeActive).toBe(true);
|
|
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
|
});
|
|
|
|
it("nukeTargetsMe: true when nuke targets a tile owned by local player", () => {
|
|
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
|
const units = unitsMap(
|
|
unit({
|
|
id: 10,
|
|
ownerID: 2,
|
|
unitType: UT_ATOM_BOMB,
|
|
isActive: true,
|
|
targetTile: 5,
|
|
}),
|
|
);
|
|
// tileState[5] low 12 bits = 1 (local player's smallID).
|
|
const tileState = new Uint16Array(16);
|
|
tileState[5] = 1;
|
|
|
|
const status = computePlayerStatus(players, units, {
|
|
localPlayerSmallID: 1,
|
|
tileState,
|
|
});
|
|
expect(status.get(2)?.nukeTargetsMe).toBe(true);
|
|
});
|
|
|
|
it("nukeTargetsMe: false when nuke targets a tile owned by someone else", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1 }),
|
|
ps({ smallID: 2 }),
|
|
ps({ smallID: 3 }),
|
|
);
|
|
const units = unitsMap(
|
|
unit({
|
|
id: 10,
|
|
ownerID: 2,
|
|
unitType: UT_ATOM_BOMB,
|
|
isActive: true,
|
|
targetTile: 5,
|
|
}),
|
|
);
|
|
// tileState[5] = player 3, not me.
|
|
const tileState = new Uint16Array(16);
|
|
tileState[5] = 3;
|
|
|
|
const status = computePlayerStatus(players, units, {
|
|
localPlayerSmallID: 1,
|
|
tileState,
|
|
});
|
|
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
|
});
|
|
|
|
it("entry is emitted when only a relative flag is true (even with no base flags)", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }), // me
|
|
ps({ smallID: 2 }), // no other flags
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
// Without local-mode, player 2 wouldn't get an entry — alliance is the
|
|
// only reason it shows up here.
|
|
expect(status.get(2)).toBeDefined();
|
|
expect(status.get(2)?.alliance).toBe(true);
|
|
});
|
|
|
|
it("localPlayerSmallID = 0 (no local player) behaves like replay mode", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }),
|
|
ps({ smallID: 2, tilesOwned: 1 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 0,
|
|
});
|
|
expect(status.get(2)?.alliance).toBe(false);
|
|
});
|
|
|
|
it("allianceReq and allianceFraction are not computed (deferred)", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }),
|
|
ps({ smallID: 2 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
expect(status.get(2)?.allianceReq).toBe(false);
|
|
expect(status.get(2)?.allianceFraction).toBe(0);
|
|
});
|
|
|
|
it("allianceFraction and allianceRemainingTicks come from the alliance expiry", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }),
|
|
ps({
|
|
smallID: 2,
|
|
alliances: [
|
|
{
|
|
id: 1,
|
|
other: "me",
|
|
createdAt: 100,
|
|
expiresAt: 700,
|
|
hasExtensionRequest: false,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
localPlayerID: "me",
|
|
tick: 400,
|
|
allianceDuration: 600,
|
|
});
|
|
expect(status.get(2)?.allianceFraction).toBe(0.5);
|
|
expect(status.get(2)?.allianceRemainingTicks).toBe(300);
|
|
});
|
|
});
|