refactor: rename core-public -> engine-public; fold helpers in; drop shared

Tighten the package model around the determinism invariant: everything the
engine imports must itself be deterministic.

- Rename core-public -> engine-public (package, dir, `engine-public/*` alias,
  all 118 import sites). It is the deterministic public surface the engine
  depends on and that client/server also use.
- Move the pure formatting helpers (renderNumber/renderTroops) from shared
  into engine-public/format — the engine uses them and they're deterministic.
- Drop the now-empty shared package (and its alias). `shared` was intended for
  client/server-shared, engine-forbidden code; there is none yet, so it is
  removed and can be re-added when such code appears.

Final graph: engine-public (leaf) <- engine <- {client, server};
client/server also -> engine-public.

Verified: tsc clean; 1364 + 65 tests pass; prod build succeeds; engine-public
is a leaf; engine has no client/server imports. Lockfile regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-06-10 20:32:32 +00:00
co-authored by Claude Opus 4.8
parent a7f992e9b0
commit 43d07ca85f
145 changed files with 193 additions and 201 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@openfront/engine-public",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts"
}
+259
View File
@@ -0,0 +1,259 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
import { ClanTagSchema } from "./Schemas";
import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas";
import { Difficulty, GameMode, GameType, RankedType } from "./game/GameTypes";
function stripClanTagFromUsername(username: string): string {
return username.replace(/^\s*\[[a-zA-Z0-9]{2,5}\]\s*/u, "").trim();
}
// Historical leaderboard rows can include legacy usernames
// that predate current strict join-time validation rules.
const LeaderboardUsernameSchema = z
.string()
.transform(stripClanTagFromUsername)
.pipe(z.string().min(1).max(64));
const RequiredClanTagSchema = ClanTagSchema.unwrap();
export const RefreshResponseSchema = z.object({
token: z.string(),
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
export const TokenPayloadSchema = z.object({
jti: z.string(),
sub: z
.string()
.refine(
(val) => {
const uuid = base64urlToUuid(val);
return !!uuid;
},
{
message: "Invalid base64-encoded UUID",
},
)
.transform((val) => {
const uuid = base64urlToUuid(val);
if (!uuid) throw new Error("Invalid base64 UUID");
return uuid;
}),
iat: z.number(),
iss: z.string(),
aud: z.string(),
exp: z.number(),
role: z
.enum(["root", "admin", "mod", "flagged", "banned"])
// In case new roles are added in the future.
.or(z.string())
.optional(),
});
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
export const ADMIN_ROLES = ["admin", "root"] as const;
export function isAdminRole(role: string | null | undefined): boolean {
return role === "admin" || role === "root";
}
export const DiscordUserSchema = z.object({
id: z.string(),
avatar: z.string().nullable(),
username: z.string(),
global_name: z.string().nullable(),
discriminator: z.string(),
});
export type DiscordUser = z.infer<typeof DiscordUserSchema>;
const SingleplayerMapAchievementSchema = z.object({
mapName: z.string(),
difficulty: z.enum(Difficulty),
});
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
email: z.string().optional(),
}),
player: z.object({
publicId: z.string(),
adfree: z.boolean(),
flares: z.string().array().optional(),
achievements: z.object({
singleplayerMap: z.array(SingleplayerMapAchievementSchema),
}),
leaderboard: z
.object({
oneVone: z
.object({
elo: z.number().optional(),
})
.optional(),
})
.optional(),
currency: z
.object({
soft: z.coerce.number(),
hard: z.coerce.number(),
})
.optional(),
clans: z
.array(
z.object({
tag: RequiredClanTagSchema,
name: z.string(),
role: z.enum(["leader", "officer", "member"]),
joinedAt: z.iso.datetime(),
memberCount: z.number().int().min(1),
}),
)
.optional(),
clanRequests: z
.array(
z.object({
tag: RequiredClanTagSchema,
name: z.string(),
createdAt: z.iso.datetime(),
}),
)
.optional(),
friends: z.array(z.string()),
subscription: z
.object({
tier: z.string(),
status: z.string(),
currentPeriodEnd: z.coerce.date().nullable(),
cancelAtPeriodEnd: z.boolean(),
})
.nullable(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
export type UserSubscription = NonNullable<
NonNullable<UserMeResponse["player"]["subscription"]>
>;
export const PlayerStatsLeafSchema = z.object({
wins: BigIntStringSchema,
losses: BigIntStringSchema,
total: BigIntStringSchema,
stats: PlayerStatsSchema,
});
export type PlayerStatsLeaf = z.infer<typeof PlayerStatsLeafSchema>;
const GameModeStatsSchema = z.partialRecord(
z.enum(GameMode),
z.partialRecord(z.enum(Difficulty), PlayerStatsLeafSchema),
);
export const PlayerStatsTreeSchema = z.object({
Singleplayer: GameModeStatsSchema.optional(),
Public: GameModeStatsSchema.optional(),
Private: GameModeStatsSchema.optional(),
Ranked: z.partialRecord(z.enum(RankedType), PlayerStatsLeafSchema).optional(),
});
export type PlayerStatsTree = z.infer<typeof PlayerStatsTreeSchema>;
export const PlayerGameSchema = z.object({
gameId: z.string(),
start: z.iso.datetime(),
mode: z.enum(GameMode),
type: z.enum(GameType),
map: z.string(),
difficulty: z.enum(Difficulty),
clientId: z.string().optional(),
});
export type PlayerGame = z.infer<typeof PlayerGameSchema>;
export const PlayerProfileSchema = z.object({
createdAt: z.iso.datetime(),
user: DiscordUserSchema.optional(),
games: PlayerGameSchema.array(),
stats: PlayerStatsTreeSchema,
});
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: LeaderboardUsernameSchema,
clanTag: RequiredClanTagSchema.nullable().optional(),
flag: z.string().optional(),
elo: z.number(),
games: z.number(),
wins: z.number(),
losses: z.number(),
winRate: z.number(),
});
export type PlayerLeaderboardEntry = z.infer<
typeof PlayerLeaderboardEntrySchema
>;
export const PlayerLeaderboardResponseSchema = z.object({
players: PlayerLeaderboardEntrySchema.array(),
});
export type PlayerLeaderboardResponse = z.infer<
typeof PlayerLeaderboardResponseSchema
>;
export const RankedLeaderboardEntrySchema = z.object({
rank: z.number(),
elo: z.number(),
peakElo: z.number().nullable(),
wins: z.number(),
losses: z.number(),
total: z.number(),
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: LeaderboardUsernameSchema,
clanTag: RequiredClanTagSchema.nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
>;
export const RankedLeaderboardResponseSchema = z.object({
[RankedType.OneVOne]: RankedLeaderboardEntrySchema.array(),
});
export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;
export const FriendEntrySchema = z.object({
publicId: z.string(),
createdAt: z.iso.datetime(),
});
export type FriendEntry = z.infer<typeof FriendEntrySchema>;
export const FriendRequestsResponseSchema = z.object({
incoming: FriendEntrySchema.array(),
outgoing: FriendEntrySchema.array(),
});
export type FriendRequestsResponse = z.infer<
typeof FriendRequestsResponseSchema
>;
export const FriendsListResponseSchema = z.object({
results: FriendEntrySchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type FriendsListResponse = z.infer<typeof FriendsListResponseSchema>;
export const SendFriendRequestResponseSchema = z.object({
status: z.enum(["requested", "accepted"]),
});
export type SendFriendRequestResponse = z.infer<
typeof SendFriendRequestResponseSchema
>;
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
descriptionTranslationKey: z.string().optional(),
url: z.string().nullable().optional(),
type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
});
export type NewsItem = z.infer<typeof NewsItemSchema>;
+37
View File
@@ -0,0 +1,37 @@
import { base64url } from "jose";
/**
* Converts a UUID string to a base64url-encoded binary representation.
* @param uuid - The UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
* @returns base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
*/
export function uuidToBase64url(uuid: string): string {
const hex = uuid.replace(/-/g, "");
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return base64url.encode(bytes);
}
/**
* Converts a base64url-encoded binary UUID back to its canonical UUID string.
* @param encoded - base64url string (e.g., 'Ej5FZ+i7EtOkVkJmFBdAAA')
* @returns UUID string (e.g., '123e4567-e89b-12d3-a456-426614174000')
*/
export function base64urlToUuid(encoded: string): string {
const bytes = base64url.decode(encoded);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
@@ -0,0 +1,190 @@
import { z } from "zod";
import { ClanTagSchema } from "./Schemas";
const RequiredClanTagSchema = ClanTagSchema.unwrap();
// Response for the game-server endpoint listing every registered clan tag.
export const ReservedClanTagsResponseSchema = z.array(z.string());
export type ReservedClanTagsResponse = z.infer<
typeof ReservedClanTagsResponseSchema
>;
export const ClanLeaderboardEntrySchema = z.object({
clanTag: RequiredClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
playerSessions: z.number(),
weightedWins: z.number(),
weightedLosses: z.number(),
weightedWLRatio: z.number(),
});
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
export const ClanLeaderboardResponseSchema = z.object({
start: z.iso.datetime(),
end: z.iso.datetime(),
clans: ClanLeaderboardEntrySchema.array(),
total: z.number().optional(),
limit: z.number().optional(),
});
export type ClanLeaderboardResponse = z.infer<
typeof ClanLeaderboardResponseSchema
>;
export const ClanInfoSchema = z.object({
name: z.string().max(35),
tag: RequiredClanTagSchema,
description: z.string().max(200),
isOpen: z.boolean(),
createdAt: z.iso.datetime().optional(),
memberCount: z.number().optional(),
});
export type ClanInfo = z.infer<typeof ClanInfoSchema>;
export const ClanBrowseResponseSchema = z.object({
results: ClanInfoSchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type ClanBrowseResponse = z.infer<typeof ClanBrowseResponseSchema>;
export const ClanMemberWLSchema = z.object({
wins: z.number(),
losses: z.number(),
});
export type ClanMemberWL = z.infer<typeof ClanMemberWLSchema>;
export const ClanMemberStatsSchema = z.object({
total: ClanMemberWLSchema,
ffa: ClanMemberWLSchema,
team: ClanMemberWLSchema,
hvn: ClanMemberWLSchema,
duos: ClanMemberWLSchema,
trios: ClanMemberWLSchema,
quads: ClanMemberWLSchema,
"2": ClanMemberWLSchema,
"3": ClanMemberWLSchema,
"4": ClanMemberWLSchema,
"5": ClanMemberWLSchema,
"6": ClanMemberWLSchema,
"7": ClanMemberWLSchema,
ranked: ClanMemberWLSchema,
"1v1": ClanMemberWLSchema,
});
export type ClanMemberStats = z.infer<typeof ClanMemberStatsSchema>;
export const TEAM_BREAKDOWN_KEYS = [
"duos",
"trios",
"quads",
"2",
"3",
"4",
"5",
"6",
"7",
] as const satisfies readonly (keyof ClanMemberStats)[];
export const RANKED_BREAKDOWN_KEYS = [
"1v1",
] as const satisfies readonly (keyof ClanMemberStats)[];
export const ClanMemberSchema = z.object({
role: z.enum(["leader", "officer", "member"]),
joinedAt: z.iso.datetime(),
publicId: z.string(),
stats: ClanMemberStatsSchema.optional(),
});
export type ClanMember = z.infer<typeof ClanMemberSchema>;
export const ClanMembersResponseSchema = z.object({
results: ClanMemberSchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
pendingRequests: z.number().optional(),
});
export type ClanMembersResponse = z.infer<typeof ClanMembersResponseSchema>;
export const ClanJoinRequestSchema = z.object({
publicId: z.string(),
createdAt: z.iso.datetime(),
});
export type ClanJoinRequest = z.infer<typeof ClanJoinRequestSchema>;
export const ClanRequestsResponseSchema = z.object({
results: ClanJoinRequestSchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type ClanRequestsResponse = z.infer<typeof ClanRequestsResponseSchema>;
export const ClanBanSchema = z.object({
publicId: z.string(),
bannedBy: z.string(),
reason: z.string().max(200).nullable(),
createdAt: z.iso.datetime(),
});
export type ClanBan = z.infer<typeof ClanBanSchema>;
export const ClanBansResponseSchema = z.object({
results: ClanBanSchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type ClanBansResponse = z.infer<typeof ClanBansResponseSchema>;
export const JoinClanResponseSchema = z.object({
status: z.enum(["joined", "requested"]),
});
export type JoinClanResponse = z.infer<typeof JoinClanResponseSchema>;
export const ClanGamePlayerSchema = z.object({
publicId: z.string(),
username: z.string(),
won: z.boolean(),
});
export type ClanGamePlayer = z.infer<typeof ClanGamePlayerSchema>;
// "incomplete" covers games with no recorded winner.
// The server stamps this when winnerType IS NULL,
// so we have to accept it on the wire even if the UI collapses it back
// into the defeat-styled badge.
export const ClanGameResultSchema = z.enum(["victory", "defeat", "incomplete"]);
export type ClanGameResult = z.infer<typeof ClanGameResultSchema>;
export const ClanGameFilters = ["ffa", "team", "hvn", "ranked"] as const;
export const ClanGameFilterSchema = z.enum(ClanGameFilters);
export type ClanGameFilter = z.infer<typeof ClanGameFilterSchema>;
export const ClanGameSchema = z.object({
gameId: z.string(),
start: z.iso.datetime(),
durationSeconds: z.number().int().nonnegative(),
map: z.string().optional(),
mode: z.string().optional(),
// playerTeams is `null` (not absent) for FFA / non-team games — use
// `.nullish()` so the wire `null` doesn't fail the parse.
playerTeams: z.string().nullish(),
rankedType: z.string().optional(),
result: ClanGameResultSchema.optional(),
// Mirrors games.num_players nullability — historical rows may not
// carry a value. Use `.nullish()` so wire `null` parses cleanly.
totalPlayers: z.number().int().nonnegative().nullish(),
clanPlayers: ClanGamePlayerSchema.array(),
});
export type ClanGame = z.infer<typeof ClanGameSchema>;
export const ClanGamesResponseSchema = z.object({
results: ClanGameSchema.array(),
// Opaque continuation token. Round-trip verbatim as the `cursor` query
// parameter to fetch the next page; never construct or parse it.
// `null` means the server has no more rows to serve. Page size is
// fixed server-side, so the client never sends a limit.
nextCursor: z.string().nullable(),
});
export type ClanGamesResponse = z.infer<typeof ClanGamesResponseSchema>;
@@ -0,0 +1,116 @@
import { base64url } from "jose";
import { z } from "zod/v4";
import { decodePatternData } from "./PatternDecoder";
import { PlayerPattern } from "./Schemas";
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export type Pattern = z.infer<typeof PatternSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type Skin = z.infer<typeof SkinSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type Subscription = z.infer<typeof SubscriptionSchema>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
export type PatternData = z.infer<typeof PatternDataSchema>;
export const ProductSchema = z.object({
productId: z.string(),
priceId: z.string(),
price: z.string(),
});
export const CosmeticNameSchema = z
.string()
.regex(/^[a-z0-9_]+$/)
.max(32);
export const PatternDataSchema = z
.string()
.max(1403)
.base64url()
.refine(
(val) => {
try {
decodePatternData(val, base64url.decode);
return true;
} catch (e) {
if (e instanceof Error) {
console.error(JSON.stringify(e.message, null, 2));
} else {
console.error(String(e));
}
return false;
}
},
{
message: "Invalid pattern",
},
);
export const ColorPaletteSchema = z.object({
name: z.string(),
primaryColor: z.string(),
secondaryColor: z.string(),
});
const CosmeticSchema = z.object({
name: CosmeticNameSchema,
affiliateCode: z.string().nullable().optional(),
product: ProductSchema.nullable(),
priceSoft: z.number().optional(),
priceHard: z.number().optional(),
artist: z.string().optional(),
rarity: z
.enum(["common", "uncommon", "rare", "epic", "legendary"])
.or(z.string()),
});
export const PatternSchema = CosmeticSchema.extend({
pattern: PatternDataSchema,
colorPalettes: z
.object({
name: z.string(),
isArchived: z.boolean(),
})
.array()
.optional(),
});
export const FlagSchema = CosmeticSchema.extend({
url: z.string(),
});
export const SkinSchema = CosmeticSchema.extend({
url: z.string(),
});
export const PackSchema = CosmeticSchema.extend({
displayName: z.string(),
currency: z.enum(["hard", "soft"]),
amount: z.number().int().positive(),
bonusAmount: z.number().int().nonnegative(),
});
export const SubscriptionSchema = CosmeticSchema.extend({
description: z.string(),
priceMonthly: z.number(),
dailySoftCurrency: z.number(),
dailyHardCurrency: z.number(),
});
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
patterns: z.record(z.string(), PatternSchema),
flags: z.record(z.string(), FlagSchema),
skins: z.record(z.string(), SkinSchema).optional(),
currencyPacks: z.record(z.string(), PackSchema).optional(),
subscriptions: z.record(z.string(), SubscriptionSchema).optional(),
});
export const DefaultPattern = {
name: "default",
patternData: "AAAAAA",
colorPalette: undefined,
} satisfies PlayerPattern;
+20
View File
@@ -0,0 +1,20 @@
// Emoji table shared with the public schema layer (extracted from engine/Util.ts).
export const emojiTable = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🥱", "🫡", "🖕"],
["👋", "👏", "✋", "🙏", "💪"],
["👍", "👎", "🫴", "🤌", "🤦‍♂️"],
["🤝", "🆘", "🕊️", "🏳️", "⏳"],
["🔥", "💥", "💀", "☢️", "⚠️"],
["↖️", "⬆️", "↗️", "👑", "🥇"],
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
["🏭", "🚂", "❓", "🐔", "🐀"],
] as const;
// 2d to 1d array
export const flattenedEmojiTable = emojiTable.flat();
export type Emoji = (typeof flattenedEmojiTable)[number];
+7
View File
@@ -0,0 +1,7 @@
// Event type contracts shared with the public schema layer. The runtime
// EventBus implementation lives in engine/EventBus.ts and re-exports these.
export type GameEvent = object;
export interface EventConstructor<T extends GameEvent = GameEvent> {
new (...args: any[]): T;
}
@@ -0,0 +1,39 @@
import { z } from "zod";
export const GraphicsOverridesSchema = z
.object({
name: z
.object({
nameScaleFactor: z.number(),
cullThreshold: z.number(),
darkNames: z.boolean(),
})
.partial(),
structure: z
.object({
classicIcons: z.boolean(),
})
.partial(),
mapOverlay: z
.object({
highlightFillBrighten: z.number(),
highlightBrighten: z.number(),
highlightThicken: z.number(),
territorySaturation: z.number(),
territoryAlpha: z.number(),
})
.partial(),
railroad: z
.object({
railMinZoom: z.number(),
})
.partial(),
passEnabled: z
.object({
fx: z.boolean(),
})
.partial(),
})
.partial();
export type GraphicsOverrides = z.infer<typeof GraphicsOverridesSchema>;
@@ -0,0 +1,72 @@
import { PlayerPattern } from "./Schemas";
export class PatternDecoder {
private bytes: Uint8Array;
readonly height: number;
readonly width: number;
readonly scale: number;
constructor(
pattern: PlayerPattern,
base64urlDecode: (input: string) => Uint8Array,
) {
({
height: this.height,
width: this.width,
scale: this.scale,
bytes: this.bytes,
} = decodePatternData(pattern.patternData, base64urlDecode));
}
isPrimary(x: number, y: number): boolean {
const px = (x >> this.scale) % this.width;
const py = (y >> this.scale) % this.height;
const idx = py * this.width + px;
const byteIndex = idx >> 3;
const bitIndex = idx & 7;
const byte = this.bytes[3 + byteIndex];
if (byte === undefined) throw new Error("Invalid pattern");
return (byte & (1 << bitIndex)) === 0;
}
scaledHeight(): number {
return this.height << this.scale;
}
scaledWidth(): number {
return this.width << this.scale;
}
}
export function decodePatternData(
b64: string,
base64urlDecode: (input: string) => Uint8Array,
): { height: number; width: number; scale: number; bytes: Uint8Array } {
const bytes = base64urlDecode(b64);
if (bytes.length < 3) {
throw new Error("Pattern data is too short to contain required metadata.");
}
const version = bytes[0];
if (version !== 0) {
throw new Error(`Unrecognized pattern version ${version}.`);
}
const byte1 = bytes[1];
const byte2 = bytes[2];
const scale = byte1 & 0x07;
const width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2;
const height = ((byte2 >> 2) & 0x3f) + 2;
const expectedBits = width * height;
const expectedBytes = (expectedBits + 7) >> 3; // Equivalent to: ceil(expectedBits / 8);
if (bytes.length - 3 < expectedBytes) {
throw new Error("Pattern data is too short for the specified dimensions.");
}
return { height, width, scale, bytes };
}
+781
View File
@@ -0,0 +1,781 @@
import quickChatData from "resources/QuickChat.json";
import { z } from "zod";
import {
ColorPaletteSchema,
CosmeticNameSchema,
PatternDataSchema,
} from "./CosmeticSchemas";
import type { GameEvent } from "./EventBus";
import {
AllPlayers,
Difficulty,
Duos,
GameMapSize,
GameMapType,
GameMode,
GameType,
HumansVsNations,
Quads,
RankedType,
Trios,
UnitType,
} from "./game/GameTypes";
import { PlayerStatsSchema } from "./StatsSchemas";
import { flattenedEmojiTable } from "./Emojis";
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
| StartGameIntent;
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 StartGameIntent = z.infer<typeof StartGameIntentSchema>;
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 PlayerSkin = z.infer<typeof PlayerSkinSchema>;
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,
friends: z.array(z.string()).optional(),
});
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)),
});
// Wire message sent from server to lobby WebSocket clients.
// "full" carries the complete snapshot; "counts" carries only the
// per-lobby player counts, which change far more often than the rest.
export const PublicLobbyFullSchema = z.object({
type: z.literal("full"),
serverTime: z.number(),
games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
});
export const PublicLobbyCountsSchema = z.object({
type: z.literal("counts"),
serverTime: z.number(),
counts: z.record(z.string(), z.number()),
});
export const PublicLobbyMessageSchema = z.discriminatedUnion("type", [
PublicLobbyFullSchema,
PublicLobbyCountsSchema,
]);
export type PublicLobbyMessage = z.infer<typeof PublicLobbyMessageSchema>;
export class LobbyInfoEvent implements GameEvent {
constructor(
public lobby: GameInfo,
public myClientID: ClientID,
) {}
}
export interface ClientInfo {
clientID: ClientID;
username: string;
clanTag: string | null;
friends?: ClientID[];
}
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(),
isWaterNukes: 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().nullable().optional(),
disableClanTags: z.boolean().optional(),
waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks
disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(),
goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(),
startingGold: z.number().int().min(0).max(1000000000).nullable().optional(),
hostCheats: z
.object({
infiniteGold: z.boolean().optional(),
infiniteTroops: z.boolean().optional(),
goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(),
startingGold: z
.number()
.int()
.min(0)
.max(1000000000)
.nullable()
.optional(),
})
.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);
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"),
unitIds: z.array(z.number().int()).nonempty(),
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(),
});
export const StartGameIntentSchema = z.object({
type: z.literal("start_game"),
});
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,
StartGameIntentSchema,
]);
// 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 FlagName = z
.string()
.max(128)
.refine(
(val) => {
if (val === undefined || val === "") return true;
return val.startsWith("flag:") || val.startsWith("country:");
},
{
message: "Invalid flag: must start with country: or flag:",
},
);
export const FlagSchema = z.string();
export const PlayerPatternSchema = z.object({
name: CosmeticNameSchema,
patternData: PatternDataSchema,
colorPalette: ColorPaletteSchema.optional(),
});
export const PlayerColorSchema = z.object({
color: z.string(),
});
// Refs contain cosmetics names, will be replaced by the actual
// content in the server
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagName.optional(),
color: z.string().optional(),
patternName: CosmeticNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
skinName: CosmeticNameSchema.optional(),
});
export const PlayerSkinSchema = z.object({
name: CosmeticNameSchema,
url: z.string(),
});
// Server converts refs to the actual cosmetics here
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
skin: PlayerSkinSchema.optional(),
});
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
cosmetics: PlayerCosmeticsSchema.optional(),
isLobbyCreator: z.boolean().optional(),
friends: z.array(ID).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>;
+118
View File
@@ -0,0 +1,118 @@
import { z } from "zod";
import { UnitType } from "./game/GameTypes";
export const bombUnits = ["abomb", "hbomb", "mirv", "mirvw"] as const;
export const BombUnitSchema = z.enum(bombUnits);
export type BombUnit = z.infer<typeof BombUnitSchema>;
export type NukeType =
| UnitType.AtomBomb
| UnitType.HydrogenBomb
| UnitType.MIRV
| UnitType.MIRVWarhead;
export const unitTypeToBombUnit = {
[UnitType.AtomBomb]: "abomb",
[UnitType.HydrogenBomb]: "hbomb",
[UnitType.MIRV]: "mirv",
[UnitType.MIRVWarhead]: "mirvw",
} as const satisfies Record<NukeType, BombUnit>;
export const boatUnits = ["trade", "trans"] as const;
export const BoatUnitSchema = z.enum(boatUnits);
export type BoatUnit = z.infer<typeof BoatUnitSchema>;
export type BoatUnitType = UnitType.TradeShip | UnitType.TransportShip;
// export const unitTypeToBoatUnit = {
// [UnitType.TradeShip]: "trade",
// [UnitType.TransportShip]: "trans",
// } as const satisfies Record<BoatUnitType, BoatUnit>;
export const otherUnits = [
"city",
"defp",
"port",
"wshp",
"silo",
"saml",
"fact",
] as const;
export const OtherUnitSchema = z.enum(otherUnits);
export type OtherUnit = z.infer<typeof OtherUnitSchema>;
export type OtherUnitType =
| UnitType.City
| UnitType.DefensePost
| UnitType.MissileSilo
| UnitType.Port
| UnitType.SAMLauncher
| UnitType.Warship
| UnitType.Factory;
export const unitTypeToOtherUnit = {
[UnitType.City]: "city",
[UnitType.DefensePost]: "defp",
[UnitType.MissileSilo]: "silo",
[UnitType.Port]: "port",
[UnitType.SAMLauncher]: "saml",
[UnitType.Warship]: "wshp",
[UnitType.Factory]: "fact",
} as const satisfies Record<OtherUnitType, OtherUnit>;
// Attacks
export const ATTACK_INDEX_SENT = 0; // Outgoing attack troops
export const ATTACK_INDEX_RECV = 1; // Incmoing attack troops
export const ATTACK_INDEX_CANCEL = 2; // Cancelled attack troops
// Player types
export const PLAYER_INDEX_HUMAN = 0;
export const PLAYER_INDEX_NATION = 1;
export const PLAYER_INDEX_BOT = 2;
// Boats
export const BOAT_INDEX_SENT = 0; // Boats launched
export const BOAT_INDEX_ARRIVE = 1; // Boats arrived
export const BOAT_INDEX_CAPTURE = 2; // Boats captured
export const BOAT_INDEX_DESTROY = 3; // Boats destroyed
// Bombs
export const BOMB_INDEX_LAUNCH = 0; // Bombs launched
export const BOMB_INDEX_LAND = 1; // Bombs landed
export const BOMB_INDEX_INTERCEPT = 2; // Bombs intercepted
// Gold
export const GOLD_INDEX_WORK = 0; // Gold earned by workers
export const GOLD_INDEX_WAR = 1; // Gold earned by conquering players
export const GOLD_INDEX_TRADE = 2; // Gold earned by trade ships
export const GOLD_INDEX_STEAL = 3; // Gold earned by capturing trade ships
export const GOLD_INDEX_TRAIN_SELF = 4; // Gold earned by own trains
export const GOLD_INDEX_TRAIN_OTHER = 5; // Gold earned by other players trains
// Other Units
export const OTHER_INDEX_BUILT = 0; // Structures and warships built
export const OTHER_INDEX_DESTROY = 1; // Structures and warships destroyed
export const OTHER_INDEX_CAPTURE = 2; // Structures captured
export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by others
export const OTHER_INDEX_UPGRADE = 4; // Structures upgraded
export const BigIntStringSchema = z.preprocess((val) => {
if (val === null) return 0n;
if (typeof val === "string" && /^-?\d+$/.test(val)) return BigInt(val);
if (typeof val === "bigint") return val;
return val;
}, z.bigint());
const AtLeastOneNumberSchema = BigIntStringSchema.array().min(1);
export type AtLeastOneNumber = z.infer<typeof AtLeastOneNumberSchema>;
export const PlayerStatsSchema = z
.object({
attacks: AtLeastOneNumberSchema.optional(),
betrayals: BigIntStringSchema.optional(),
killedAt: BigIntStringSchema.optional(),
conquests: AtLeastOneNumberSchema.optional(),
boats: z.partialRecord(BoatUnitSchema, AtLeastOneNumberSchema).optional(),
bombs: z.partialRecord(BombUnitSchema, AtLeastOneNumberSchema).optional(),
gold: AtLeastOneNumberSchema.optional(),
units: z.partialRecord(OtherUnitSchema, AtLeastOneNumberSchema).optional(),
})
.optional();
export type PlayerStats = z.infer<typeof PlayerStatsSchema>;
@@ -0,0 +1,11 @@
import { z } from "zod";
import { GameConfigSchema } from "./Schemas";
export const CreateGameInputSchema = GameConfigSchema.or(
z
.object({})
.strict()
.transform((val) => undefined),
);
export const GameInputSchema = GameConfigSchema.partial();
+32
View File
@@ -0,0 +1,32 @@
// Framework-agnostic display formatting helpers shared by the engine and
// client. No engine/client dependencies — safe for either side to import.
export function renderTroops(troops: number): string {
return renderNumber(troops / 10);
}
export function renderNumber(
num: number | bigint,
fixedPoints?: number,
): string {
num = Number(num);
num = Math.max(num, 0);
if (num >= 10_000_000) {
const value = Math.floor(num / 100000) / 10;
return value.toFixed(fixedPoints ?? 1) + "M";
} else if (num >= 1_000_000) {
const value = Math.floor(num / 10000) / 100;
return value.toFixed(fixedPoints ?? 2) + "M";
} else if (num >= 100000) {
return Math.floor(num / 1000) + "K";
} else if (num >= 10000) {
const value = Math.floor(num / 100) / 10;
return value.toFixed(fixedPoints ?? 1) + "K";
} else if (num >= 1000) {
const value = Math.floor(num / 10) / 100;
return value.toFixed(fixedPoints ?? 2) + "K";
} else {
return Math.floor(num).toString();
}
}
@@ -0,0 +1,151 @@
// Pure enum/const declarations shared between the engine and clients.
// Extracted from engine/game/Game.ts so the public schema layer
// (engine-public) can reference them without importing engine.
export const AllPlayers = "AllPlayers" as const;
export const Duos = "Duos" as const;
export const Trios = "Trios" as const;
export const Quads = "Quads" as const;
export const HumansVsNations = "Humans Vs Nations" as const;
export enum Difficulty {
Easy = "Easy",
Medium = "Medium",
Hard = "Hard",
Impossible = "Impossible",
}
export enum GameType {
Singleplayer = "Singleplayer",
Public = "Public",
Private = "Private",
}
export enum GameMode {
FFA = "Free For All",
Team = "Team",
}
export enum RankedType {
OneVOne = "1v1",
}
export enum GameMapSize {
Compact = "Compact",
Normal = "Normal",
}
export enum GameMapType {
World = "World",
WorldInverted = "World Inverted",
GiantWorldMap = "Giant World Map",
Europe = "Europe",
EuropeClassic = "Europe Classic",
Mena = "Mena",
NorthAmerica = "North America",
SouthAmerica = "South America",
Oceania = "Oceania",
BlackSea = "Black Sea",
Africa = "Africa",
Pangaea = "Pangaea",
Asia = "Asia",
Mars = "Mars",
BritanniaClassic = "Britannia Classic",
Britannia = "Britannia",
GatewayToTheAtlantic = "Gateway to the Atlantic",
Australia = "Australia",
Iceland = "Iceland",
EastAsia = "East Asia",
BetweenTwoSeas = "Between Two Seas",
FaroeIslands = "Faroe Islands",
DeglaciatedAntarctica = "Deglaciated Antarctica",
FalklandIslands = "Falkland Islands",
Baikal = "Baikal",
Halkidiki = "Halkidiki",
StraitOfGibraltar = "Strait of Gibraltar",
Italia = "Italia",
Japan = "Japan",
Pluto = "Pluto",
Montreal = "Montreal",
NewYorkCity = "New York City",
Achiran = "Achiran",
BaikalNukeWars = "Baikal Nuke Wars",
FourIslands = "Four Islands",
Svalmel = "Svalmel",
GulfOfStLawrence = "Gulf of St. Lawrence",
Lisbon = "Lisbon",
Manicouagan = "Manicouagan",
Lemnos = "Lemnos",
Tourney1 = "Tourney 2 Teams",
Tourney2 = "Tourney 3 Teams",
Tourney3 = "Tourney 4 Teams",
Tourney4 = "Tourney 8 Teams",
Passage = "Passage",
Sierpinski = "Sierpinski",
TheBox = "The Box",
TwoLakes = "Two Lakes",
StraitOfHormuz = "Strait of Hormuz",
Surrounded = "Surrounded",
Didier = "Didier",
DidierFrance = "Didier France",
AmazonRiver = "Amazon River",
BosphorusStraits = "Bosphorus Straits",
BeringStrait = "Bering Strait",
Yenisei = "Yenisei",
TradersDream = "Traders Dream",
Hawaii = "Hawaii",
Alps = "Alps",
NileDelta = "Nile Delta",
Arctic = "Arctic",
SanFrancisco = "San Francisco",
Aegean = "Aegean",
MilkyWay = "MilkyWay",
MareNostrum = "Mare Nostrum",
Dyslexdria = "Dyslexdria",
GreatLakes = "Great Lakes",
StraitOfMalacca = "Strait Of Malacca",
Luna = "Luna",
Conakry = "Conakry",
Caucasus = "Caucasus",
LosAngeles = "Los Angeles",
BeringSea = "Bering Sea",
Antarctica = "Antarctica",
ArchipelagoSea = "ArchipelagoSea",
BajaCalifornia = "Baja California",
MiddleEast = "Middle East",
TaiwanStrait = "Taiwan Strait",
IndianSubcontinent = "Indian Subcontinent",
DanishStraits = "Danish Straits",
NorthwestPassage = "Northwest Passage",
Venice = "Venice",
Korea = "Korea",
Balkans = "Balkans",
YellowSea = "Yellow Sea",
Labyrinth = "Labyrinth",
Caribbean = "Caribbean",
Onion = "Onion",
ChoppingBlock = "Chopping Block",
SoutheastAsia = "SoutheastAsia",
MississippiRiver = "Mississippi River",
HongKong = "Hong Kong",
}
export enum UnitType {
TransportShip = "Transport",
Warship = "Warship",
Shell = "Shell",
SAMMissile = "SAMMissile",
Port = "Port",
AtomBomb = "Atom Bomb",
HydrogenBomb = "Hydrogen Bomb",
TradeShip = "Trade Ship",
MissileSilo = "Missile Silo",
DefensePost = "Defense Post",
SAMLauncher = "SAM Launcher",
City = "City",
MIRV = "MIRV",
MIRVWarhead = "MIRV Warhead",
Train = "Train",
Factory = "Factory",
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}