Files
OpenFrontIO/src/core/Schemas.ts
T
FloPinguin cf0cf14a1f Add new public game modifiers 🙂 (#3500)
## Description:

Adds 5 new public game modifiers to the Special game mode modifier pool:

- **Ports Disabled** - disables port construction, focus on factories
- **Nukes Disabled** - disables atom bombs, hydrogen bombs, MIRVs,
missile silos and SAM launchers
- **SAMs Disabled** - disables SAM launchers (thats funny, you cant
protect against nukes, have to space out your stuff) (mutually exclusive
with nukes disabled)
- **1M Starting Gold** - gives all players 1M starting gold (was
requested by people, new chill tier alongside existing 5M and 25M)
- **4min Peace Time** - grants 4 minutes of PVP spawn immunity

All `PublicGameModifiers` boolean fields are now optional - inactive
modifiers are omitted from game JSON instead of being serialized as
`false` (stop bloating the JSON size)

I think we have enough modifiers now :) Good variety

## 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
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
2026-03-24 12:57:08 -07:00

724 lines
20 KiB
TypeScript

import countries from "resources/countries.json";
import quickChatData from "resources/QuickChat.json";
import { z } from "zod";
import {
ColorPaletteSchema,
PatternDataSchema,
PatternNameSchema,
} from "./CosmeticSchemas";
import type { GameEvent } from "./EventBus";
import {
AllPlayers,
Difficulty,
Duos,
GameMapSize,
GameMapType,
GameMode,
GameType,
HumansVsNations,
Quads,
RankedType,
Trios,
UnitType,
} from "./game/Game";
import { PlayerStatsSchema } from "./StatsSchemas";
import { flattenedEmojiTable } from "./Util";
export type GameID = string;
export type ClientID = string;
export type Intent =
| SpawnIntent
| AttackIntent
| CancelAttackIntent
| BoatAttackIntent
| CancelBoatIntent
| AllianceRequestIntent
| AllianceRejectIntent
| AllianceExtensionIntent
| BreakAllianceIntent
| TargetPlayerIntent
| EmojiIntent
| DonateGoldIntent
| DonateTroopsIntent
| BuildUnitIntent
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent
| MarkDisconnectedIntent
| EmbargoAllIntent
| UpgradeStructureIntent
| DeleteUnitIntent
| KickPlayerIntent
| TogglePauseIntent
| UpdateGameConfigIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>;
export type EmbargoAllIntent = z.infer<typeof EmbargoAllIntentSchema>;
export type CancelBoatIntent = z.infer<typeof CancelBoatIntentSchema>;
export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>;
export type AllianceRejectIntent = z.infer<typeof AllianceRejectIntentSchema>;
export type BreakAllianceIntent = z.infer<typeof BreakAllianceIntentSchema>;
export type TargetPlayerIntent = z.infer<typeof TargetPlayerIntentSchema>;
export type EmojiIntent = z.infer<typeof EmojiIntentSchema>;
export type DonateGoldIntent = z.infer<typeof DonateGoldIntentSchema>;
export type DonateTroopsIntent = z.infer<typeof DonateTroopIntentSchema>;
export type EmbargoIntent = z.infer<typeof EmbargoIntentSchema>;
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type UpgradeStructureIntent = z.infer<
typeof UpgradeStructureIntentSchema
>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type MarkDisconnectedIntent = z.infer<
typeof MarkDisconnectedIntentSchema
>;
export type AllianceExtensionIntent = z.infer<
typeof AllianceExtensionIntentSchema
>;
export type DeleteUnitIntent = z.infer<typeof DeleteUnitIntentSchema>;
export type KickPlayerIntent = z.infer<typeof KickPlayerIntentSchema>;
export type TogglePauseIntent = z.infer<typeof TogglePauseIntentSchema>;
export type UpdateGameConfigIntent = z.infer<
typeof UpdateGameConfigIntentSchema
>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
export type ClientMessage =
| ClientSendWinnerMessage
| ClientPingMessage
| ClientIntentMessage
| ClientJoinMessage
| ClientRejoinMessage
| ClientLogMessage
| ClientHashMessage;
export type ServerMessage =
| ServerTurnMessage
| ServerStartGameMessage
| ServerPingMessage
| ServerDesyncMessage
| ServerPrestartMessage
| ServerErrorMessage
| ServerLobbyInfoMessage;
export type ServerTurnMessage = z.infer<typeof ServerTurnMessageSchema>;
export type ServerStartGameMessage = z.infer<
typeof ServerStartGameMessageSchema
>;
export type ServerPingMessage = z.infer<typeof ServerPingMessageSchema>;
export type ServerDesyncMessage = z.infer<typeof ServerDesyncSchema>;
export type ServerPrestartMessage = z.infer<typeof ServerPrestartMessageSchema>;
export type ServerErrorMessage = z.infer<typeof ServerErrorSchema>;
export type ServerLobbyInfoMessage = z.infer<
typeof ServerLobbyInfoMessageSchema
>;
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>;
export type ClientRejoinMessage = z.infer<typeof ClientRejoinMessageSchema>;
export type ClientLogMessage = z.infer<typeof ClientLogMessageSchema>;
export type ClientHashMessage = z.infer<typeof ClientHashSchema>;
export type AllPlayersStats = z.infer<typeof AllPlayersStatsSchema>;
export type Player = z.infer<typeof PlayerSchema>;
export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
export type PublicGameInfo = z.infer<typeof PublicGameInfoSchema>;
export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]);
export const UsernameSchema = z
.string()
.regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u)
.min(3)
.max(27);
export const ClanTagSchema = z
.string()
.regex(/^[a-zA-Z0-9]{2,5}$/)
.nullable();
const ClientInfoSchema = z.object({
clientID: z.string(),
username: UsernameSchema,
clanTag: ClanTagSchema,
});
export const GameInfoSchema = z.object({
gameID: z.string(),
clients: z.array(ClientInfoSchema).optional(),
lobbyCreatorClientID: z.string().optional(),
startsAt: z.number().optional(),
serverTime: z.number(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
publicGameType: PublicGameTypeSchema.optional(),
});
export const PublicGameInfoSchema = z.object({
gameID: z.string(),
numClients: z.number(),
startsAt: z.number().optional(),
gameConfig: z.lazy(() => GameConfigSchema).optional(),
publicGameType: PublicGameTypeSchema,
});
export const PublicGamesSchema = z.object({
serverTime: z.number(),
games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
});
export class LobbyInfoEvent implements GameEvent {
constructor(
public lobby: GameInfo,
public myClientID: ClientID,
) {}
}
export interface ClientInfo {
clientID: ClientID;
username: string;
clanTag: string | null;
}
export enum LogSeverity {
Debug = "DEBUG",
Info = "INFO",
Warn = "WARN",
Error = "ERROR",
Fatal = "FATAL",
}
//
// Utility types
//
const TeamCountConfigSchema = z.union([
z.number(),
z.literal(Duos),
z.literal(Trios),
z.literal(Quads),
z.literal(HumansVsNations),
]);
export type TeamCountConfig = z.infer<typeof TeamCountConfigSchema>;
export const GameConfigSchema = z.object({
gameMap: z.enum(GameMapType),
difficulty: z.enum(Difficulty),
donateGold: z.boolean(), // Configures donations to humans only
donateTroops: z.boolean(), // Configures donations to humans only
gameType: z.enum(GameType),
gameMode: z.enum(GameMode),
rankedType: z.enum(RankedType).optional(), // Only set for ranked games.
gameMapSize: z.enum(GameMapSize),
publicGameModifiers: z
.object({
isCompact: z.boolean().optional(),
isRandomSpawn: z.boolean().optional(),
isCrowded: z.boolean().optional(),
isHardNations: z.boolean().optional(),
startingGold: z.number().int().min(0).optional(),
goldMultiplier: z.number().min(0.1).max(1000).optional(),
isAlliancesDisabled: z.boolean().optional(),
isPortsDisabled: z.boolean().optional(),
isNukesDisabled: z.boolean().optional(),
isSAMsDisabled: z.boolean().optional(),
isPeaceTime: z.boolean().optional(),
})
.optional(),
nations: z
.number()
.int()
.min(1)
.max(400)
.or(z.enum(["default", "disabled"])),
bots: z.number().int().min(0).max(400),
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
disableNavMesh: z.boolean().optional(),
disableAlliances: z.boolean().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks
disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(),
goldMultiplier: z.number().min(0.1).max(1000).optional(),
startingGold: z.number().int().min(0).max(1000000000).optional(),
});
export const TeamSchema = z.string();
export const SafeString = z
.string()
.regex(
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=[\]{}|;:"'/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|[üÜ])*$/u,
)
.max(1000);
export const PersistentIdSchema = z.uuid();
const JwtTokenSchema = z.jwt();
const TokenSchema = z
.string()
.refine(
(v) =>
PersistentIdSchema.safeParse(v).success ||
JwtTokenSchema.safeParse(v).success,
{
message: "Token must be a valid UUID or JWT",
},
);
const EmojiSchema = z
.number()
.nonnegative()
.max(flattenedEmojiTable.length - 1);
export const GAME_ID_REGEX = /^[A-Za-z0-9]{8}$/;
export const isValidGameID = (value: string): boolean =>
GAME_ID_REGEX.test(value);
export const ID = z.string().regex(GAME_ID_REGEX);
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
Object.entries(quickChatData).flatMap(([category, entries]) =>
entries.map((entry) => `${category}.${entry.key}`),
) as [string, ...string[]],
);
//
// Intents
//
export const AllianceExtensionIntentSchema = z.object({
type: z.literal("allianceExtension"),
recipient: ID,
});
export const AttackIntentSchema = z.object({
type: z.literal("attack"),
targetID: ID.nullable(),
troops: z.number().nonnegative().nullable(),
});
export const SpawnIntentSchema = z.object({
type: z.literal("spawn"),
tile: z.number(),
});
export const BoatAttackIntentSchema = z.object({
type: z.literal("boat"),
troops: z.number().nonnegative(),
dst: z.number(),
});
export const AllianceRequestIntentSchema = z.object({
type: z.literal("allianceRequest"),
recipient: ID,
});
export const AllianceRejectIntentSchema = z.object({
type: z.literal("allianceReject"),
requestor: ID,
});
export const BreakAllianceIntentSchema = z.object({
type: z.literal("breakAlliance"),
recipient: ID,
});
export const TargetPlayerIntentSchema = z.object({
type: z.literal("targetPlayer"),
target: ID,
});
export const EmojiIntentSchema = z.object({
type: z.literal("emoji"),
recipient: z.union([ID, z.literal(AllPlayers)]),
emoji: EmojiSchema,
});
export const EmbargoIntentSchema = z.object({
type: z.literal("embargo"),
targetID: ID,
action: z.union([z.literal("start"), z.literal("stop")]),
});
export const EmbargoAllIntentSchema = z.object({
type: z.literal("embargo_all"),
action: z.union([z.literal("start"), z.literal("stop")]),
});
export const DonateGoldIntentSchema = z.object({
type: z.literal("donate_gold"),
recipient: ID,
gold: z.number().nonnegative().nullable(),
});
export const DonateTroopIntentSchema = z.object({
type: z.literal("donate_troops"),
recipient: ID,
troops: z.number().nonnegative().nullable(),
});
export const BuildUnitIntentSchema = z.object({
type: z.literal("build_unit"),
unit: z.enum(UnitType),
tile: z.number(),
rocketDirectionUp: z.boolean().optional(),
});
export const UpgradeStructureIntentSchema = z.object({
type: z.literal("upgrade_structure"),
unit: z.enum(UnitType),
unitId: z.number(),
});
export const CancelAttackIntentSchema = z.object({
type: z.literal("cancel_attack"),
attackID: z.string(),
});
export const CancelBoatIntentSchema = z.object({
type: z.literal("cancel_boat"),
unitID: z.number(),
});
export const MoveWarshipIntentSchema = z.object({
type: z.literal("move_warship"),
unitId: z.number(),
tile: z.number(),
});
export const DeleteUnitIntentSchema = z.object({
type: z.literal("delete_unit"),
unitId: z.number(),
});
export const QuickChatIntentSchema = z.object({
type: z.literal("quick_chat"),
recipient: ID,
quickChatKey: QuickChatKeySchema,
target: ID.optional(),
});
export const MarkDisconnectedIntentSchema = z.object({
type: z.literal("mark_disconnected"),
clientID: ID,
isDisconnected: z.boolean(),
});
export const KickPlayerIntentSchema = z.object({
type: z.literal("kick_player"),
target: ID,
});
export const TogglePauseIntentSchema = z.object({
type: z.literal("toggle_pause"),
paused: z.boolean().default(false),
});
export const UpdateGameConfigIntentSchema = z.object({
type: z.literal("update_game_config"),
config: GameConfigSchema.partial(),
});
const IntentSchema = z.discriminatedUnion("type", [
AttackIntentSchema,
CancelAttackIntentSchema,
SpawnIntentSchema,
MarkDisconnectedIntentSchema,
BoatAttackIntentSchema,
CancelBoatIntentSchema,
AllianceRequestIntentSchema,
AllianceRejectIntentSchema,
BreakAllianceIntentSchema,
TargetPlayerIntentSchema,
EmojiIntentSchema,
DonateGoldIntentSchema,
DonateTroopIntentSchema,
BuildUnitIntentSchema,
UpgradeStructureIntentSchema,
EmbargoIntentSchema,
EmbargoAllIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
AllianceExtensionIntentSchema,
DeleteUnitIntentSchema,
KickPlayerIntentSchema,
TogglePauseIntentSchema,
UpdateGameConfigIntentSchema,
]);
// StampedIntent = Intent with server-stamped clientID (used in turns and execution)
export const StampedIntentSchema = IntentSchema.and(z.object({ clientID: ID }));
export type StampedIntent = Intent & { clientID: ClientID };
//
// Server utility types
//
export const TurnSchema = z.object({
turnNumber: z.number(),
intents: StampedIntentSchema.array(),
// The hash of the game state at the end of the turn.
hash: z.number().nullable().optional(),
});
export const FlagSchema = z
.string()
.max(128)
.optional()
.refine(
(val) => {
if (val === undefined || val === "") return true;
if (val.startsWith("!")) return true;
return countryCodes.includes(val);
},
{ message: "Invalid flag: must be a valid country code or start with !" },
);
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagSchema.optional(),
color: z.string().optional(),
patternName: PatternNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
});
export const PlayerPatternSchema = z.object({
name: PatternNameSchema,
patternData: PatternDataSchema,
colorPalette: ColorPaletteSchema.optional(),
});
export const PlayerColorSchema = z.object({
color: z.string(),
});
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
});
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
cosmetics: PlayerCosmeticsSchema.optional(),
isLobbyCreator: z.boolean().optional(),
});
export const GameStartInfoSchema = z.object({
gameID: ID,
lobbyCreatedAt: z.number(),
visibleAt: z.number().optional(),
config: GameConfigSchema,
players: PlayerSchema.array(),
});
export const WinnerSchema = z
.union([
z.tuple([z.literal("player"), ID]).rest(ID),
z.tuple([z.literal("team"), SafeString]).rest(ID),
z.tuple([z.literal("nation"), SafeString]).rest(ID),
])
.optional();
export type Winner = z.infer<typeof WinnerSchema>;
//
// Server
//
export const ServerTurnMessageSchema = z.object({
type: z.literal("turn"),
turn: TurnSchema,
});
export const ServerPingMessageSchema = z.object({
type: z.literal("ping"),
});
export const ServerPrestartMessageSchema = z.object({
type: z.literal("prestart"),
gameMap: z.enum(GameMapType),
gameMapSize: z.enum(GameMapSize),
});
export const ServerStartGameMessageSchema = z.object({
type: z.literal("start"),
// Turns the client missed if they are late to the game.
turns: TurnSchema.array(),
gameStartInfo: GameStartInfoSchema,
lobbyCreatedAt: z.number(),
// The clientID assigned to this connection by the server.
// Absent for replays where the viewer has no player identity.
myClientID: ID.optional(),
});
export const ServerDesyncSchema = z.object({
type: z.literal("desync"),
turn: z.number(),
correctHash: z.number().nullable(),
clientsWithCorrectHash: z.number(),
totalActiveClients: z.number(),
yourHash: z.number().optional(),
});
export const ServerErrorSchema = z.object({
type: z.literal("error"),
error: z.string(),
message: z.string().optional(),
});
export const ServerLobbyInfoMessageSchema = z.object({
type: z.literal("lobby_info"),
lobby: GameInfoSchema,
// The clientID assigned to this connection by the server
myClientID: ID,
});
export const ServerMessageSchema = z.discriminatedUnion("type", [
ServerTurnMessageSchema,
ServerPrestartMessageSchema,
ServerStartGameMessageSchema,
ServerPingMessageSchema,
ServerDesyncSchema,
ServerErrorSchema,
ServerLobbyInfoMessageSchema,
]);
//
// Client
//
export const ClientSendWinnerSchema = z.object({
type: z.literal("winner"),
winner: WinnerSchema,
allPlayersStats: AllPlayersStatsSchema,
});
export const ClientHashSchema = z.object({
type: z.literal("hash"),
hash: z.number(),
turnNumber: z.number(),
});
export const ClientLogMessageSchema = z.object({
type: z.literal("log"),
severity: z.enum(LogSeverity),
log: ID,
});
export const ClientPingMessageSchema = z.object({
type: z.literal("ping"),
});
export const ClientIntentMessageSchema = z.object({
type: z.literal("intent"),
intent: IntentSchema,
});
// WARNING: never send this message to clients.
// Note: clientID is NOT included - server assigns it based on persistentID from token
export const ClientJoinMessageSchema = z.object({
type: z.literal("join"),
token: TokenSchema, // WARNING: PII - server extracts persistentID from this
gameID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
// Server replaces the refs with the actual cosmetic data.
cosmetics: PlayerCosmeticRefsSchema.optional(),
turnstileToken: z.string().nullable(),
});
export const ClientRejoinMessageSchema = z.object({
type: z.literal("rejoin"),
gameID: ID,
// Note: clientID is NOT sent - server looks it up from persistentID in token
lastTurn: z.number(),
token: TokenSchema,
});
export const ClientMessageSchema = z.discriminatedUnion("type", [
ClientSendWinnerSchema,
ClientPingMessageSchema,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientRejoinMessageSchema,
ClientLogMessageSchema,
ClientHashSchema,
]);
//
// Records
//
export const PlayerRecordSchema = PlayerSchema.extend({
persistentID: PersistentIdSchema.nullable(), // WARNING: PII
stats: PlayerStatsSchema,
});
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
export const GameEndInfoSchema = GameStartInfoSchema.extend({
players: PlayerRecordSchema.array(),
start: z.number(),
end: z.number(),
duration: z.number().nonnegative(),
num_turns: z.number(),
winner: WinnerSchema,
lobbyFillTime: z.number().nonnegative(),
});
export type GameEndInfo = z.infer<typeof GameEndInfoSchema>;
const GitCommitSchema = z
.string()
.regex(/^[0-9a-fA-F]{40}$/)
.or(z.literal("DEV"));
export const PartialAnalyticsRecordSchema = z.object({
info: GameEndInfoSchema,
version: z.literal("v0.0.2"),
});
export type ClientAnalyticsRecord = z.infer<
typeof PartialAnalyticsRecordSchema
>;
export const AnalyticsRecordSchema = PartialAnalyticsRecordSchema.extend({
gitCommit: GitCommitSchema,
subdomain: z.string(),
domain: z.string(),
});
export type AnalyticsRecord = z.infer<typeof AnalyticsRecordSchema>;
export const GameRecordSchema = AnalyticsRecordSchema.extend({
turns: TurnSchema.array(),
});
export const PartialGameRecordSchema = PartialAnalyticsRecordSchema.extend({
turns: TurnSchema.array(),
});
export type PartialGameRecord = z.infer<typeof PartialGameRecordSchema>;
export type GameRecord = z.infer<typeof GameRecordSchema>;