diff --git a/package.json b/package.json index b96c23f4..c3dfe80e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "showdex", - "version": "1.3.0", + "name": "olrics-showdex", + "version": "1.0.2", "description": "Pokémon Showdown extension that harnesses the power of parabolic calculus to strategically extract your opponents' Elo.", "author": "Keith Choison ", "license": "AGPL-3.0", diff --git a/src/pages/Calcdex/Calcdex.module.scss b/src/pages/Calcdex/Calcdex.module.scss index bdb46fd7..ef43c834 100644 --- a/src/pages/Calcdex/Calcdex.module.scss +++ b/src/pages/Calcdex/Calcdex.module.scss @@ -111,6 +111,210 @@ @include spacing.mt(12px); } +.validationPanel { + @include spacing.mt(12px); + @include spacing.p($a: 10px); + border-radius: 10px; + box-shadow: 0 6px 18px color.alpha(colors.$black, 0.16); + + [data-showdex-scheme='light'] & { + background-color: color.alpha(colors.$white, 0.7); + } + + [data-showdex-scheme='dark'] & { + background-color: color.alpha(colors.$gray-darkest, 0.68); + box-shadow: 0 6px 18px color.alpha(colors.$black, 0.4); + } +} + +.validationHeader { + @include flex.row($align: center, $justify: space-between); + @include spacing.mb(8px); +} + +.validationTitle { + @include font.primary(600); + font-size: 12px; + letter-spacing: 0.02em; + + [data-showdex-scheme='dark'] & { + color: colors.$white; + } +} + +.validationSummary { + @include font.primary(600); + font-size: 11px; + padding: 2px 6px; + border-radius: 999px; + + [data-showdex-scheme='light'] & { + background-color: color.alpha(colors.$gray-light, 0.4); + color: colors.$gray-darkest; + } + + [data-showdex-scheme='dark'] & { + background-color: color.alpha(colors.$gray-dark, 0.6); + color: colors.$white; + } + + &.validationHasFails { + [data-showdex-scheme='light'] & { + background-color: color.alpha(colors.$red, 0.12); + color: colors.$red; + } + + [data-showdex-scheme='dark'] & { + background-color: color.alpha(colors.$red, 0.2); + color: color.alpha(colors.$red, 0.95); + } + } +} + +.validationBaseline { + @include flex.row($align: center); + gap: 4px; + @include spacing.mb(8px); +} + +.validationBaselineItem { + width: 18px; + height: 18px; + @include flex.row-center; + border-radius: 3px; + font-size: 9px; + + [data-showdex-scheme='light'] & { + background-color: color.alpha(colors.$white, 0.8); + } + + [data-showdex-scheme='dark'] & { + background-color: color.alpha(colors.$gray-darker, 0.8); + } +} + +.validationIcon { + font-size: 10px; +} + +.validationTypeWindow { + & + & { + @include spacing.mt(8px); + } +} + +.validationTypeWindowLabel { + @include font.primary(500); + font-size: 10px; + letter-spacing: 0.03em; + text-transform: uppercase; + @include spacing.mb(4px); + + [data-showdex-scheme='light'] & { + color: color.alpha(colors.$gray-darkest, 0.7); + } + + [data-showdex-scheme='dark'] & { + color: color.alpha(colors.$white, 0.6); + } +} + +.validationTypeGrid { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.validationTypeIcon { + position: relative; + width: 36px; + height: 36px; + @include flex.row-center; + border-radius: 6px; + overflow: hidden; + + [data-showdex-scheme='light'] & { + background-color: color.alpha(colors.$white, 0.7); + box-shadow: inset 0 0 0 1px color.alpha(colors.$gray-light, 0.4); + } + + [data-showdex-scheme='dark'] & { + background-color: color.alpha(colors.$gray-darker, 0.6); + box-shadow: inset 0 0 0 1px color.alpha(colors.$black, 0.3); + } + + // highlight the type icon + :global(.container) { + width: 100%; + height: 100%; + @include flex.row-center; + opacity: 0.7; + } +} + +.validationTypeCount { + position: absolute; + bottom: -2px; + right: 0; + @include font.primary(600); + font-size: 9px; + padding: 0 2px; + border-radius: 2px 0 4px 0; + min-width: 14px; + text-align: center; + + [data-showdex-scheme='light'] & { + background-color: color.alpha(colors.$gray-dark, 0.6); + color: colors.$white; + } + + [data-showdex-scheme='dark'] & { + background-color: color.alpha(colors.$black, 0.5); + color: colors.$white; + } +} + +.validationDouble { + &:hover .validationDoubleMarker { + opacity: 1; + } +} + +.validationDoubleMarker { + position: absolute; + top: -1px; + left: 1px; + @include font.primary(600); + font-size: 8px; + color: colors.$red; + opacity: 0; + @include transition.apply(opacity); +} + +.validationPass { + [data-showdex-scheme='light'] & { + background-color: color.alpha(colors.$green, 0.12); + box-shadow: inset 0 0 0 1px color.alpha(colors.$green, 0.3); + } + + [data-showdex-scheme='dark'] & { + background-color: color.alpha(colors.$green, 0.15); + box-shadow: inset 0 0 0 1px color.alpha(colors.$green, 0.4); + } +} + +.validationFail { + [data-showdex-scheme='light'] & { + background-color: color.alpha(colors.$red, 0.15); + box-shadow: inset 0 0 0 1px color.alpha(colors.$red, 0.4); + } + + [data-showdex-scheme='dark'] & { + background-color: color.alpha(colors.$red, 0.18); + box-shadow: inset 0 0 0 1px color.alpha(colors.$red, 0.4); + } +} + .overlayCloseButton { @include position.abs($t: 8px, $r: 8px); @include flex.row-center; diff --git a/src/pages/Calcdex/Calcdex.tsx b/src/pages/Calcdex/Calcdex.tsx index 5f52993f..b780807e 100644 --- a/src/pages/Calcdex/Calcdex.tsx +++ b/src/pages/Calcdex/Calcdex.tsx @@ -30,6 +30,7 @@ import { import { findPlayerTitle } from '@showdex/utils/app'; import { useMobileViewport, useRandomUuid } from '@showdex/utils/hooks'; import styles from './Calcdex.module.scss'; +import { RandomBattleValidationPanel } from './RandomBattleValidationPanel'; export interface CalcdexProps { onUserPopup?: (username?: string) => void; @@ -197,6 +198,8 @@ export const Calcdex = ({ /> } + + { + const typeMatch = checkId.match(/(?:type-count|type-weak|type-double-weak)-(.+)$/); + if (!typeMatch) return null; + + const typeId = typeMatch[1]; + // Map the type names directly since they're already normalized + const typeNameMap: Record = { + normal: 'Normal', + fighting: 'Fighting', + flying: 'Flying', + poison: 'Poison', + ground: 'Ground', + rock: 'Rock', + bug: 'Bug', + ghost: 'Ghost', + steel: 'Steel', + fire: 'Fire', + water: 'Water', + grass: 'Grass', + electric: 'Electric', + psychic: 'Psychic', + ice: 'Ice', + dragon: 'Dragon', + dark: 'Dark', + fairy: 'Fairy', + }; + + return typeNameMap[typeId] || null; +}; + +export const RandomBattleValidationPanel = (): JSX.Element => { + const validation = useRandomBattlesValidation(); + + if (!validation?.active || !validation?.checks?.length) { + return null; + } + + const typeChecks = validation.checks.filter((c) => c.group === 'type-count'); + const weakChecks = validation.checks.filter((c) => c.group === 'type-weakness'); + const doubleWeakChecks = validation.checks.filter((c) => c.group === 'type-double-weakness'); + const freezeDryCheck = validation.checks.find((c) => c.id === 'freeze-dry-weakness'); + + return ( +
+
+
Random Battles
+
+ + {typeChecks.length > 0 && ( +
+
Types
+
+ {typeChecks.map((check) => { + const typeName = parseTypeFromCheckId(check.id); + + return ( +
+ {typeName && } + {check.count} +
+ ); + })} +
+
+ )} + + {(weakChecks.length > 0 || doubleWeakChecks.length > 0) && ( +
+
Weaknesses
+
+ {doubleWeakChecks.map((check) => { + const typeName = parseTypeFromCheckId(check.id); + + return ( +
+ {typeName && } + {check.count} + +
+ ); + })} + {weakChecks.map((check) => { + const typeName = parseTypeFromCheckId(check.id); + + return ( +
+ {typeName && } + {check.count} +
+ ); + })} + {freezeDryCheck && freezeDryCheck.count > 0 && ( +
+ + {freezeDryCheck.count} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/src/pages/Calcdex/index.ts b/src/pages/Calcdex/index.ts index 6bebf268..d14268a8 100644 --- a/src/pages/Calcdex/index.ts +++ b/src/pages/Calcdex/index.ts @@ -8,4 +8,5 @@ export * from './CalcdexPreactBattleSide'; export * from './CalcdexPreactBattleTimerButton'; export * from './CalcdexPreactBootstrapper'; export * from './CalcdexPreactPanel'; +export * from './RandomBattleValidationPanel'; export * from './CalcdexRenderer'; diff --git a/src/redux/store/createStore.ts b/src/redux/store/createStore.ts index 6de65fa5..b8025b76 100644 --- a/src/redux/store/createStore.ts +++ b/src/redux/store/createStore.ts @@ -16,6 +16,7 @@ import { pkmnApi, showdownApi } from '@showdex/redux/services'; import { type CalcdexSliceState, calcdexSlice } from './calcdexSlice'; import { type HellodexSliceState, hellodexSlice } from './hellodexSlice'; import { type NotedexSliceState, notedexSlice } from './notedexSlice'; +import { type RandBattlesValidatorSliceState, randBattlesValidatorSlice } from './randBattlesValidatorSlice'; import { type ShowdexSliceState, showdexSlice } from './showdexSlice'; import { type TeamdexSliceState, teamdexSlice } from './teamdexSlice'; @@ -27,6 +28,7 @@ export interface RootState extends ReturnType { [hellodexSlice.name]: HellodexSliceState; [notedexSlice.name]: NotedexSliceState; [calcdexSlice.name]: CalcdexSliceState; + [randBattlesValidatorSlice.name]: RandBattlesValidatorSliceState; [teamdexSlice.name]: TeamdexSliceState; } @@ -73,6 +75,7 @@ export const createStore = ( [hellodexSlice.name]: hellodexSlice.reducer, [notedexSlice.name]: notedexSlice.reducer, [calcdexSlice.name]: calcdexSlice.reducer, + [randBattlesValidatorSlice.name]: randBattlesValidatorSlice.reducer, [teamdexSlice.name]: teamdexSlice.reducer, }, diff --git a/src/redux/store/index.ts b/src/redux/store/index.ts index f109ff00..043bbf00 100644 --- a/src/redux/store/index.ts +++ b/src/redux/store/index.ts @@ -3,5 +3,6 @@ export * from './createStore'; export * from './hellodexSlice'; export * from './hooks'; export * from './notedexSlice'; +export * from './randBattlesValidatorSlice'; export * from './showdexSlice'; export * from './teamdexSlice'; diff --git a/src/redux/store/randBattlesValidatorSlice.ts b/src/redux/store/randBattlesValidatorSlice.ts new file mode 100644 index 00000000..ad3cbdcd --- /dev/null +++ b/src/redux/store/randBattlesValidatorSlice.ts @@ -0,0 +1,44 @@ +import { + type Draft, + type PayloadAction, + createSlice, +} from '@reduxjs/toolkit'; +import { type RandomBattlesValidationResult } from '@showdex/utils/random-battles'; + +export interface RandBattlesValidatorSliceEntry extends RandomBattlesValidationResult { + updatedAt: number; +} + +export type RandBattlesValidatorSliceState = Record; + +export const randBattlesValidatorSlice = createSlice({ + name: 'randBattlesValidator', + initialState: {} as RandBattlesValidatorSliceState, + reducers: { + set: ( + state: Draft, + action: PayloadAction<{ battleId: string; validation: RandomBattlesValidationResult }>, + ) => { + const { battleId, validation } = action.payload || {}; + + if (!battleId) { + return; + } + + state[battleId] = { + ...validation, + updatedAt: Date.now(), + }; + }, + clear: ( + state: Draft, + action: PayloadAction, + ) => { + const battleIds = Array.isArray(action.payload) ? action.payload : [action.payload]; + + battleIds.filter(Boolean).forEach((battleId) => { + delete state[battleId]; + }); + }, + }, +}); diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 41196dfc..f64cb0b3 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,6 +1,7 @@ export * from './useElementSize'; export * from './useMobileViewport'; export * from './useRandomUuid'; +export * from './useRandomBattlesValidation'; export * from './useRoomNavigation'; export * from './useThunkyReducer'; export * from './useUserAgent'; diff --git a/src/utils/hooks/useRandomBattlesValidation.ts b/src/utils/hooks/useRandomBattlesValidation.ts new file mode 100644 index 00000000..d8f1f746 --- /dev/null +++ b/src/utils/hooks/useRandomBattlesValidation.ts @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { useCalcdexContext } from '@showdex/components/calc'; +import { randBattlesValidatorSlice, useDispatch, useSelector } from '@showdex/redux/store'; +import { calcRandomBattlesValidation } from '@showdex/utils/random-battles'; + +export const useRandomBattlesValidation = () => { + const { state } = useCalcdexContext(); + const dispatch = useDispatch(); + + const battleId = state?.battleId; + const opponentKey = state?.opponentKey; + const opponent = opponentKey ? state?.[opponentKey] : null; + const pokemon = opponent?.pokemon || []; + + const validation = React.useMemo(() => calcRandomBattlesValidation({ + format: state?.format, + gen: state?.gen, + pokemon, + maxTeamSize: Math.max(opponent?.maxPokemon || 0, pokemon.length || 0, 6), + }), [ + opponent?.maxPokemon, + opponentKey, + pokemon, + state?.format, + state?.gen, + ]); + + React.useEffect(() => { + if (!battleId) { + return; + } + + if (!validation.active) { + dispatch(randBattlesValidatorSlice.actions.clear(battleId)); + return; + } + + dispatch(randBattlesValidatorSlice.actions.set({ + battleId, + validation, + })); + }, [ + battleId, + dispatch, + validation, + ]); + + const stored = useSelector((root) => root.randBattlesValidator?.[battleId]); + + return stored || validation; +}; diff --git a/src/utils/random-battles/calcRandomBattlesValidation.ts b/src/utils/random-battles/calcRandomBattlesValidation.ts new file mode 100644 index 00000000..2cd5a6ce --- /dev/null +++ b/src/utils/random-battles/calcRandomBattlesValidation.ts @@ -0,0 +1,374 @@ +import { type CalcdexPokemon } from '@showdex/interfaces/calc'; +import { formatId } from '@showdex/utils/core'; +import { getDexForFormat } from '@showdex/utils/dex'; + +export type RandomBattlesValidationCheckGroup = + | 'species' + | 'tera-blast' + | 'type-count' + | 'type-weakness' + | 'type-double-weakness' + | 'freeze-dry'; + +export interface RandomBattlesValidationCheck { + id: string; + label: string; + count: number; + limit: number; + ok: boolean; + group: RandomBattlesValidationCheckGroup; +} + +export interface RandomBattlesValidationSummary { + passed: number; + total: number; +} + +export interface RandomBattlesValidationResult { + active: boolean; + format?: string; + teamSize: number; + limitFactor: number; + isMonotype: boolean; + checks: RandomBattlesValidationCheck[]; + summary: RandomBattlesValidationSummary; +} + +export interface CalcRandomBattlesValidationOptions { + format?: string; + gen?: number; + pokemon?: CalcdexPokemon[]; + maxTeamSize?: number; +} + +const teraBlastSpeciesIds = new Set([ + 'ogerpon', + 'ogerponhearthflame', + 'ogerponwellspring', + 'ogerponcornerstone', + 'terapagos', +]); + +const getTypeId = (typeName?: string): string => formatId(typeName || ''); + +const normalizeSpeciesIds = (dex: Showdown.ModdedDex, pokemon?: CalcdexPokemon) => { + const speciesName = pokemon?.transformedForme + || pokemon?.speciesForme + || pokemon?.name + || ''; + const species = speciesName ? dex?.species?.get?.(speciesName) : null; + const baseSpecies = species?.baseSpecies || species?.name || speciesName; + + return { + speciesId: formatId(species?.name || speciesName), + baseSpeciesId: formatId(baseSpecies), + }; +}; + +const resolvePokemonTypes = (dex: Showdown.ModdedDex, pokemon?: CalcdexPokemon): Showdown.TypeName[] => { + // First try direct types if they exist + if (pokemon?.dirtyTypes?.length) { + return pokemon.dirtyTypes.filter(Boolean); + } + + if (pokemon?.types?.length) { + return pokemon.types.filter(Boolean); + } + + // Otherwise, look up the species in the dex + const speciesName = pokemon?.transformedForme + || pokemon?.speciesForme + || pokemon?.species + || pokemon?.name + || ''; + + if (!speciesName) { + return []; + } + + const species = dex?.species?.get?.(speciesName); + return (species?.types || []).filter(Boolean) as Showdown.TypeName[]; +}; + +const getDamageMultiplier = ( + dex: Showdown.ModdedDex, + attackType: Showdown.TypeName, + defenderTypes: Showdown.TypeName[], +): number => { + if (!attackType || !defenderTypes?.length) { + return 1; + } + + return defenderTypes.reduce((multiplier, defenderType) => { + if (!defenderType) { + return multiplier; + } + + const typeData = dex?.types?.get?.(defenderType); + if (!typeData) { + return multiplier; + } + + const damageTaken = (typeData?.damageTaken || {}) as Record; + // Use attackType directly (capitalized) instead of attackTypeId (lowercased) + const modifier = damageTaken[attackType] ?? 0; + + if (modifier === 1) { + return multiplier * 2; + } + + if (modifier === 2) { + return multiplier * 0.5; + } + + if (modifier === 3) { + return 0; + } + + return multiplier; + }, 1); +}; + +const collectMoves = (pokemon?: CalcdexPokemon): string[] => [ + ...(pokemon?.moves || []), + ...(pokemon?.revealedMoves || []), + ...(pokemon?.serverMoves || []), +].filter(Boolean); + +const getAbilityId = (pokemon?: CalcdexPokemon): string => formatId( + pokemon?.dirtyAbility + || pokemon?.ability + || pokemon?.baseAbility + || '', +); + +const getAttackTypes = (): Showdown.TypeName[] => [ + 'Normal', + 'Fighting', + 'Flying', + 'Poison', + 'Ground', + 'Rock', + 'Bug', + 'Ghost', + 'Steel', + 'Fire', + 'Water', + 'Grass', + 'Electric', + 'Psychic', + 'Ice', + 'Dragon', + 'Dark', + 'Fairy', +]; + +export const calcRandomBattlesValidation = ( + options: CalcRandomBattlesValidationOptions, +): RandomBattlesValidationResult => { + try { + const format = options?.format || ''; + const formatKey = formatId(format); + const isRandom = /random/.test(formatKey); + const isMonotype = /monotype/.test(formatKey); + + const dex = getDexForFormat(format); + const pokemon = (options?.pokemon || []).filter(Boolean); + const maxTeamSize = Math.max(options?.maxTeamSize || 0, pokemon.length || 0, 6); + const limitFactor = Math.max(1, Math.round(maxTeamSize / 6)); + + if (!isRandom || !pokemon.length || !dex) { + return { + active: false, + format, + teamSize: maxTeamSize, + limitFactor, + isMonotype, + checks: [], + summary: { + passed: 0, + total: 0, + }, + }; + } + + const attackTypes = getAttackTypes(); + const speciesCounts = new Map(); + const typeCounts = new Map(); + const weakCounts = new Map(); + const doubleWeakCounts = new Map(); + + let teraBlastUsers = 0; + let freezeDryWeakCount = 0; + + pokemon.forEach((entry) => { + if (!entry) return; + + const { speciesId, baseSpeciesId } = normalizeSpeciesIds(dex, entry); + const types = resolvePokemonTypes(dex, entry); + const abilityId = getAbilityId(entry); + const moves = collectMoves(entry); + const speciesKey = baseSpeciesId || speciesId; + + if (speciesKey) { + speciesCounts.set(speciesKey, (speciesCounts.get(speciesKey) || 0) + 1); + } + + if ( + teraBlastSpeciesIds.has(speciesId) + || teraBlastSpeciesIds.has(baseSpeciesId) + || moves.some((move) => formatId(move) === 'terablast') + ) { + teraBlastUsers += 1; + } + + if (types.length) { + new Set(types).forEach((typeName) => { + typeCounts.set(typeName, (typeCounts.get(typeName) || 0) + 1); + }); + } + + if (!isMonotype && types.length) { + attackTypes.forEach((attackType) => { + const multiplier = getDamageMultiplier(dex, attackType, types); + + if (multiplier > 1) { + weakCounts.set(attackType, (weakCounts.get(attackType) || 0) + 1); + } + + if (multiplier >= 4) { + doubleWeakCounts.set(attackType, (doubleWeakCounts.get(attackType) || 0) + 1); + } + }); + + // Fluffy and Dry Skin abilities count as Fire weakness + const hasFireWeakAbility = abilityId === 'dryskin' || abilityId === 'fluffy'; + if (hasFireWeakAbility) { + const fireMultiplier = getDamageMultiplier(dex, 'Fire', types); + // Only add if not already counted (multiplier <= 1 means not weak to Fire normally) + if (fireMultiplier <= 1) { + weakCounts.set('Fire', (weakCounts.get('Fire') || 0) + 1); + } + } + + const iceMultiplier = getDamageMultiplier(dex, 'Ice', types); + const isWater = types.includes('Water'); + const hasFreezeDryVulnerability = abilityId === 'dryskin' || abilityId === 'fluffy'; + + if (iceMultiplier > 1 || isWater || hasFreezeDryVulnerability) { + freezeDryWeakCount += 1; + } + } + }); + + const checks: RandomBattlesValidationCheck[] = []; + const maxSpeciesCount = Math.max(0, ...Array.from(speciesCounts.values())); + + checks.push({ + id: 'species-clause', + label: 'Species Clause', + count: maxSpeciesCount, + limit: 1, + ok: maxSpeciesCount <= 1, + group: 'species', + }); + + checks.push({ + id: 'tera-blast-users', + label: 'Tera Blast Users', + count: teraBlastUsers, + limit: 1, + ok: teraBlastUsers <= 1, + group: 'tera-blast', + }); + + if (!isMonotype) { + const typeLimit = 2 * limitFactor; + const weakLimit = 3 * limitFactor; + const doubleWeakLimit = 1 * limitFactor; + const freezeDryLimit = 4 * limitFactor; + + attackTypes.forEach((typeName) => { + const count = typeCounts.get(typeName) || 0; + + if (count > 0) { + checks.push({ + id: `type-count-${getTypeId(typeName)}`, + label: `${typeName} Type`, + count, + limit: typeLimit, + ok: count <= typeLimit, + group: 'type-count', + }); + } + }); + + attackTypes.forEach((typeName) => { + const count = weakCounts.get(typeName) || 0; + + checks.push({ + id: `type-weak-${getTypeId(typeName)}`, + label: `Weak to ${typeName}`, + count, + limit: weakLimit, + ok: count <= weakLimit, + group: 'type-weakness', + }); + }); + + attackTypes.forEach((typeName) => { + const count = doubleWeakCounts.get(typeName) || 0; + + if (count > 0) { + checks.push({ + id: `type-double-weak-${getTypeId(typeName)}`, + label: `Double weak to ${typeName}`, + count, + limit: doubleWeakLimit, + ok: count <= doubleWeakLimit, + group: 'type-double-weakness', + }); + } + }); + + checks.push({ + id: 'freeze-dry-weakness', + label: 'Weak to Freeze-Dry', + count: freezeDryWeakCount, + limit: freezeDryLimit, + ok: freezeDryWeakCount <= freezeDryLimit, + group: 'freeze-dry', + }); + } + + const passed = checks.filter((check) => check.ok).length; + + return { + active: true, + format, + teamSize: maxTeamSize, + limitFactor, + isMonotype, + checks, + summary: { + passed, + total: checks.length, + }, + }; + } catch (error) { + // Gracefully handle errors in validation calculation + console.error('[Random Battles Validation Error]', error); + return { + active: false, + format: options?.format, + teamSize: options?.maxTeamSize || 0, + limitFactor: 1, + isMonotype: false, + checks: [], + summary: { + passed: 0, + total: 0, + }, + }; + } +}; diff --git a/src/utils/random-battles/index.ts b/src/utils/random-battles/index.ts new file mode 100644 index 00000000..0d314227 --- /dev/null +++ b/src/utils/random-battles/index.ts @@ -0,0 +1 @@ +export * from './calcRandomBattlesValidation';