Compare commits

...
8 Commits
Author SHA1 Message Date
tresabhi e18d6f58f9 somewhat through PrimaryArmorSceneComponent 2026-09-27 16:26:28 -05:00
tresabhi 488451d71f TankopediaLoadout 2026-09-27 13:49:02 -05:00
tresabhi 558a02b6ae compile tank search page 2026-09-27 13:22:34 -05:00
tresabhi d8f6a55ee4 Merge branch 'main' into data-driven 2026-09-27 13:11:01 -05:00
TrèsAbhi 763ecf22b0 New Crowdin updates (#266) 2026-09-24 16:25:00 -05:00
TrèsAbhi 2beb084b67 Merge pull request #267 from Hitjack007/fix/session-missing-average-crash 2026-09-24 14:07:50 -07:00
tresabhi f7ca622906 correct assertions 2026-09-24 16:07:12 -05:00
Mark Greene 88643d366d Fix: skip session delta entries with no tank definition or average
The /session/ page crashed when a tracked player's tank stats included
a tank with no matching tank definition or no average stats (WN8
baseline). The delta memo dereferenced `tank.id` on a possibly missing
definition and forced `average?.mu!` through as non-null, so a missing
average threw inside `compositeStats`/`calculateWN8` uncaught, breaking
the page on every load while the bad state persisted in localStorage.

Skip those entries instead of crashing: entries with no tank
definition or no average `mu` are dropped from the delta via
`flatMap`, so the rest of the session table still renders.
2026-09-23 08:21:16 +12:00
39 changed files with 492 additions and 426 deletions

No files matched your search

@@ -1,4 +1,4 @@
import { MapDefinitions } from "@blitzkit/core";
import { MapDefinitions } from "@blitzkit/protos";
import { Cache } from "./0_base";
import { ServerBlitzKitAPI3 } from "./3_models";
+2 -2
View File
@@ -1,5 +1,5 @@
import { emptyAllStats } from '..';
import { BlitzStats } from './compositeStats/constants';
import { emptyAllStats } from "../types";
import { BlitzStats } from "./compositeStats/constants";
export function sumAllStats(allStatsCollection: BlitzStats[]) {
return allStatsCollection.reduce((accumulated, current) => {
+21 -2
View File
@@ -1,5 +1,24 @@
import { emptyAllStats } from '../blitz';
import type { BlitzStats } from '../statistics';
import type { BlitzStats } from "../statistics";
export const emptyAllStats: BlitzStats = {
battles: 0,
capture_points: 0,
damage_dealt: 0,
damage_received: 0,
dropped_capture_points: 0,
frags: 0,
frags8p: 0,
hits: 0,
losses: 0,
max_frags: 0,
max_xp: 0,
shots: 0,
spotted: 0,
survived_battles: 0,
win_and_survived: 0,
wins: 0,
xp: 0,
};
export interface IndividualTankStats {
all: BlitzStats;
-1
View File
@@ -1,4 +1,3 @@
export * from "./literals";
export * from "./strings";
export * from "./unwrapper";
export * from "./wrapper";
+5 -1
View File
@@ -2,7 +2,11 @@ import locales from "@blitzkit/i18n/locales.json";
import { I18nString } from "@blitzkit/protos";
export function unwrapper(locale: string) {
return function (i18nString: I18nString) {
return function (i18nString?: I18nString) {
if (i18nString === undefined) {
throw new Error("i18nString is undefined");
}
if (locale in i18nString.locales) {
return i18nString.locales[locale];
}
-15
View File
@@ -1,15 +0,0 @@
import locales from "../locales.json";
import { type BlitzKitStrings, STRINGS } from "./strings";
export function wrapper(slicer: (strings: BlitzKitStrings) => string) {
const defaultString = slicer(STRINGS[locales.default] as BlitzKitStrings);
const object = { [locales.default]: defaultString };
for (const { locale } of locales.supported) {
const string = slicer(STRINGS[locale] as BlitzKitStrings);
if (string !== defaultString) object[locale] = string;
}
return object;
}
+10 -10
View File
@@ -13,7 +13,7 @@
"hp": "{value}од",
"x": "x{value}",
"percentage": "{value}%",
"deg_s": "{value}град/c",
"deg_s": "{value}°/c",
"plus": "+{value}"
},
"gun_types": {
@@ -37,8 +37,8 @@
"d_1": "Вчора"
},
"branches": {
"beta": "Beta",
"development": "Development",
"beta": "Бета",
"development": "Розробка",
"opentest": "Відкритий тест",
"preview": "Preview",
"production": "Production"
@@ -340,8 +340,8 @@
},
"mixer": {
"name": "Mixer",
"button": "Mix and match",
"description": "Mix tank modules",
"button": "Змішувати та поєднувати",
"description": "Змішані танкові модулі",
"seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz",
"screenshot": "Знімок екрану",
"randomize": "Рандомізувати"
@@ -501,11 +501,11 @@
"tech_tree": {
"title": "Звичайний",
"battle": "1 бій",
"battles": "{count} battles",
"battles": "{count} битви",
"route": "Route {index} of {count}"
},
"game_modes": {
"title": "Game mode abilities"
"title": "Здібності ігрового режиму"
},
"review": {
"title": "Review videos",
@@ -626,7 +626,7 @@
"camouflageShootingStill": "Нерухомо",
"camouflageShootingMoving": "У русі",
"camouflageCaughtOnFire": "Caught on fire",
"width": "Width",
"width": "Ширина",
"height": "Height",
"length": "Length",
"volume": "Volume",
@@ -740,7 +740,7 @@
}
},
"gallery": {
"name": "Gallery",
"name": "Галерея",
"button": "Browse all",
"description": "Blitz player avatars",
"seo_description": "Усі World of Tanks Blitz аватари гравців",
@@ -843,7 +843,7 @@
"breakdown": {
"name": "Breakdown",
"options": {
"width": "Width",
"width": "Ширина",
"height": "Height",
"showTotal": "Show total",
"listGap": "List gap",
@@ -1,4 +1,4 @@
syntax = "proto2";
syntax = "proto3";
package blitzkit;
@@ -10,8 +10,8 @@ message EquipmentDefinitions {
}
message Equipment {
required I18nString name = 1;
required I18nString description = 2;
I18nString name = 1;
I18nString description = 2;
}
message EquipmentPreset {
@@ -19,6 +19,5 @@ message EquipmentPreset {
}
message EquipmentSlot {
required uint32 left = 1;
required uint32 right = 2;
repeated uint32 options = 1;
}
@@ -17,9 +17,9 @@ import {
Vector2,
} from "three";
import { degToRad } from "three/src/math/MathUtils.js";
import { hasEquipment } from "../../../../core/blitzkit/hasEquipment";
import { jsxTree } from "../../../../core/blitzkit/jsxTree";
import { Duel } from "../../../../stores/duel";
import { DuelModule, getDuel } from "../../../../hooks/useDuel";
import { hasEquipment } from "../../../../hooks/useEquipment";
import { Tankopedia } from "../../../../stores/tankopedia";
import { TankopediaPersistent } from "../../../../stores/tankopediaPersistent";
import { transitionEvent } from "../../../Lighting";
@@ -82,9 +82,8 @@ export function PrimaryArmorSceneComponent({
useEffect(() => {
function handleShellChange() {
const tankopediaEphemeral = Tankopedia.state;
const shell =
tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
Tankopedia.state.customShell ?? getDuel("antagonist", DuelModule.Shell);
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(
@@ -117,15 +116,16 @@ export function PrimaryArmorSceneComponent({
material.wireframe = wireframe;
}
function handleProtagonistEquipmentChange(noInvalidate = false) {
const equipment = Duel.state.protagonist.equipmentMatrix;
const equipment = Tankopedia.state.protagonist.equipment;
const protagonist = getDuel("protagonist", DuelModule.Tank);
const hasEnhancedArmor = hasEquipment(
110,
Duel.state.protagonist.tank.equipment_preset,
protagonist.equipment_preset,
equipment,
);
const equalizer =
(Duel.state.equalize
? Duel.state.protagonist.tank.equalizer?.armor
(Tankopedia.state.equalize
? protagonist.equalizer?.armor
: undefined) ?? 1;
material.uniforms.thickness.value =
@@ -134,10 +134,11 @@ export function PrimaryArmorSceneComponent({
if (!noInvalidate) invalidate();
}
function handleAntagonistEquipmentChange(noInvalidate = false) {
const equipment = Duel.state.antagonist.equipmentMatrix;
const equipment = Tankopedia.state.antagonist.equipment;
const tankopediaEphemeral = Tankopedia.state;
const shell =
tankopediaEphemeral.customShell ?? Duel.state.antagonist.shell;
tankopediaEphemeral.customShell ??
getDuel("antagonist", DuelModule.Shell);
const penetration = shell.penetration!.near;
const hasCalibratedShells = hasEquipment(
103,
@@ -74,7 +74,7 @@ export function StaticArmorSceneComponent({
...props
}: StaticArmorSceneComponentProps) {
const camera = useThree((state) => state.camera);
const hasEnhancedArmor = useEquipment(110);
const hasEnhancedArmor = useEquipment("protagonist", 110);
const tank = Duel.use((state) => state.protagonist.tank);
const equalize = Duel.use((state) => state.equalize);
const equalizer = (equalize ? tank.equalizer : undefined) ?? defaultEqualizer;
@@ -1,7 +1,7 @@
import { isEqual } from "lodash-es";
import { useMemo } from "react";
import { api } from "../../api/dynamic";
import { useLocale } from "../../hooks/useLocale";
import { useStrings } from "../../hooks/useStrings";
import { TankFilters } from "../../stores/tankFilters";
import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import { TankSort } from "../../stores/tankopediaSort";
@@ -29,7 +29,8 @@ export function RecentlyViewedTanks() {
}),
[filters],
);
const { strings } = useLocale();
const strings = useStrings();
const by = TankSort.use((state) => state.by);
if (recentlyViewed.length === 0 || by !== "meta.none" || hasFilters) {
@@ -1,11 +1,13 @@
import { alias, TankType, type TankDefinition } from "@blitzkit/core";
import { alias } from "@blitzkit/core";
import { TankType, type TankDefinition } from "@blitzkit/protos";
import { uniq } from "lodash-es";
import { type ReactNode } from "react";
import { api } from "../../api/dynamic";
import { useLocale } from "../../hooks/useLocale";
import { useUnwrapper } from "../../hooks/useUnwrapper";
import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import { classIcons } from "../ClassIcon";
import { ClassIcon } from "../ClassIcon";
import { Flex } from "../Flex";
import { LinkI18nWrapper } from "../LinkI18nWrapper";
import { TankCardSkeleton } from "../TankCardSkeleton";
@@ -28,7 +30,8 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
return <TankCardSkeleton />;
}
const { unwrap, locale } = useLocale();
const unwrap = useUnwrapper();
const locale = useLocale();
const provideLink = !props.noLink && props.onTankSelect === undefined;
const name = unwrap(props.tank.name!);
@@ -41,8 +44,6 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
: "gray";
const lowContrast = props.tank.type !== TankType.TANK_TYPE_RESEARCHABLE;
const Icon = classIcons[props.tank.class];
const content = (
<Flex
className={styles.wrapper}
@@ -75,7 +76,7 @@ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
</Flex>
<Text className={styles.name} color={color} lowContrast={lowContrast}>
<Icon className={styles.icon} />
<ClassIcon class={props.tank.class} className={styles.icon} />
<span className={styles.text}>{name}</span>
</Text>
@@ -1,5 +1,5 @@
import { CaretDownIcon } from "@radix-ui/react-icons";
import { useLocale } from "../../../hooks/useLocale";
import { useStrings } from "../../../hooks/useStrings";
import type {
TankopediaSortBy,
TankopediaSortDirection,
@@ -17,7 +17,7 @@ interface ItemProps {
}
function Item({ by }: ItemProps) {
const { strings } = useLocale();
const strings = useStrings();
const _by = TankSort.use((state) => state.by);
return (
@@ -36,7 +36,7 @@ function Item({ by }: ItemProps) {
export function Sort() {
const direction = TankSort.use((state) => state.direction);
const { strings } = useLocale();
const strings = useStrings();
return (
<DropdownMenu.Root>
@@ -1,12 +1,12 @@
import type { TankDefinition } from "@blitzkit/core";
import type { TankDefinition } from "@blitzkit/protos";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import {
type KeyboardEventHandler,
useCallback,
useEffect,
useRef,
type KeyboardEventHandler,
useCallback,
useEffect,
useRef,
} from "react";
import { useLocale } from "../../hooks/useLocale";
import { useStrings } from "../../hooks/useStrings";
import { TankFilters } from "../../stores/tankFilters";
import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import { Flex } from "../Flex";
@@ -23,7 +23,7 @@ export function TankSearchBar({
skeleton,
onSelect,
}: SearchBarProps) {
const { strings } = useLocale();
const strings = useStrings();
const search = TankFilters.use((state) => state.search);
@@ -17,7 +17,7 @@ export function Crew({ stats }: StatsAcceptorProps) {
const tank = Duel.use((state) => state.protagonist.tank);
const { strings } = useLocale();
const provisions = Duel.use((state) => state.protagonist.provisions);
const hasImprovedVentilation = useEquipment(102);
const hasImprovedVentilation = useEquipment("protagonist", 102);
const provisionCrewBonus =
provisions.reduce(
(total, provision) =>
@@ -71,8 +71,8 @@ export function Characteristics({ skeleton }: MaybeSkeletonComponentProps) {
},
);
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124);
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
useEffect(() => {
const [pitch, yaw] = applyPitchYawLimits(
@@ -32,8 +32,8 @@ export function Consumables() {
const cooldownBooster = Duel.use(
(state) => state.protagonist.cooldownBooster,
);
const hasConsumableDeliverySystem = useEquipment(118);
const hasHighEndConsumables = useEquipment(101);
const hasConsumableDeliverySystem = useEquipment("protagonist", 118);
const hasHighEndConsumables = useEquipment("protagonist", 101);
const { strings } = useLocale();
return (
@@ -23,8 +23,8 @@ export function QuickInputs() {
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const initialTurretPitch =
tankModelDefinition.initial_turret_rotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124);
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
function displayPitch(pitch: number) {
return radToDeg(pitch) - initialTurretPitch;
@@ -68,9 +68,9 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
const antagonistShell = Duel.use((state) => state.antagonist.shell);
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
const antagonistTank = Duel.use((state) => state.antagonist.tank);
const hasCalibratedShells = useEquipment(103, true);
const hasCalibratedShells = useEquipment("protagonist", 103, true);
const [tab, setTab] = useState("search");
const hasEnhancedArmor = useEquipment(110);
const hasEnhancedArmor = useEquipment("protagonist", 110);
const antagonistUniqueGuns = uniqueGuns(antagonistTank.turrets);
const { strings, unwrap } = useLocale();
const revealed = Tankopedia.use((state) => state.revealed);
@@ -1,30 +1,38 @@
import type { Consumable } from "@blitzkit/core";
import { TrashIcon } from "@radix-ui/react-icons";
import type { Consumable, EquipmentSlot } from "@blitzkit/protos";
import { ClockIcon, ResetIcon, TrashIcon } from "@radix-ui/react-icons";
import { chunk } from "lodash-es";
import { api } from "../../api/dynamic";
import { equipmentColumns } from "../../config/equipment";
import { useConsumables } from "../../hooks/useConsumables";
import { useProtagonistGun } from "../../hooks/useProtagonistGun";
import { useProtagonistTank } from "../../hooks/useProtagonistTank";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipmentPreset } from "../../hooks/useEquipmentPreset";
import { useStrings } from "../../hooks/useStrings";
import { useTankCompatibility } from "../../hooks/useTankCompatibility";
import { useUnwrapper } from "../../hooks/useUnwrapper";
import { Tankopedia } from "../../stores/tankopedia";
import { hasUpgrades } from "../../tankopedia/hasUpgrades";
import { vehicleStatusKeys } from "../../tankopedia/tankState";
import type { ComputedCharacteristics } from "../../types/characteristics";
import { Button } from "../Button";
import { Heading } from "../Heading";
import { IconButton } from "../IconButton";
import { Text } from "../Text";
import { Tooltip } from "../Tooltip";
import styles from "./index.module.css";
const equipment = await api.equipment();
interface TankopediaLoadoutProps {
characteristics: ComputedCharacteristics;
}
export function TankopediaLoadout({ characteristics }: TankopediaLoadoutProps) {
const tank = useProtagonistTank();
const tank = useDuel("protagonist", DuelModule.Tank);
const showModules = hasUpgrades(tank);
return (
<>
{showModules && <Modules />}
{/* {showModules && <Modules />} */}
<Equipment />
<Consumables characteristics={characteristics} />
<State />
@@ -120,8 +128,8 @@ interface ConsumablesProps {
function Consumables({ characteristics }: ConsumablesProps) {
const strings = useStrings();
const consumables = useConsumables();
const tank = useProtagonistTank();
const gun = useProtagonistGun();
const tank = useDuel("protagonist", DuelModule.Tank);
const gun = useDuel("protagonist", DuelModule.Gun);
const isCompatible = useTankCompatibility(tank, gun);
return (
@@ -148,7 +156,7 @@ function Consumables({ characteristics }: ConsumablesProps) {
}
return (
<Consumable key={id} id={Number(id)} consumable={consumable} />
<ConsumableEntry key={id} id={Number(id)} consumable={consumable} />
);
})}
</div>
@@ -161,12 +169,12 @@ interface ConsumableProps {
consumable: Consumable;
}
function Consumable({ id, consumable }: ConsumableProps) {
function ConsumableEntry({ id, consumable }: ConsumableProps) {
const isSelected = Tankopedia.use((state) =>
state.protagonist.consumables.includes(id),
);
const tank = useProtagonistTank();
const unwrap = useUnwrap();
const tank = useDuel("protagonist", DuelModule.Tank);
const unwrap = useUnwrapper();
return (
<div className={styles["consumable-wrapper"]}>
@@ -207,15 +215,15 @@ function Consumable({ id, consumable }: ConsumableProps) {
</Button>
</Tooltip>
<Price price={price} />
{/* <Price price={price} /> */}
</div>
);
}
function Equipment() {
const strings = useStrings();
const tank = useProtagonistTank();
const equipment = useEquipment(tank.tank!);
const tank = useDuel("protagonist", DuelModule.Tank);
const equipment = useEquipmentPreset(tank.equipment_preset);
return (
<div className={styles.section}>
@@ -237,7 +245,7 @@ function Equipment() {
</div>
<div className={styles.equipment}>
{chunk(equipment.preset.slots, equipmentColumns).map((chunk, index) => (
{chunk(equipment.slots, equipmentColumns).map((chunk, index) => (
<EquipmentRow key={index} chunk={chunk} rowIndex={index} />
))}
</div>
@@ -246,7 +254,7 @@ function Equipment() {
}
interface EquipmentRowProps {
chunk: BlitzStaticEquipmentPresetComponent_EquipmentSlot[];
chunk: EquipmentSlot[];
rowIndex: number;
}
@@ -254,7 +262,7 @@ function EquipmentRow({ chunk, rowIndex }: EquipmentRowProps) {
return (
<div className={styles.row}>
{chunk.map((slot, index) => (
<EquipmentSlot
<EquipmentSlotEntry
key={index}
slot={slot}
rowIndex={rowIndex}
@@ -266,21 +274,25 @@ function EquipmentRow({ chunk, rowIndex }: EquipmentRowProps) {
}
interface EquipmentSlotProps {
slot: BlitzStaticEquipmentPresetComponent_EquipmentSlot;
slot: EquipmentSlot;
rowIndex: number;
columnIndex: number;
}
function EquipmentSlot({ slot, rowIndex, columnIndex }: EquipmentSlotProps) {
function EquipmentSlotEntry({
slot,
rowIndex,
columnIndex,
}: EquipmentSlotProps) {
const equipmentIndex = rowIndex * equipmentColumns + columnIndex;
const tank = useProtagonistTank();
const equipment = useEquipment(tank.tank!);
const { price } = equipment.price.unlock_slot_prices[equipmentIndex];
const tank = useDuel("protagonist", DuelModule.Tank);
const equipment = useEquipmentPreset(tank.equipment_preset);
// const { price } = equipment.price.unlock_slot_prices[equipmentIndex];
return (
<div className={styles.slots}>
<div className={styles.options}>
{slot.options_catalog_i_ds.map((id, index) => (
{slot.options.map((id, index) => (
<EquipmentOption
key={id}
optionIndex={index}
@@ -290,7 +302,7 @@ function EquipmentSlot({ slot, rowIndex, columnIndex }: EquipmentSlotProps) {
))}
</div>
<Price price={price!} />
{/* <Price price={price!} /> */}
</div>
);
}
@@ -307,11 +319,14 @@ function EquipmentOption({
columnIndex,
}: EquipmentOptionProps) {
const equipmentIndex = rowIndex * equipmentColumns + columnIndex;
const tank = useProtagonistTank();
const equipment = useEquipment(tank.tank!);
const slot = equipment.preset.slots[equipmentIndex];
const id = slot.options_catalog_i_ds[optionIndex];
const gameStrings = useGameStrings("EquipmentEntity");
const tank = useDuel("protagonist", DuelModule.Tank);
const equipmentPreset = useEquipmentPreset(tank.equipment_preset);
const unwrap = useUnwrapper();
const slot = equipmentPreset.slots[equipmentIndex];
const id = slot.options[optionIndex];
const specificEquipment = equipment.equipments[id];
const isSelected = Tankopedia.use(
(state) =>
@@ -320,13 +335,13 @@ function EquipmentOption({
);
return (
<Tooltip tooltip={gameStrings[equipment.equipments[id].equipment_name]}>
<Tooltip tooltip={unwrap(specificEquipment.name)}>
<Button
color={isSelected ? undefined : "gray"}
variant={isSelected ? "surface" : "soft"}
data-selected={isSelected}
radius="1"
parentArray
listChild
className={styles.slot}
onClick={() => {
Tankopedia.mutate((draft) => {
@@ -344,292 +359,292 @@ function EquipmentOption({
);
}
function Modules() {
const strings = useStrings();
const tank = useProtagonistTank();
const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
const alternates = Tankopedia.use((state) => state.protagonist.alternates);
const isStock = useMemo(() => {
return tank.tank!.upgrade_lines.every((line) => {
if (isAlternativeLine(line.name)) return true;
return upgrades[line.name] === 0;
});
}, [upgrades]);
const isUpgraded = useMemo(() => {
return tank.tank!.upgrade_lines.every((line) => {
if (isAlternativeLine(line.name)) return true;
// function Modules() {
// const strings = useStrings();
// const tank = useDuel("protagonist", DuelModule.Tank);
// const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
// const alternates = Tankopedia.use((state) => state.protagonist.alternates);
// const isStock = useMemo(() => {
// return tank.tank!.upgrade_lines.every((line) => {
// if (isAlternativeLine(line.name)) return true;
// return upgrades[line.name] === 0;
// });
// }, [upgrades]);
// const isUpgraded = useMemo(() => {
// return tank.tank!.upgrade_lines.every((line) => {
// if (isAlternativeLine(line.name)) return true;
return (
upgrades[line.name] === line.stages.length - 1 ||
(line.name in alternates && alternates[line.name])
);
});
}, [upgrades]);
// return (
// upgrades[line.name] === line.stages.length - 1 ||
// (line.name in alternates && alternates[line.name])
// );
// });
// }, [upgrades]);
return (
<div className={styles.section}>
<div className={styles.header}>
<Heading size="4">{strings.tanks.loadout.modules}</Heading>
// return (
// <div className={styles.section}>
// <div className={styles.header}>
// <Heading size="4">{strings.tanks.loadout.modules}</Heading>
<div className={styles["upgrade-buttons"]}>
<IconButton
variant={isStock ? "solid" : "soft"}
size="minor"
array
onClick={() => {
Tankopedia.mutate((draft) => {
for (const line of tank.tank!.upgrade_lines) {
if (isAlternativeLine(line.name)) continue;
// <div className={styles["upgrade-buttons"]}>
// <IconButton
// variant={isStock ? "solid" : "soft"}
// size="minor"
// array
// onClick={() => {
// Tankopedia.mutate((draft) => {
// for (const line of tank.tank!.upgrade_lines) {
// if (isAlternativeLine(line.name)) continue;
draft.protagonist.upgrades[line.name] = 0;
draft.protagonist.alternates[line.name] = false;
}
});
}}
color="gray"
>
<DoubleArrowLeftIcon />
</IconButton>
// draft.protagonist.upgrades[line.name] = 0;
// draft.protagonist.alternates[line.name] = false;
// }
// });
// }}
// color="gray"
// >
// <DoubleArrowLeftIcon />
// </IconButton>
<IconButton
variant={isUpgraded ? "solid" : "soft"}
size="minor"
array
onClick={() => {
Tankopedia.mutate((draft) => {
for (const line of tank.tank!.upgrade_lines) {
draft.protagonist.upgrades[line.name] =
line.stages.length - 1;
// <IconButton
// variant={isUpgraded ? "solid" : "soft"}
// size="minor"
// array
// onClick={() => {
// Tankopedia.mutate((draft) => {
// for (const line of tank.tank!.upgrade_lines) {
// draft.protagonist.upgrades[line.name] =
// line.stages.length - 1;
if (isAlternativeLine(line.name)) {
const original = originalLineName(line.name)!;
draft.protagonist.alternates[original] = true;
}
}
});
}}
color="gray"
>
<DoubleArrowRightIcon />
</IconButton>
</div>
</div>
// if (isAlternativeLine(line.name)) {
// const original = originalLineName(line.name)!;
// draft.protagonist.alternates[original] = true;
// }
// }
// });
// }}
// color="gray"
// >
// <DoubleArrowRightIcon />
// </IconButton>
// </div>
// </div>
<div className={styles.lines}>
{/* TODO: hide lines with 1 module except for alts */}
{tank.tank!.upgrade_lines.map((line) => (
<Line
key={line.name}
name={line.name}
lines={tank.tank!.upgrade_lines}
/>
))}
</div>
</div>
);
}
// <div className={styles.lines}>
// {/* TODO: hide lines with 1 module except for alts */}
// {tank.tank!.upgrade_lines.map((line) => (
// <Line
// key={line.name}
// name={line.name}
// lines={tank.tank!.upgrade_lines}
// />
// ))}
// </div>
// </div>
// );
// }
interface LineProps {
name: string;
lines: UpgradeLine[];
}
// interface LineProps {
// name: string;
// lines: UpgradeLine[];
// }
function Line({ name, lines }: LineProps) {
const combinedLines = useMemo(() => {
const names: string[] = [name];
// function Line({ name, lines }: LineProps) {
// const combinedLines = useMemo(() => {
// const names: string[] = [name];
for (const originalLine in alternativeLines) {
const alternativeLine = alternativeLines[originalLine];
// for (const originalLine in alternativeLines) {
// const alternativeLine = alternativeLines[originalLine];
if (
originalLine === name &&
lines.some((line) => line.name === alternativeLine)
) {
names.push(alternativeLine);
}
}
// if (
// originalLine === name &&
// lines.some((line) => line.name === alternativeLine)
// ) {
// names.push(alternativeLine);
// }
// }
return names;
}, [name]);
// return names;
// }, [name]);
return (
<div className={styles.line}>
{!isAlternativeLine(name) &&
combinedLines.map((name) => (
<div className={styles["chunks-wrapper"]}>
<LineInner key={name} name={name} lines={lines} />
</div>
))}
</div>
);
}
// return (
// <div className={styles.line}>
// {!isAlternativeLine(name) &&
// combinedLines.map((name) => (
// <div className={styles["chunks-wrapper"]}>
// <LineInner key={name} name={name} lines={lines} />
// </div>
// ))}
// </div>
// );
// }
function LineInner({ name, lines }: LineProps) {
const line = lines.find((line) => line.name === name)!;
// function LineInner({ name, lines }: LineProps) {
// const line = lines.find((line) => line.name === name)!;
return chunk(line.stages, maxModulesPerRow).map((chunk, wrapIndex) => (
<div className={styles["line-wrap"]}>
{wrapIndex > 0 && (
<Text size="minor" className={styles["wrap-marker"]} lowContrast>
<CornerBottomLeftIcon />
</Text>
)}
// return chunk(line.stages, maxModulesPerRow).map((chunk, wrapIndex) => (
// <div className={styles["line-wrap"]}>
// {wrapIndex > 0 && (
// <Text size="minor" className={styles["wrap-marker"]} lowContrast>
// <CornerBottomLeftIcon />
// </Text>
// )}
{chunk.map((stage, elementIndex) => (
<LineElement
key={elementIndex}
index={wrapIndex * maxModulesPerRow + elementIndex}
lineName={name}
stage={stage}
/>
))}
</div>
));
}
// {chunk.map((stage, elementIndex) => (
// <LineElement
// key={elementIndex}
// index={wrapIndex * maxModulesPerRow + elementIndex}
// lineName={name}
// stage={stage}
// />
// ))}
// </div>
// ));
// }
interface LineElementProps {
index: number;
lineName: string;
stage: StageParameters;
}
// interface LineElementProps {
// index: number;
// lineName: string;
// stage: StageParameters;
// }
function LineElement({ index, lineName, stage }: LineElementProps) {
const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
const alternates = Tankopedia.use((state) => state.protagonist.alternates);
const tank = useProtagonistTank();
const strings = useStrings();
const gameStrings = useGameStrings("TankEntity");
const upgradePreset = useUpgradePreset(tank.tank!.tank_upgrade_preset);
// function LineElement({ index, lineName, stage }: LineElementProps) {
// const upgrades = Tankopedia.use((state) => state.protagonist.upgrades);
// const alternates = Tankopedia.use((state) => state.protagonist.alternates);
// const tank = useDuel("protagonist", DuelModule.Tank);
// const strings = useStrings();
// const gameStrings = useGameStrings("TankEntity");
// const upgradePreset = useUpgradePreset(tank.tank!.tank_upgrade_preset);
let price: StandardPrice | undefined;
// let price: StandardPrice | undefined;
switch (stage.grade) {
case Grade.GRADE_COMMON:
price = upgradePreset.common_grade_price;
break;
// switch (stage.grade) {
// case Grade.GRADE_COMMON:
// price = upgradePreset.common_grade_price;
// break;
case Grade.GRADE_RARE:
price = upgradePreset.rare_grade_price;
break;
// case Grade.GRADE_RARE:
// price = upgradePreset.rare_grade_price;
// break;
case Grade.GRADE_EPIC:
price = upgradePreset.unique_grade_price;
break;
// case Grade.GRADE_EPIC:
// price = upgradePreset.unique_grade_price;
// break;
case Grade.GRADE_LEGENDARY:
price = upgradePreset.legendary_grade_price;
break;
}
// case Grade.GRADE_LEGENDARY:
// price = upgradePreset.legendary_grade_price;
// break;
// }
for (const override of upgradePreset.prices_overrides) {
if (override.stage_tech_name !== stage.tech_name) continue;
price = override.price;
}
// for (const override of upgradePreset.prices_overrides) {
// if (override.stage_tech_name !== stage.tech_name) continue;
// price = override.price;
// }
price ??= StandardPrice.create();
// price ??= StandardPrice.create();
let isSelected: boolean;
// let isSelected: boolean;
if (isAlternativeLine(lineName)) {
const originalLine = originalLineName(lineName);
isSelected = alternates[originalLine!];
} else {
if (alternates[lineName]) {
isSelected = false;
} else {
isSelected = upgrades[lineName] === index;
}
}
// if (isAlternativeLine(lineName)) {
// const originalLine = originalLineName(lineName);
// isSelected = alternates[originalLine!];
// } else {
// if (alternates[lineName]) {
// isSelected = false;
// } else {
// isSelected = upgrades[lineName] === index;
// }
// }
return (
<div className={styles.wrapper}>
<Tooltip tooltip={gameStrings[stage.display_name]}>
<Button
className={styles.stage}
color={isSelected ? undefined : "gray"}
variant={isSelected ? "surface" : "soft"}
radius="1"
onClick={() => {
// TODO: make minimum selectable upgrade the last free module for tanks like the destiny
// return (
// <div className={styles.wrapper}>
// <Tooltip tooltip={gameStrings[stage.display_name]}>
// <Button
// className={styles.stage}
// color={isSelected ? undefined : "gray"}
// variant={isSelected ? "surface" : "soft"}
// radius="1"
// onClick={() => {
// // TODO: make minimum selectable upgrade the last free module for tanks like the destiny
Tankopedia.mutate((draft) => {
const tankData = tank.tank!;
// Tankopedia.mutate((draft) => {
// const tankData = tank.tank!;
if (isAlternativeLine(lineName)) {
const originalLine = originalLineName(lineName)!;
// if (isAlternativeLine(lineName)) {
// const originalLine = originalLineName(lineName)!;
draft.protagonist.alternates[originalLine] = true;
draft.protagonist.upgrades[lineName] = index;
} else {
draft.protagonist.alternates[lineName] = false;
draft.protagonist.upgrades[lineName] = index;
}
// draft.protagonist.alternates[originalLine] = true;
// draft.protagonist.upgrades[lineName] = index;
// } else {
// draft.protagonist.alternates[lineName] = false;
// draft.protagonist.upgrades[lineName] = index;
// }
for (const required of stage.required_upgrades) {
for (const line of tankData.upgrade_lines) {
let i = 0;
// for (const required of stage.required_upgrades) {
// for (const line of tankData.upgrade_lines) {
// let i = 0;
for (const candidateStage of line.stages) {
if (candidateStage.tech_name === required) {
draft.protagonist.upgrades[line.name] = Math.max(
draft.protagonist.upgrades[line.name],
i,
);
}
// for (const candidateStage of line.stages) {
// if (candidateStage.tech_name === required) {
// draft.protagonist.upgrades[line.name] = Math.max(
// draft.protagonist.upgrades[line.name],
// i,
// );
// }
i++;
}
}
}
// i++;
// }
// }
// }
for (const line of tankData.upgrade_lines) {
if (draft.protagonist.alternates[line.name]) {
continue;
}
// for (const line of tankData.upgrade_lines) {
// if (draft.protagonist.alternates[line.name]) {
// continue;
// }
let i = draft.protagonist.upgrades[line.name];
// let i = draft.protagonist.upgrades[line.name];
while (i > 0) {
const currentStage = line.stages[i];
// while (i > 0) {
// const currentStage = line.stages[i];
const valid = currentStage.required_upgrades.every(
(required) => {
for (const otherLine of tankData.upgrade_lines) {
const idx = draft.protagonist.upgrades[otherLine.name];
// const valid = currentStage.required_upgrades.every(
// (required) => {
// for (const otherLine of tankData.upgrade_lines) {
// const idx = draft.protagonist.upgrades[otherLine.name];
if (otherLine.stages[idx]?.tech_name === required) {
return true;
}
}
// if (otherLine.stages[idx]?.tech_name === required) {
// return true;
// }
// }
return false;
},
);
// return false;
// },
// );
if (valid) break;
// if (valid) break;
i--;
}
// i--;
// }
draft.protagonist.upgrades[line.name] = i;
}
});
}}
>
<img
className={styles.icon}
src={`/media/modules/${stage.stage_type}.webp`}
/>
// draft.protagonist.upgrades[line.name] = i;
// }
// });
// }}
// >
// <img
// className={styles.icon}
// src={`/media/modules/${stage.stage_type}.webp`}
// />
<Text weight="light" lowContrast size="minor" className={styles.tier}>
{isAlternativeLine(lineName)
? strings.tanks.loadout.alternative
: romanize(stage.number)}
</Text>
</Button>
</Tooltip>
// <Text weight="light" lowContrast size="minor" className={styles.tier}>
// {isAlternativeLine(lineName)
// ? strings.tanks.loadout.alternative
// : romanize(stage.number)}
// </Text>
// </Button>
// </Tooltip>
{<Price className={styles.price} price={price} />}
</div>
);
}
// {<Price className={styles.price} price={price} />}
// </div>
// );
// }
@@ -8,11 +8,9 @@ import {
import { applyPitchYawLimits } from "../../core/blitz/applyPitchYawLimits";
import { modelTransformEvent } from "../../core/blitzkit/modelTransform";
import { Pose, poseEvent } from "../../core/blitzkit/pose";
import { DuelModule, useDuel } from "../../hooks/useDuel";
import { useEquipment } from "../../hooks/useEquipment";
import { useModel } from "../../hooks/useModel";
import { useProtagonistGun } from "../../hooks/useProtagonistGun";
import { useProtagonistTank } from "../../hooks/useProtagonistTank";
import { useProtagonistTurret } from "../../hooks/useProtagonistTurret";
import { Tankopedia } from "../../stores/tankopedia";
import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import { TankopediaDisplay } from "../../stores/tankopediaPersistent/constants";
@@ -41,12 +39,12 @@ export const TankopediaSandbox = forwardRef<
TankSandboxProps
>(({ thicknessRange }, ref) => {
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasDownImprovedVerticalStabilizer = useEquipment(124);
const hasImprovedVerticalStabilizer = useEquipment("protagonist", 122);
const hasDownImprovedVerticalStabilizer = useEquipment("protagonist", 124);
const tank = useProtagonistTank();
const gun = useProtagonistGun();
const turret = useProtagonistTurret();
const tank = useDuel("protagonist", DuelModule.Tank);
const gun = useDuel("protagonist", DuelModule.Gun);
const turret = useDuel("protagonist", DuelModule.Turret);
const model = Tankopedia.use((state) => state.protagonist.model);
const turretModelDefinition = model.turrets[turret.id];
+1
View File
@@ -0,0 +1 @@
export const equipmentColumns = 3;
@@ -1,7 +1,7 @@
import { isTankCompatible } from "@blitzkit/core";
import type { TankDefinition } from "@blitzkit/protos";
import { times } from "lodash-es";
import { api } from "../../api/dynamic";
import { isTankCompatible } from "../../hooks/useTankCompatibility";
import type { TankFilters } from "../../stores/tankFilters";
const SHELLS = times(3, (index) => index);
@@ -1,11 +1,12 @@
import { getTankStats, idToRegion, TankDefinition } from "@blitzkit/core";
import { getTankStats, idToRegion } from "@blitzkit/core";
import type { TankDefinition } from "@blitzkit/protos";
import { TankFilters } from "../../stores/tankFilters";
import { filterTank } from "./filterTank";
export async function filterTanks(
filters: TankFilters,
tanks: TankDefinition[],
player?: number
player?: number,
) {
const filtered: TankDefinition[] = [];
let owned: number[] | undefined;
@@ -1,23 +0,0 @@
import { api } from "../../api/dynamic";
import type { EquipmentMatrix } from "../../stores/duel";
const equipmentDefinitions = await api.equipment();
export function hasEquipment(
id: number,
equipmentPreset: string,
equipmentMatrix: EquipmentMatrix,
) {
const preset = equipmentDefinitions.presets[equipmentPreset];
return preset.slots.some((slot, index) => {
const row = Math.floor(index / 3);
const column = index % 3;
const choice = equipmentMatrix[row][column];
if (choice === 0) return false;
const equipped = slot[choice === -1 ? "left" : "right"];
return equipped === id;
});
}
+52
View File
@@ -72,3 +72,55 @@ export function useDuel(side: DuelSide, module: DuelModule) {
const shellId = Tankopedia.use((state) => state[side].shell);
return gun.shells.find((shell) => shell.id === shellId)!;
}
export function getDuel(
side: DuelSide,
module: DuelModule.Tank,
): TankDefinition;
export function getDuel(
side: DuelSide,
module: DuelModule.Track,
): TrackDefinition;
export function getDuel(
side: DuelSide,
module: DuelModule.Engine,
): EngineDefinition;
export function getDuel(
side: DuelSide,
module: DuelModule.Turret,
): TurretDefinition;
export function getDuel(side: DuelSide, module: DuelModule.Gun): GunDefinition;
export function getDuel(
side: DuelSide,
module: DuelModule.Shell,
): ShellDefinition;
export function getDuel(side: DuelSide, module: DuelModule) {
const tank = tanks.tanks[Tankopedia.state[side].tank];
if (module === DuelModule.Tank) return tank;
if (module === DuelModule.Track) {
return tank.tracks.find(
(track) => track.id === Tankopedia.state[side].track,
)!;
}
if (module === DuelModule.Engine) {
return tank.engines.find(
(engine) => engine.id === Tankopedia.state[side].engine,
)!;
}
const turret = tank.turrets.find(
(turret) => turret.id === Tankopedia.state[side].turret,
)!;
if (module === DuelModule.Turret) return turret;
const gun = turret.guns.find((gun) => gun.id === Tankopedia.state[side].gun)!;
if (module === DuelModule.Gun) return gun;
return gun.shells.find((shell) => shell.id === Tankopedia.state[side].shell)!;
}
+23 -18
View File
@@ -7,27 +7,32 @@ const tanks = await api.tanks();
export type DuelSide = "protagonist" | "antagonist";
export function useEquipment(id: number, side: DuelSide) {
export function useEquipment(side: DuelSide, id: number) {
const member = Tankopedia.use((state) => state[side]);
const appliedEquipment = Tankopedia.use((state) => state[side].equipment);
const tank = tanks.tanks[member.tank];
const preset = equipment.presets[tank.equipment_preset];
const equipmentMatrix = Tankopedia.use(
(state) => state[side].equipment_matrix,
const value = useMemo(
() => hasEquipment(id, tank.equipment_preset, appliedEquipment),
[appliedEquipment, member.tank],
);
const value = useMemo(() => {
return preset.slots.some((slot, index) => {
const row = Math.floor(index / 3);
const column = index % 3;
const choice = equipmentMatrix[row][column];
if (choice === 0) return false;
const equipped = slot[choice === -1 ? "left" : "right"];
return equipped === id;
});
}, [equipmentMatrix, member.tank]);
return value;
}
export function hasEquipment(
id: number,
_preset: string,
applied: Record<number, number>,
) {
const preset = equipment.presets[_preset];
return preset.slots.some((slot, index) => {
if (!(index in applied)) return false;
const choice = applied[index];
return slot.options[choice] === id;
});
}
@@ -1,7 +1,7 @@
import { api } from "../api/dynamic";
const equipmentDefinitions = await api.equipment();
const equipment = await api.equipment();
export function useEquipmentPreset(preset: string) {
return equipmentDefinitions.presets[preset];
return equipment.presets[preset];
}
@@ -1,4 +1,4 @@
import { CamouflageDefinitions } from "@blitzkit/core";
import { CamouflageDefinitions } from "@blitzkit/protos";
import { api } from "../../../api/dynamic";
export { getStaticPaths } from "../_index";
@@ -1,4 +1,4 @@
import { ConsumableDefinitions } from "@blitzkit/core";
import { ConsumableDefinitions } from "@blitzkit/protos";
import { api } from "../../../api/dynamic";
export { getStaticPaths } from "../_index";
@@ -1,4 +1,4 @@
import { GameDefinitions } from "@blitzkit/core";
import { GameDefinitions } from "@blitzkit/protos";
import { api } from "../../../api/dynamic";
export { getStaticPaths } from "../_index";
@@ -1,4 +1,4 @@
import { ModelDefinitions } from "@blitzkit/core";
import { ModelDefinitions } from "@blitzkit/protos";
import { api } from "../../../api/dynamic";
export { getStaticPaths } from "../_index";
@@ -1,4 +1,4 @@
import { ProvisionDefinitions } from "@blitzkit/core";
import { ProvisionDefinitions } from "@blitzkit/protos";
import { api } from "../../../api/dynamic";
export { getStaticPaths } from "../_index";
@@ -1,4 +1,4 @@
import { TankDefinitions } from "@blitzkit/core";
import { TankDefinitions } from "@blitzkit/protos";
import { api } from "../../../api/dynamic";
export { getStaticPaths } from "../_index";
@@ -90,15 +90,21 @@ function Content() {
session.tracking && tankStatsB
? deltaTankStats(session.player.stats, tankStatsB)
.sort((a, b) => b.last_battle_time - a.last_battle_time)
.map((entry) => {
.flatMap((entry) => {
if (!(entry.tank_id in tankDefinitions.tanks)) return [];
const tank = tankDefinitions.tanks[entry.tank_id];
if (!(tank.id in averageDefinitions.averages)) return [];
const average = averageDefinitions.averages[tank.id];
const composite = compositeStats(
{ ...entry.all, battle_life_time: entry.battle_life_time },
average?.mu!,
average.mu!,
);
return { tank, composite };
return [{ tank, composite }];
})
: undefined,
[session.tracking && session.player, tankStatsB],
@@ -20,7 +20,7 @@ export const Page = withErrorWrapper(
Tankopedia.useInitialization(protagonistTank);
const protagonist = Tankopedia.use((state) => state.protagonist);
// const protagonistEquipment = useEquipment(protagonistTank.tank!);
// const protagonistEquipment = useEquipment("protagonist", protagonistTank.tank!);
const characteristics = useMemo(
() => computeCharacteristics(),
+10 -1
View File
@@ -1,5 +1,9 @@
import { createDefaultSkills } from "@blitzkit/core";
import type { ShellDefinition, TankDefinition } from "@blitzkit/protos";
import type {
ModelDefinition,
ShellDefinition,
TankDefinition,
} from "@blitzkit/protos";
import type { Vector3 } from "three";
import { Varuna } from "varuna";
import { api } from "../api/dynamic";
@@ -81,6 +85,9 @@ interface Tankopedia {
protagonist: TankState;
antagonist: TankState;
model: ModelDefinition;
equalize: boolean;
shot?: Shot;
skills: Record<string, number>;
@@ -118,6 +125,8 @@ export const Tankopedia = new Varuna<Tankopedia, TankDefinition>((tank) => ({
antagonist: createTankState(tank),
model: models.models[tank.id],
equalize: false,
relativeAgainst: TankopediaRelativeAgainst.Class,
editStatic: false,
skills: createDefaultSkills(skills),
@@ -13,12 +13,6 @@ const models = await api.models();
const provisions = await api.provisions();
const scripts = await api.scripts();
export const genericDefaultEquipmentMatrix: EquipmentMatrix = [
[-1, 1, -1],
[0, 0, 0],
[0, 0, 0],
];
export function createTankState(tank: TankDefinition) {
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
@@ -35,7 +29,7 @@ export function createTankState(tank: TankDefinition) {
assault_distance: (gun.assault_ranges?.ranges[0].distance ?? 0) / 2,
speed: 0,
equipment_matrix: genericDefaultEquipmentMatrix,
equipment: {},
status: createTankStatus(),
model: models.models[turret.id],
+2 -3
View File
@@ -1,5 +1,4 @@
import type { ModelDefinition } from "@blitzkit/core";
import type { EquipmentMatrix } from "./createTankState";
import type { ModelDefinition } from "@blitzkit/protos";
export interface TankState {
tank: number;
@@ -13,7 +12,7 @@ export interface TankState {
assault_distance: number;
speed: number;
equipment_matrix: EquipmentMatrix;
equipment: Record<number, number>;
status: Record<VehicleStatusKey, boolean>;
model: ModelDefinition;