mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-25 11:47:05 +00:00
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:
co-authored by
Claude Opus 4.8
parent
a7f992e9b0
commit
43d07ca85f
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@openfront/engine-public",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user