diff --git a/resources/lang/en.json b/resources/lang/en.json index d73550ac3..1af29f0c9 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1246,6 +1246,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 2b67df29b..c1bbf1f59 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 fb48c44d0..396df0bb0 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -328,6 +328,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 d2cf39c76..a4d4523ca 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -148,6 +148,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 255bcc786..a294c8cb5 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -78,7 +78,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. @@ -96,8 +97,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([ @@ -258,6 +270,7 @@ export class MapPlaylist { isSAMsDisabled, isPeaceTime, isWaterNukes, + isDoomsdayClock, } = poolResult; if (boostWaterNukes) { isWaterNukes = true; @@ -284,7 +297,8 @@ export class MapPlaylist { !isNukesDisabled && !isSAMsDisabled && !isPeaceTime && - !isWaterNukes + !isWaterNukes && + !isDoomsdayClock ) { excludedModifiers.push("isCrowded"); const fallback = this.getRandomSpecialGameModifiers( @@ -301,6 +315,7 @@ export class MapPlaylist { isSAMsDisabled, isPeaceTime, isWaterNukes, + isDoomsdayClock, } = fallback); ({ isHardNations } = fallback); } @@ -357,7 +372,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, @@ -561,6 +588,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); + }); +});