diff --git a/environments/development.json b/environments/development.json index 06aff4bef..7d6265bf6 100644 --- a/environments/development.json +++ b/environments/development.json @@ -5,7 +5,7 @@ }, "nodes": {}, "aliases": { - "api": "/api", + "api": "https://api.beta.blitzkit.app", "static": "https://static.blitzkit.app" } } diff --git a/packages/core/src/blitz/getLeagueFromScore.ts b/packages/core/src/blitz/getLeagueFromScore.ts deleted file mode 100644 index 46d578d01..000000000 --- a/packages/core/src/blitz/getLeagueFromScore.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { LEAGUES } from "./rating"; - -export function getLeagueFromScore(score: number) { - return LEAGUES.find(({ minScore }) => score >= minScore)!; -} diff --git a/packages/core/src/blitz/getRatingInfo.ts b/packages/core/src/blitz/getRatingInfo.ts deleted file mode 100644 index 0b89949ef..000000000 --- a/packages/core/src/blitz/getRatingInfo.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { context } from "../blitzkit/context"; -import { patientFetch } from "../blitzkit/patientFetch"; -import type { RatingLeague, RatingReward } from "./rating"; -import type { Region } from "./regions"; -import { regionToRegionSubdomain } from "./regionToRegionSubdomain"; - -export type RatingInfo = - | { - detail: undefined; - - title: string; - icon: string | null; - start_at: string; - finish_at: string; - current_season: number; - updated_at: string; - count: number; - - rewards: RatingReward[]; - leagues: RatingLeague[]; - } - | { detail: { error: string } }; - -const cache: Partial> = {}; - -export async function getRatingInfo(region: Region) { - if (cache[region]) return cache[region]!; - - const response = await patientFetch( - context === "website" - ? `/api/${region}/rating/current/info` - : `https://${regionToRegionSubdomain( - region - )}.wotblitz.com/en/api/rating-leaderboards/season/`, - undefined - ); - const data = (await response.json()) as RatingInfo; - - cache[region] = data; - - return data; -} diff --git a/packages/core/src/blitz/getRatingLeague.ts b/packages/core/src/blitz/getRatingLeague.ts deleted file mode 100644 index 83c249f7d..000000000 --- a/packages/core/src/blitz/getRatingLeague.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { context } from "../blitzkit/context"; -import { patientFetch } from "../blitzkit/patientFetch"; -import type { RatingPlayer } from "../statistics"; -import type { LeagueTop } from "./rating"; -import type { Region } from "./regions"; -import { regionToRegionSubdomain } from "./regionToRegionSubdomain"; - -export async function getRatingLeague( - region: Region, - league: number, - usePatientFetcher = false -) { - const fetcher = (url: string) => - usePatientFetcher - ? patientFetch(url, undefined, { cache: "no-store" }) - : fetch(url, { cache: "no-store" }); - const response = await fetcher( - context === "website" - ? `/api/${region}/rating/current/league/${league}` - : `https://${regionToRegionSubdomain( - region - )}.wotblitz.com/en/api/rating-leaderboards/league/${league}/top/` - ); - const json = await response.json(); - - return context === "website" - ? (json as RatingPlayer[]) - : (json as LeagueTop).result; -} diff --git a/packages/core/src/blitz/getRatingNeighbors.ts b/packages/core/src/blitz/getRatingNeighbors.ts deleted file mode 100644 index 4c2d08523..000000000 --- a/packages/core/src/blitz/getRatingNeighbors.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { patientFetch } from "../blitzkit/patientFetch"; -import { withCORSProxy } from "../blitzkit/withCORSProxy"; -import type { RatingNeighbors } from "./rating"; -import type { Region } from "./regions"; -import { regionToRegionSubdomain } from "./regionToRegionSubdomain"; - -export async function getRatingNeighbors( - region: Region, - id: number, - count: number, - usePatientFetcher = false -) { - const fetcher = (url: string) => - usePatientFetcher - ? patientFetch(url, undefined, { cache: "no-store" }) - : fetch(url, { cache: "no-store" }); - const response = await fetcher( - withCORSProxy( - `https://${regionToRegionSubdomain( - region - )}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${count}` - ) - ); - const json = (await response.json()) as RatingNeighbors; - - return json; -} diff --git a/packages/core/src/blitz/index.ts b/packages/core/src/blitz/index.ts index 6b8193288..00aaabe43 100644 --- a/packages/core/src/blitz/index.ts +++ b/packages/core/src/blitz/index.ts @@ -10,13 +10,8 @@ export * from "./getAccountAchievements"; export * from "./getAccountInfo"; export * from "./getClanAccountInfo"; export * from "./getClanInfo"; -export * from "./getLeagueFromScore"; -export * from "./getRatingInfo"; -export * from "./getRatingLeague"; -export * from "./getRatingNeighbors"; export * from "./getTankStats"; export * from "./idToRegion"; -export * from "./isOnGoingRatingSeason"; export * from "./isValidBlitzId"; export * from "./nations"; export * from "./normalizeExtra"; @@ -28,6 +23,5 @@ export * from "./regions"; export * from "./regionSubdomainToRegion"; export * from "./regionToRegionSubdomain"; export * from "./searchClansAcrossRegions"; -export * from "./searchCurrentRatingPlayers"; export * from "./searchPlayersAcrossRegions"; export * from "./toUniqueId"; diff --git a/packages/core/src/blitz/isOnGoingRatingSeason.ts b/packages/core/src/blitz/isOnGoingRatingSeason.ts deleted file mode 100644 index dbbfaa69e..000000000 --- a/packages/core/src/blitz/isOnGoingRatingSeason.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { getRatingInfo } from "./getRatingInfo"; - -export async function isOnGoingRatingSeason() { - const ratingInfo = await getRatingInfo("com"); - return ratingInfo.detail === undefined; -} diff --git a/packages/core/src/blitz/searchCurrentRatingPlayers.ts b/packages/core/src/blitz/searchCurrentRatingPlayers.ts deleted file mode 100644 index b40ae6e47..000000000 --- a/packages/core/src/blitz/searchCurrentRatingPlayers.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { context } from "../blitzkit/context"; -import { type Region } from "./regions"; -import { regionToRegionSubdomain } from "./regionToRegionSubdomain"; - -type CurrentRatingPlayerSearch = Record< - number, - { - spa_id: number; - nickname: string; - clan_tag: string; - } & ( - | { - skip: true; - } - | { - skip: false; - mmr: number; - season_number: number; - calibrationBattlesLeft: number; - number: number; - updated_at: string; - neighbors: []; - } - ) ->; - -export async function searchCurrentRatingPlayers(region: Region, name: string) { - const encodedSearch = encodeURIComponent(name); - const response = await fetch( - context === "website" - ? `/api/${region}/rating/current/search/${encodedSearch}` - : `https://${regionToRegionSubdomain( - region - )}.wotblitz.com/en/api/rating-leaderboards/search/?prefix=${encodedSearch}`, - { cache: "no-store" } - ); - const accountList = (await response.json()) as CurrentRatingPlayerSearch; - - return accountList; -} diff --git a/packages/core/src/blitzkit/alias.ts b/packages/core/src/blitzkit/alias.ts new file mode 100644 index 000000000..119ada258 --- /dev/null +++ b/packages/core/src/blitzkit/alias.ts @@ -0,0 +1,13 @@ +import { environmentConfig } from "@blitzkit/core"; + +export function alias(alias: string, path: string) { + const environment = environmentConfig(); + + if (!(alias in environment.aliases)) { + throw new Error(`Invalid alias "${alias}"`); + } + + const base = environment.aliases[alias]; + + return `${base}${path}`; +} diff --git a/packages/core/src/blitzkit/api/client.ts b/packages/core/src/blitzkit/api/client.ts index 2a28275a4..d71fb728d 100644 --- a/packages/core/src/blitzkit/api/client.ts +++ b/packages/core/src/blitzkit/api/client.ts @@ -1,3 +1,4 @@ +import { alias } from "@blitzkit/core"; import { fetchPB } from "../../protobuf"; import { CamouflageDefinitions, @@ -14,38 +15,50 @@ import { BlitzKitAPI } from "./base"; export class ClientBlitzKitAPI extends BlitzKitAPI { gameDefinitions() { - return fetchPB("/api/definitions/game.pb", GameDefinitions); + return fetchPB(alias("api", "/definitions/game.pb"), GameDefinitions); } consumableDefinitions() { - return fetchPB("/api/definitions/consumables.pb", ConsumableDefinitions); + return fetchPB( + alias("api", "/definitions/consumables.pb"), + ConsumableDefinitions, + ); } tankDefinitions() { - return fetchPB("/api/definitions/tanks.pb", TankDefinitions); + return fetchPB(alias("api", "/definitions/tanks.pb"), TankDefinitions); } camouflageDefinitions() { - return fetchPB("/api/definitions/camouflage.pb", CamouflageDefinitions); + return fetchPB( + alias("api", "/definitions/camouflage.pb"), + CamouflageDefinitions, + ); } provisionDefinitions() { - return fetchPB("/api/definitions/provisions.pb", ProvisionDefinitions); + return fetchPB( + alias("api", "/definitions/provisions.pb"), + ProvisionDefinitions, + ); } modelDefinitions() { - return fetchPB("/api/definitions/models.pb", ModelDefinitions); + return fetchPB(alias("api", "/definitions/models.pb"), ModelDefinitions); } skillDefinitions() { - return fetchPB("/api/definitions/skills.pb", SkillDefinitions); + return fetchPB(alias("api", "/definitions/skills.pb"), SkillDefinitions); } equipmentDefinitions() { - return fetchPB("/api/definitions/equipment.pb", EquipmentDefinitions); + return fetchPB( + alias("api", "/definitions/equipment.pb"), + EquipmentDefinitions, + ); } galleryDefinitions() { - return fetchPB("/api/definitions/gallery.pb", Gallery); + return fetchPB(alias("api", "/definitions/gallery.pb"), Gallery); } } diff --git a/packages/core/src/blitzkit/api/server/10_galleryDefinitions.ts b/packages/core/src/blitzkit/api/server/10_galleryDefinitions.ts index fede7e381..40cf9295e 100644 --- a/packages/core/src/blitzkit/api/server/10_galleryDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/10_galleryDefinitions.ts @@ -1,8 +1,8 @@ import { Avatar, fetchGlossary, Gallery } from "@blitzkit/core"; -import { ServerBlitzKitAPI9 } from "@blitzkit/core/src/blitzkit/api/server/9_gameDefinitions"; import locales from "@blitzkit/i18n/locales.json"; import { extname } from "path"; import { Cache } from "./0_base"; +import { ServerBlitzKitAPI9 } from "./9_gameDefinitions"; export abstract class ServerBlitzKitAPI10 extends ServerBlitzKitAPI9 { @Cache() diff --git a/packages/core/src/blitzkit/environment.ts b/packages/core/src/blitzkit/environment.ts new file mode 100644 index 000000000..2fffb5c5c --- /dev/null +++ b/packages/core/src/blitzkit/environment.ts @@ -0,0 +1,33 @@ +import { assertSecret } from "@blitzkit/core"; + +interface EnvironmentConfig { + paths: Record; + nodes: Record; + aliases: Record; +} + +export const environments = import.meta.glob( + "../../../../environments/*.json", + { + eager: true, + import: "default", + }, +) as Record; +const root = "../../../../environments"; + +for (const key in environments) { + const trimmed = key.slice(root.length + 1, -5); + + environments[trimmed] = environments[key]; + delete environments[key]; +} + +export function environmentConfig() { + const environment = assertSecret(import.meta.env.ENVIRONMENT); + + if (!(environment in environments)) { + throw new Error(`Invalid environment "${environment}"`); + } + + return environments[environment]; +} diff --git a/packages/core/src/blitzkit/getArchivedLatestSeasonNumber.ts b/packages/core/src/blitzkit/getArchivedLatestSeasonNumber.ts deleted file mode 100644 index 460a81f01..000000000 --- a/packages/core/src/blitzkit/getArchivedLatestSeasonNumber.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getRatingInfo } from "../blitz/getRatingInfo"; -import { assertSecret } from "./assertSecret"; - -interface GitHubTrees { - tree: { path: string; url: string }[]; -} - -let cachedLatestArchivedSeasonNumber: number | null = null; - -export async function getArchivedLatestSeasonNumber() { - if (cachedLatestArchivedSeasonNumber !== null) { - return cachedLatestArchivedSeasonNumber; - } - - const ratingInfo = await getRatingInfo("com"); - - if (ratingInfo.detail) { - const regionsURL = ( - (await fetch( - `https://api.github.com/repos/${assertSecret( - import.meta.env.PUBLIC_ASSET_REPO - )}/git/trees/main` - ).then((response) => response.json())) as GitHubTrees - ).tree.find(({ path }) => path === "regions")!.url; - const comURL = ( - (await fetch(regionsURL).then((response) => - response.json() - )) as GitHubTrees - ).tree.find(({ path }) => path === "com")!.url; - const ratingURL = ( - (await fetch(comURL).then((response) => response.json())) as { - tree: [{ url: string }]; - } - ).tree[0].url; - const latestSeason = Math.max( - ...( - (await fetch(ratingURL).then((response) => response.json())) as { - tree: { path: string }[]; - } - ).tree.map(({ path }) => Number(path)) - ); - - cachedLatestArchivedSeasonNumber = latestSeason; - } else { - cachedLatestArchivedSeasonNumber = ratingInfo.current_season - 1; - } - - return cachedLatestArchivedSeasonNumber; -} diff --git a/packages/core/src/blitzkit/getArchivedRatingInfo.ts b/packages/core/src/blitzkit/getArchivedRatingInfo.ts deleted file mode 100644 index 782838baf..000000000 --- a/packages/core/src/blitzkit/getArchivedRatingInfo.ts +++ /dev/null @@ -1,30 +0,0 @@ -// import { RatingInfo, Region, asset } from '@blitzkit/core'; - -// const cache: Record< -// Region, -// Record< -// number, -// RatingInfo & { -// detail: undefined; -// } -// > -// > = { -// com: {}, -// eu: {}, -// asia: {}, -// }; - -// export async function getArchivedRatingInfo(region: Region, season: number) { -// if (!cache[region][season]) { -// const info = await fetch( -// asset(`regions/${region}/rating/${season}/info.cdon.lz4`), -// ) -// .then((response) => response.arrayBuffer()) -// .then((buffer) => -// superDecompress(buffer), -// ); -// cache[region][season] = info; -// } - -// return cache[region][season]; -// } diff --git a/packages/core/src/blitzkit/index.ts b/packages/core/src/blitzkit/index.ts index a60517d32..700e57018 100644 --- a/packages/core/src/blitzkit/index.ts +++ b/packages/core/src/blitzkit/index.ts @@ -1,3 +1,4 @@ +export * from "./alias"; export * from "./assertSecret"; export * from "./availableProvisions"; export * from "./averageDefinitions"; @@ -7,6 +8,7 @@ export * from "./createDefaultSkills"; export * from "./degressiveStat"; export * from "./discoveredIdDefinitions"; export * from "./emblemURL"; +export * from "./environment"; export * from "./eventManager"; export * from "./getTimeDaysAgo"; export * from "./isExplosive"; diff --git a/packages/core/src/blitzkit/tankIcon.ts b/packages/core/src/blitzkit/tankIcon.ts index 6fef51e05..8eef1203d 100644 --- a/packages/core/src/blitzkit/tankIcon.ts +++ b/packages/core/src/blitzkit/tankIcon.ts @@ -1,5 +1,7 @@ +import { alias } from "@blitzkit/core"; + export const BLITZKIT_TANK_ICON_SIZE = { width: 40 * 16, height: 30 * 16 }; export function tankIcon(id: number, size: "big" | "small" = "big") { - return `/api/tanks/${id}/icons/${size}.webp`; + return alias("api", `/tanks/${id}/icons/${size}.webp`); } diff --git a/packages/scripts/src/buildRatingLeaderboard.ts b/packages/scripts/src/buildRatingLeaderboard.ts deleted file mode 100644 index 4cd4081e1..000000000 --- a/packages/scripts/src/buildRatingLeaderboard.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { - getAccountInfo, - patientFetchJSON, - RatingInfo, - RatingLeaderboard, - RatingLeaderboardEntryV1, - RatingLeaderboardEntryV2, - RatingNeighbors, - RatingPlayer, - Region, - REGIONS, - regionToRegionSubdomain, -} from "@blitzkit/core"; -import { chunk } from "lodash-es"; -import { argv } from "process"; -import { AssetUploader } from "./core/github/assetUploader"; - -const region = argv - .find((arg) => arg.startsWith("--region=")) - ?.split("=")[1] as Region | undefined; - -if (region === undefined || !REGIONS.includes(region)) { - throw new Error("No valid region specified"); -} - -console.log(`Building rating leaderboard for ${region}`); - -const regionSubdomain = regionToRegionSubdomain(region); -const NEIGHBORS = 2 ** 8; -const PLAYERS = await patientFetchJSON( - `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/` -).then((data) => (data.detail === undefined ? data.count : undefined)); - -if (PLAYERS === undefined) { - throw new Error("No current rating season running"); -} - -const players: Record = {}; -const entriesV1: RatingLeaderboardEntryV1[] = []; - -let registered = 0; - -async function branchFromPlayer(id: number) { - const { neighbors } = await patientFetchJSON( - `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}` - ); - - const firstPlayer = neighbors[0]; - const lastPlayer = neighbors[neighbors.length - 1]; - - const branches: Promise[] = []; - if (!players[firstPlayer.number]) - branches.push(branchFromPlayer(firstPlayer.spa_id)); - if (!players[lastPlayer.number]) - branches.push(branchFromPlayer(lastPlayer.spa_id)); - - neighbors.forEach((neighbor) => { - if (players[neighbor.number]) return; - - players[neighbor.number] = neighbor; - registered++; - }); - - console.log( - `Registered ${registered} / ${PLAYERS} (${( - (registered / PLAYERS!) * - 100 - ).toFixed(2)}%)` - ); - - await Promise.all(branches); -} - -async function branchFromLeague(id: number) { - const { result } = await patientFetchJSON<{ result: RatingPlayer[] }>( - `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/` - ); - - const firstPlayer = result[0]; - const lastPlayer = result.at(-1)!; - - await Promise.all([ - branchFromPlayer(firstPlayer.spa_id), - branchFromPlayer(lastPlayer.spa_id), - ]); -} - -console.log("Branching from leagues..."); -await Promise.all([ - branchFromLeague(0), - branchFromLeague(1), - branchFromLeague(2), - branchFromLeague(3), - branchFromLeague(4), -]); - -console.log("Converting to an array..."); -for (let index = 0; index < PLAYERS; index++) { - const listing = players[index + 1]; - if (listing) { - entriesV1[index] = { id: listing.spa_id, score: listing.score }; - } else { - console.warn(`Position ${index + 1} had no player`); - entriesV1[index] = { id: 0, score: 0 }; - } -} - -console.log("Requesting further stats..."); -const entries: RatingLeaderboardEntryV2[] = []; -let chunkIndex = 0; -for (const leaderboardChunk of chunk(entriesV1, 100)) { - console.log( - `Chunk ${chunkIndex + 1} of ${Math.ceil(entriesV1.length / 100)}` - ); - - const stats = await getAccountInfo( - region, - leaderboardChunk.map(({ id }) => id), - ["statistics.rating"] - ); - - stats.forEach((stat, index) => { - const trueIndex = chunkIndex * 100 + index; - - if ( - leaderboardChunk[index].id === 0 || - stat?.statistics.rating === undefined - ) { - entries[trueIndex] = { - battles: 0, - damage: 0, - id: 0, - kills: 0, - score: 0, - survived: 0, - wins: 0, - }; - } else { - entries[trueIndex] = { - id: leaderboardChunk[index].id, - score: leaderboardChunk[index].score, - battles: stat.statistics.rating.battles, - damage: stat.statistics.rating.damage_dealt, - kills: stat.statistics.rating.frags, - survived: stat.statistics.rating.survived_battles, - wins: stat.statistics.rating.wins, - }; - } - }); - - chunkIndex++; -} - -const info = await patientFetchJSON( - `https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/` -); -const normalizedServer = regionSubdomain === "na" ? "com" : regionSubdomain; -const content = RatingLeaderboard.encode({ - version: { $case: "v2", value: { entries } }, -}).finish(); - -using uploader = new AssetUploader(`rating leaderboard ${regionSubdomain}`); - -await uploader.add({ - path: `regions/${normalizedServer}/rating/${info.current_season}.pb`, - content, -}); - -await uploader.flush(); diff --git a/packages/scripts/src/setupDepotDownloader.ts b/packages/scripts/src/setupDepotDownloader.ts deleted file mode 100644 index 72ef9322b..000000000 --- a/packages/scripts/src/setupDepotDownloader.ts +++ /dev/null @@ -1,39 +0,0 @@ -import decompress from "decompress"; -import { existsSync } from "fs"; -import { mkdir, readdir, writeFile } from "fs/promises"; - -if (!existsSync("../../temp")) { - console.log("Creating temp directory..."); - await mkdir("../../temp"); -} - -console.log("Downloading depot downloader..."); -await fetch( - "https://api.github.com/repos/SteamRE/DepotDownloader/releases/latest" -) - .then( - (response) => - response.json() as Promise<{ - assets: { browser_download_url: string; name: string }[]; - }> - ) - .then(({ assets }) => { - const asset = assets.find(({ name }) => name.includes("linux-x64")); - if (!asset) throw new Error("No asset found"); - return fetch(asset.browser_download_url); - }) - .then((response) => response.arrayBuffer()) - .then((arrayBuffer) => - writeFile("../../temp/depotDownloader.zip", new Uint8Array(arrayBuffer)) - ); - -console.log("Unzipping..."); -await decompress( - "../../temp/depotDownloader.zip", - "../../temp/depotDownloader" -); - -console.log( - "Download complete; files:", - await readdir("../../temp/depotDownloader") -); diff --git a/packages/website/src/components/Compare/Body.tsx b/packages/website/src/components/Compare/Body.tsx index a3e559ce4..ee88cd4e4 100644 --- a/packages/website/src/components/Compare/Body.tsx +++ b/packages/website/src/components/Compare/Body.tsx @@ -1,3 +1,4 @@ +import { alias } from "@blitzkit/core"; import { Flex, Heading, IconButton, Table } from "@radix-ui/themes"; import type { TankCharacteristics } from "../../core/blitzkit/tankCharacteristics"; import { useLocale } from "../../hooks/useLocale"; @@ -57,7 +58,10 @@ export function Body({ stats }: BodyProps) { alt={unwrap(thisShell.name!)} width={16} height={16} - src={`/api/icons/shells/${thisShell.icon}.webp`} + src={alias( + "api", + `/icons/shells/${thisShell.icon}.webp`, + )} /> ))} diff --git a/packages/website/src/components/Compare/CompareTable.tsx b/packages/website/src/components/Compare/CompareTable.tsx index 87123a411..59d7ab555 100644 --- a/packages/website/src/components/Compare/CompareTable.tsx +++ b/packages/website/src/components/Compare/CompareTable.tsx @@ -1,4 +1,4 @@ -import { availableProvisions, permanentSkills } from "@blitzkit/core"; +import { alias, availableProvisions, permanentSkills } from "@blitzkit/core"; import { checkConsumableProvisionInclusivity } from "@blitzkit/core/src/blitzkit/checkConsumableProvisionInclusivity"; import { ComponentPlaceholderIcon } from "@radix-ui/react-icons"; import { @@ -231,7 +231,10 @@ export function CompareTable({ stats }: CompareTableProps) { alt={module} width={24} height={24} - src={`/api/icons/modules/${module}.webp`} + src={alias( + "api", + `/icons/modules/${module}.webp`, + )} style={{ marginLeft: index > 0 ? -8 : 0, objectFit: "contain", @@ -320,7 +323,10 @@ export function CompareTable({ stats }: CompareTableProps) { .name!, )} key={provision} - src={`/api//icons/provisions/${provision}.webp`} + src={alias( + "api", + `//icons/provisions/${provision}.webp`, + )} style={{ left: "50%", /** @@ -525,7 +531,10 @@ export function CompareTable({ stats }: CompareTableProps) { ].name!, )} key={consumable} - src={`/api/icons/consumables/${consumable}.webp`} + src={alias( + "api", + `/icons/consumables/${consumable}.webp`, + )} style={{ left: "50%", /** @@ -617,7 +626,7 @@ export function CompareTable({ stats }: CompareTableProps) { variant={camouflage ? "solid" : "outline"} > 0} discriminator={level === 0 ? undefined : romanize(level)} - icon={`/api/icons/skills/${skill}.webp`} + icon={alias("api", `/icons/skills/${skill}.webp`)} onClick={(event) => { if (!onChange) return; diff --git a/packages/website/src/components/Gallery/Card.tsx b/packages/website/src/components/Gallery/Card.tsx index aa524492d..dfc762b44 100644 --- a/packages/website/src/components/Gallery/Card.tsx +++ b/packages/website/src/components/Gallery/Card.tsx @@ -1,4 +1,4 @@ -import { Avatar } from "@blitzkit/core"; +import { alias, Avatar } from "@blitzkit/core"; import { Cross1Icon, DownloadIcon } from "@radix-ui/react-icons"; import { Dialog, @@ -18,7 +18,10 @@ type GalleryCardProps = MaybeSkeletonComponentProps<{ }>; export function GalleryCard(props: GalleryCardProps) { - const src = `/api/gallery/avatars/${!props.skeleton && `${props.avatar.id}${props.avatar.extension}`}`; + const src = alias( + "api", + `/gallery/avatars/${!props.skeleton && `${props.avatar.id}${props.avatar.extension}`}`, + ); const card = useRef(null!); const { unwrap } = useLocale(); diff --git a/packages/website/src/components/Guesser.tsx b/packages/website/src/components/Guesser.tsx index 93c02e1cf..09c1a3cd0 100644 --- a/packages/website/src/components/Guesser.tsx +++ b/packages/website/src/components/Guesser.tsx @@ -1,4 +1,5 @@ import { + alias, SEARCH_KEYS, TankDefinition, TIER_ROMAN_NUMERALS, @@ -149,7 +150,10 @@ export function Guesser() { prefix={ } discriminator={ diff --git a/packages/website/src/components/ModuleButtons/ModuleButton.tsx b/packages/website/src/components/ModuleButtons/ModuleButton.tsx index 3f343b0fe..007dd1e28 100644 --- a/packages/website/src/components/ModuleButtons/ModuleButton.tsx +++ b/packages/website/src/components/ModuleButtons/ModuleButton.tsx @@ -1,3 +1,4 @@ +import { alias } from "@blitzkit/core"; import { GenericTankComponentButton } from "./GenericTankComponentButton"; import type { TankComponentButtonProps } from "./TankComponentButton"; @@ -8,7 +9,7 @@ interface ModuleProps extends TankComponentButtonProps { export function ModuleButton({ module, ...props }: ModuleProps) { return ( ); diff --git a/packages/website/src/components/ModuleButtons/ShellButton.tsx b/packages/website/src/components/ModuleButtons/ShellButton.tsx index b9452e71f..3aeb7d9d4 100644 --- a/packages/website/src/components/ModuleButtons/ShellButton.tsx +++ b/packages/website/src/components/ModuleButtons/ShellButton.tsx @@ -1,3 +1,4 @@ +import { alias } from "@blitzkit/core"; import { GenericTankComponentButton } from "./GenericTankComponentButton"; import type { TankComponentButtonProps } from "./TankComponentButton"; @@ -8,7 +9,7 @@ interface ShellButtonProps extends TankComponentButtonProps { export function ShellButton({ shell, ...props }: ShellButtonProps) { return ( ); diff --git a/packages/website/src/components/SmallTankIcon.tsx b/packages/website/src/components/SmallTankIcon.tsx index 74fb89e33..144301eb2 100644 --- a/packages/website/src/components/SmallTankIcon.tsx +++ b/packages/website/src/components/SmallTankIcon.tsx @@ -1,3 +1,5 @@ +import { alias } from "@blitzkit/core"; + interface SmallTankIconProps { id: number; size?: number; @@ -17,7 +19,7 @@ export function SmallTankIcon({ id, size = HEIGHT }: SmallTankIconProps) { > {`Tank( className="tank-search-card" data-provide-link={provideLink} style={{ - backgroundImage: `url(/api/flags/scratched/${tank.nation}.webp)`, + backgroundImage: `url(${alias( + "api", + `/flags/scratched/${tank.nation}.webp`, + )})`, ...style, }} {...props} @@ -76,7 +79,7 @@ export const TankCard = forwardRef( > {unwrap(tank.name!)} diff --git a/packages/website/src/components/TankSearch/components/FilterControl.tsx b/packages/website/src/components/TankSearch/components/FilterControl.tsx index 1451bda88..d43a1c5b9 100644 --- a/packages/website/src/components/TankSearch/components/FilterControl.tsx +++ b/packages/website/src/components/TankSearch/components/FilterControl.tsx @@ -1,4 +1,5 @@ import { + alias, GunDefinition, ShellType, TANK_CLASSES, @@ -309,7 +310,7 @@ function NationsFilter() { width: "1.25em", height: "1.25em", }} - src={`/api/flags/circle/${nation}.webp`} + src={alias("api", `/flags/circle/${nation}.webp`)} /> ))} @@ -346,7 +347,7 @@ function NationsFilter() { right="0" mr="-2" style={{ - backgroundImage: `url(${`/api/flags/scratched/${nation}.webp`})`, + backgroundImage: `url(${alias("api", `/flags/scratched/${nation}.webp`)})`, backgroundSize: "100%", backgroundRepeat: "no-repeat", backgroundPosition: "5rem center", @@ -679,9 +680,10 @@ function IndividualShellFilter({ {shells[index] !== null && ( )} @@ -712,9 +714,12 @@ function IndividualShellFilter({ color={selected ? undefined : "gray"} > @@ -883,7 +888,7 @@ function ConsumablesFilter() { height: "1.25em", objectFit: "contain", }} - src={`/api/icons/consumables/${consumable}.webp`} + src={alias("api", `/icons/consumables/${consumable}.webp`)} /> ))} @@ -928,7 +933,7 @@ function ConsumablesFilter() { height: "1.25em", objectFit: "contain", }} - src={`/api/icons/consumables/${consumable}.webp`} + src={alias("api", `/icons/consumables/${consumable}.webp`)} /> {unwrap(consumableDefinition.name!)} @@ -977,7 +982,7 @@ function ProvisionsFilter() { height: "1.25em", objectFit: "contain", }} - src={`/api/icons/provisions/${provision}.webp`} + src={alias("api", `/icons/provisions/${provision}.webp`)} /> ))} @@ -1022,7 +1027,7 @@ function ProvisionsFilter() { height: "1.25em", objectFit: "contain", }} - src={`/api/icons/provisions/${provision}.webp`} + src={alias("api", `/icons/provisions/${provision}.webp`)} /> {unwrap(provisionDefinition.name!)} @@ -1151,7 +1156,10 @@ function GameModeAbilitiesFilter() { height: "1.25em", objectFit: "contain", }} - src={`/api/icons/consumables/${consumable}.webp`} + src={alias( + "api", + `/icons/consumables/${consumable}.webp`, + )} /> {unwrap(abilityDefinition.name!)} @@ -1200,7 +1208,10 @@ function GameModeAbilitiesFilter() { height: "1.25em", objectFit: "contain", }} - src={`/api/icons/provisions/${provision}.webp`} + src={alias( + "api", + `/icons/provisions/${provision}.webp`, + )} /> {unwrap(powerDefinition.name!)} diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/Firepower.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/Firepower.tsx index 3e2378d5f..bcebbf061 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/Firepower.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/Firepower.tsx @@ -1,4 +1,4 @@ -import { isExplosive } from "@blitzkit/core"; +import { alias, isExplosive } from "@blitzkit/core"; import { literals } from "@blitzkit/i18n"; import { Flex, Heading, IconButton, Tooltip } from "@radix-ui/themes"; import { useLocale } from "../../../../../../hooks/useLocale"; @@ -62,7 +62,7 @@ export function Firepower({ alt={unwrap(thisShell.name!)} width={16} height={16} - src={`/api/icons/shells/${thisShell.icon}.webp`} + src={alias("api", `/icons/shells/${thisShell.icon}.webp`)} /> diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/components/ShellSwitcher.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/components/ShellSwitcher.tsx index 68667a0a0..69788ab6a 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/components/ShellSwitcher.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/components/ShellSwitcher.tsx @@ -1,3 +1,4 @@ +import { alias } from "@blitzkit/core"; import { Flex, IconButton, Tooltip } from "@radix-ui/themes"; import { useLocale } from "../../../../../../../../hooks/useLocale"; import { Duel } from "../../../../../../../../stores/duel"; @@ -37,7 +38,7 @@ export function ShellSwitcher() { {unwrap(thisShell.name!)} diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/index.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/index.tsx index 52deb82c8..4b8cf3d5c 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/index.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/RicochetVisualizer/index.tsx @@ -1,4 +1,4 @@ -import { isExplosive } from "@blitzkit/core"; +import { alias, isExplosive } from "@blitzkit/core"; import { literals } from "@blitzkit/i18n"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useRef, useState } from "react"; @@ -124,7 +124,7 @@ export function RicochetVisualizer({ stats }: StatsAcceptorProps) { /> {!tank.fixed_camouflage && ( )} 0} banner={ cooldownBooster === 0 diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Modules.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Modules.tsx index ef2e7fb2a..cd44eeb82 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Modules.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Modules.tsx @@ -1,4 +1,5 @@ import { + alias, EngineDefinition, formatCompact, GunDefinition, @@ -110,7 +111,7 @@ function ModuleButton({ {unlock.cost!.type} {unwrap(provisions.name!)} @@ -101,7 +108,7 @@ export function GameModeSection() { height: "1.5em", objectFit: "contain", }} - src={`/api/icons/consumables/${id}.webp`} + src={alias("api", `/icons/consumables/${id}.webp`)} alt={unwrap(consumable.name!)} /> {unwrap(consumable.name!)} diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/DynamicArmorSwitcher.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/DynamicArmorSwitcher.tsx index 6a5bef873..ec788d455 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/DynamicArmorSwitcher.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/DynamicArmorSwitcher.tsx @@ -1,3 +1,4 @@ +import { alias } from "@blitzkit/core"; import { IconButton } from "@radix-ui/themes"; import { useArmor } from "../../../../../../hooks/useArmor"; import { Duel } from "../../../../../../stores/duel"; @@ -37,7 +38,7 @@ export function DynamicArmorSwitcher() { > Dynamic Armor {unwrap(thisShell.name!)} Calibrated Shells Enhanced Armor {label} diff --git a/packages/website/src/components/Tankopedia/MetaSection/components/Stars/components/PartialStar.tsx b/packages/website/src/components/Tankopedia/MetaSection/components/Stars/components/PartialStar.tsx deleted file mode 100644 index d2ee2bb22..000000000 --- a/packages/website/src/components/Tankopedia/MetaSection/components/Stars/components/PartialStar.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons'; -import { Box, type BoxProps } from '@radix-ui/themes'; - -type PartialStarProps = BoxProps & { - fill: number; -}; - -export function PartialStar({ fill, ...props }: PartialStarProps) { - return ( - - - {fill > 0 && ( - - - - )} - - ); -} diff --git a/packages/website/src/components/Tankopedia/MetaSection/components/Stars/index.tsx b/packages/website/src/components/Tankopedia/MetaSection/components/Stars/index.tsx deleted file mode 100644 index 06f4187d0..000000000 --- a/packages/website/src/components/Tankopedia/MetaSection/components/Stars/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { StarsInt } from '@blitzkit/core'; -import { type TextProps, Flex, Text } from '@radix-ui/themes'; -import { times } from 'lodash-es'; -import { PartialStar } from './components/PartialStar'; - -type StarsProps = TextProps & { - stars: number | null; - onCast?: (value: StarsInt) => void; -}; - -export function Stars({ stars, onCast, ...props }: StarsProps) { - let color: TextProps['color'] = 'green'; - - if (stars === null) color = 'gray'; - else if (stars < 1.5) color = 'tomato'; - else if (stars < 2.5) color = 'orange'; - else if (stars < 3.5) color = 'amber'; - else if (stars < 4.5) color = 'blue'; - - return ( - - - {stars !== null && - times(Math.floor(stars), (index) => ( - onCast?.((index + 1) as StarsInt)} - /> - ))} - {stars !== null && stars % 1 !== 0 && } - {times(5 - Math.ceil(stars ?? 0), (index) => ( - - onCast?.((Math.floor(stars ?? 0) + index + 1) as StarsInt) - } - /> - ))} - - - ); -} diff --git a/packages/website/src/components/Tankopedia/MetaSection/components/Votes/components/StarRow.tsx b/packages/website/src/components/Tankopedia/MetaSection/components/Votes/components/StarRow.tsx deleted file mode 100644 index 199e734b9..000000000 --- a/packages/website/src/components/Tankopedia/MetaSection/components/Votes/components/StarRow.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Flex, Text } from '@radix-ui/themes'; -import { Stars } from '../../Stars'; - -interface StarRowProps { - stars: number | null; - children: string; -} - -export function StarRow({ stars, children }: StarRowProps) { - return ( - - - {children} - - - - ); -} diff --git a/packages/website/src/components/Tankopedia/MetaSection/components/Votes/components/VoteCaster.tsx b/packages/website/src/components/Tankopedia/MetaSection/components/Votes/components/VoteCaster.tsx deleted file mode 100644 index a036259cb..000000000 --- a/packages/website/src/components/Tankopedia/MetaSection/components/Votes/components/VoteCaster.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import type { BlitzkitResponse, StarsInt } from "@blitzkit/core"; -import { PaperPlaneIcon } from "@radix-ui/react-icons"; -import { - AlertDialog, - Button, - Dialog, - Flex, - Spinner, - Text, -} from "@radix-ui/themes"; -import { useState } from "react"; -import { useLocale } from "../../../../../../hooks/useLocale"; -import { App } from "../../../../../../stores/app"; -import { Duel } from "../../../../../../stores/duel"; -import { Stars } from "../../Stars"; - -export function VoteCaster() { - const tank = Duel.use((state) => state.protagonist.tank); - const [easiness, setEasiness] = useState(null); - const [firepower, setFirepower] = useState(null); - const [maneuverability, setManeuverability] = useState(null); - const [survivability, setSurvivability] = useState(null); - // true = vote successful, false = vote failed, null = hide alert - const [postVote, setPostVote] = useState(null); - // true = open, false = closed, null = casting - const [showDialogue, setShowDialogue] = useState(false); - const { unwrap } = useLocale(); - - return ( - <> - - - - - - - Voting for {unwrap(tank.name!)} - - - - - Easiness{" "} - - {easiness === null ? "(empty)" : `(${easiness})`} - - - - - - - Firepower{" "} - - {firepower === null ? "(empty)" : `(${firepower})`} - - - - - - - Maneuverability{" "} - - {maneuverability === null - ? "(empty)" - : `(${maneuverability})`} - - - - - - - Survivability{" "} - - {survivability === null ? "(empty)" : `(${survivability})`} - - - - - - - - - - - - - - - - - setPostVote(null)} - > - {postVote === true && ( - - Vote casted - - It may take a few minutes for it to show up. - - - - - - - - - )} - - {postVote === false && ( - - Voting failed - - So sorry about this! If you're a nerd, feel free to check out the - console. - - - - - - - - - )} - - - ); -} diff --git a/packages/website/src/components/Tankopedia/MetaSection/components/Votes/index.tsx b/packages/website/src/components/Tankopedia/MetaSection/components/Votes/index.tsx deleted file mode 100644 index 5455a4327..000000000 --- a/packages/website/src/components/Tankopedia/MetaSection/components/Votes/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { formatCompact } from '@blitzkit/core'; -import { Flex, Heading, Skeleton, Text } from '@radix-ui/themes'; -import { memo } from 'react'; -import { useLocale } from '../../../../../hooks/useLocale'; -import { useTankVotes } from '../../../../../hooks/useTankVotes'; -import { App } from '../../../../../stores/app'; -import { Duel } from '../../../../../stores/duel'; -import { WargamingLoginButton } from '../../../../WargamingLoginButton'; -import { StarRow } from './components/StarRow'; -import { VoteCaster } from './components/VoteCaster'; - -export const Votes = memo( - () => { - const id = Duel.use((state) => state.protagonist.tank.id); - const votes = useTankVotes(id); - const wargaming = App.use((state) => state.logins.wargaming); - const { locale } = useLocale(); - - return ( - - - - {!votes && } - {votes && formatCompact(locale, votes.votes)}{' '} - {votes && (votes.votes > 1 ? 'votes' : 'vote')} - - - {!wargaming && ( - - Login to vote - - )} - {wargaming && } - - - - - - - - - - - - - - - - <> - {votes ? ( - wargaming ? ( - votes.last_updated ? ( - `You voted on ${new Date( - votes.last_updated, - ).toLocaleDateString()}` - ) : ( - `You haven't voted yet` - ) - ) : ( - 'Login to vote' - ) - ) : ( - - )} - - - - - - ); - }, - () => true, -); diff --git a/packages/website/src/components/Tankopedia/MetaSection/index.tsx b/packages/website/src/components/Tankopedia/MetaSection/index.tsx index 69f72f40d..ca25dbdfe 100644 --- a/packages/website/src/components/Tankopedia/MetaSection/index.tsx +++ b/packages/website/src/components/Tankopedia/MetaSection/index.tsx @@ -1,4 +1,9 @@ -import { TankPriceType, TankType, TIER_ROMAN_NUMERALS } from "@blitzkit/core"; +import { + alias, + TankPriceType, + TankType, + TIER_ROMAN_NUMERALS, +} from "@blitzkit/core"; import { Box, Flex } from "@radix-ui/themes"; import { Var } from "../../../core/radix/var"; import { useLocale } from "../../../hooks/useLocale"; @@ -35,7 +40,7 @@ export function MetaSection() { > { strings.common.nations[ @@ -130,7 +138,7 @@ export function MetaSection() { gold @@ -147,12 +155,15 @@ export function MetaSection() { {TankPriceType[protagonist.price!.type]} @@ -162,12 +173,15 @@ export function MetaSection() { {TankPriceType[protagonist.price!.type]} @@ -183,7 +197,7 @@ export function MetaSection() { xp diff --git a/packages/website/src/components/Tankopedia/TechTreeSection/components/Node.tsx b/packages/website/src/components/Tankopedia/TechTreeSection/components/Node.tsx index c5a66b044..5a710564e 100644 --- a/packages/website/src/components/Tankopedia/TechTreeSection/components/Node.tsx +++ b/packages/website/src/components/Tankopedia/TechTreeSection/components/Node.tsx @@ -1,4 +1,4 @@ -import { formatCompact, TIER_ROMAN_NUMERALS } from "@blitzkit/core"; +import { alias, formatCompact, TIER_ROMAN_NUMERALS } from "@blitzkit/core"; import { literals } from "@blitzkit/i18n/src/literals"; import { Box, Flex, Skeleton, Text } from "@radix-ui/themes"; import { awaitableAverageDefinitions } from "../../../../core/awaitables/averageDefinitions"; @@ -58,7 +58,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) { mx={highlight ? "2" : "0"} style={{ background: highlight - ? `url(${`/api/flags/fade/${tank.nation}.webp`})` + ? `url(${alias("api", `/flags/fade/${tank.nation}.webp`)})` : undefined, backgroundPosition: "center", backgroundSize: "cover", @@ -79,7 +79,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) { > {unwrap(tank.name!)} XP Silver XP Silver | TankVotes> = {}; - -export function useTankVotes(id: number) { - const [votes, setVotes] = useState(null); - const wargaming = App.use((state) => state.logins.wargaming); - - useEffect(() => { - if (!wargaming) return; - - if (id in cache) { - Promise.all([cache[id]]).then(([votes]) => setVotes(votes)); - } else { - const cacheable = fetch( - `/api/tank-voting/${id}/average${ - wargaming ? `?player=${wargaming.id}&token=${wargaming.token}` : '' - }`, - ) - .then( - (response) => response.json() as Promise>, - ) - .then(unwrapBlitzkitResponse); - cacheable.then(setVotes); - cache[id] = cacheable; - } - }, [id, wargaming]); - - return votes; -} diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/mixer/_index.tsx b/packages/website/src/pages/[...chunk_default]/[...locale]/mixer/_index.tsx index caabc2184..2346fc2d9 100644 --- a/packages/website/src/pages/[...chunk_default]/[...locale]/mixer/_index.tsx +++ b/packages/website/src/pages/[...chunk_default]/[...locale]/mixer/_index.tsx @@ -1,4 +1,5 @@ import { + alias, GunDefinition, TankDefinition, TIER_ROMAN_NUMERALS, @@ -110,7 +111,7 @@ function ModuleChooser({ height: "4em", objectFit: "contain", }} - src={`/api/tanks/${tank}/icons/big.webp`} + src={alias("api", `/tanks/${tank}/icons/big.webp`)} /> )} diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/index.astro b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/index.astro index 3f5c07555..67727b72b 100644 --- a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/index.astro +++ b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/index.astro @@ -1,6 +1,7 @@ --- import { AstroSeo } from "@astrolib/seo"; import { + alias, assertSecret, createDefaultProvisions, createDefaultSkills, @@ -51,7 +52,7 @@ const title = literals(strings.website.tools.tankopedia.tank_seo_title, { }); const url = import.meta.env.SITE + Astro.url.pathname; -const icon = `/api/tanks/${id}/icons/big.webp`; +const icon = alias("api", `/tanks/${id}/icons/big.webp`); const turret = tank.turrets.at(-1)!; const gun = turret.guns.at(-1)!; const shell = gun.shells[0];