diff --git a/resources/lang/en.json b/resources/lang/en.json index 3d8704cfe..78d5abd23 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1147,6 +1147,8 @@ "crowded": "Crowded", "disable_alliances": "Alliances Disabled", "disable_alliances_label": "Alliances", + "doomsday_clock": "Doomsday Clock", + "doomsday_clock_label": "Doomsday Clock", "gold_multiplier": "x{amount} Gold Multiplier", "hard_nations": "Hard Nations", "nukes_disabled": "Nukes Disabled", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index d34aa2b44..6e52836b8 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -220,6 +220,12 @@ export function getActiveModifiers( badgeKey: "public_game_modifier.water_nukes", }); } + if (modifiers.isDoomsdayClock) { + result.push({ + labelKey: "public_game_modifier.doomsday_clock_label", + badgeKey: "public_game_modifier.doomsday_clock", + }); + } return result; } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 83da4692e..f2c526728 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -281,6 +281,7 @@ export const GameConfigSchema = z.object({ isSAMsDisabled: z.boolean().optional(), isPeaceTime: z.boolean().optional(), isWaterNukes: z.boolean().optional(), + isDoomsdayClock: z.boolean().optional(), }) .optional(), nations: z diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 737810795..acb5a5694 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -143,6 +143,7 @@ export interface PublicGameModifiers { isSAMsDisabled?: boolean; isPeaceTime?: boolean; isWaterNukes?: boolean; + isDoomsdayClock?: boolean; } export interface UnitInfo { diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index edc4a8ca1..6d17da488 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -74,7 +74,8 @@ type ModifierKey = | "isNukesDisabled" | "isSAMsDisabled" | "isPeaceTime" - | "isWaterNukes"; + | "isWaterNukes" + | "isDoomsdayClock"; // Each entry represents one "ticket" in the pool. More tickets = higher chance of selection. // Weights are roughly informed by the community "favorite modifier" poll. @@ -92,8 +93,19 @@ const SPECIAL_MODIFIER_POOL: ModifierKey[] = [ ...Array(1).fill("isSAMsDisabled"), ...Array(1).fill("isPeaceTime"), ...Array(4).fill("isWaterNukes"), + ...Array(4).fill("isDoomsdayClock"), ]; +// Speeds the Doomsday Clock can roll at when it lands in the rotation. Picked +// per game (see getSpecialConfig) so the pacing varies instead of always being +// the same preset. +const DOOMSDAY_ROTATION_SPEEDS = [ + "slow", + "normal", + "fast", + "veryfast", +] as const; + // Maps where water nukes have a higher chance on top of the normal pool // Water nukes are especially fun here const WATER_NUKES_BOOSTED_MAPS: ReadonlySet = new Set([ @@ -254,6 +266,7 @@ export class MapPlaylist { isSAMsDisabled, isPeaceTime, isWaterNukes, + isDoomsdayClock, } = poolResult; if (boostWaterNukes) { isWaterNukes = true; @@ -280,7 +293,8 @@ export class MapPlaylist { !isNukesDisabled && !isSAMsDisabled && !isPeaceTime && - !isWaterNukes + !isWaterNukes && + !isDoomsdayClock ) { excludedModifiers.push("isCrowded"); const fallback = this.getRandomSpecialGameModifiers( @@ -297,6 +311,7 @@ export class MapPlaylist { isSAMsDisabled, isPeaceTime, isWaterNukes, + isDoomsdayClock, } = fallback); ({ isHardNations } = fallback); } @@ -353,7 +368,19 @@ export class MapPlaylist { isSAMsDisabled, isPeaceTime, isWaterNukes, + isDoomsdayClock, }, + // Rolled into the rotation: enable the anti-stall clock at a speed picked + // per game so the pacing varies across the presets. + doomsdayClock: isDoomsdayClock + ? { + enabled: true, + speed: + DOOMSDAY_ROTATION_SPEEDS[ + Math.floor(Math.random() * DOOMSDAY_ROTATION_SPEEDS.length) + ], + } + : undefined, startingGold, goldMultiplier, disableAlliances: isAlliancesDisabled ? true : undefined, @@ -557,6 +584,7 @@ export class MapPlaylist { isSAMsDisabled: selected.has("isSAMsDisabled") || undefined, isPeaceTime: selected.has("isPeaceTime") || undefined, isWaterNukes: selected.has("isWaterNukes") || undefined, + isDoomsdayClock: selected.has("isDoomsdayClock") || undefined, }; } diff --git a/tests/client/DoomsdayModifierBadge.test.ts b/tests/client/DoomsdayModifierBadge.test.ts new file mode 100644 index 000000000..1a1057e8f --- /dev/null +++ b/tests/client/DoomsdayModifierBadge.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { getActiveModifiers } from "../../src/client/Utils"; + +// The doomsday clock is part of the public "special" modifier rotation, so an +// active isDoomsdayClock modifier must surface a lobby badge like every other +// rotation modifier. +describe("doomsday clock public modifier", () => { + it("surfaces a doomsday-clock badge when the modifier is active", () => { + const mods = getActiveModifiers({ isDoomsdayClock: true }); + expect(mods).toHaveLength(1); + expect(mods[0].badgeKey).toBe("public_game_modifier.doomsday_clock"); + expect(mods[0].labelKey).toBe("public_game_modifier.doomsday_clock_label"); + }); + + it("omits the badge when the modifier is absent", () => { + expect(getActiveModifiers({})).toHaveLength(0); + expect(getActiveModifiers(undefined)).toHaveLength(0); + }); +});