@kaikybrofc/omnizap-system 2.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +534 -0
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/RELEASE-v2.1.2.md +83 -0
- package/app/config/adminIdentity.js +87 -0
- package/app/config/baileysConfig.js +693 -0
- package/app/config/groupUtils.js +388 -0
- package/app/connection/socketController.js +992 -0
- package/app/controllers/messageController.js +354 -0
- package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
- package/app/modules/adminModule/groupEventHandlers.js +355 -0
- package/app/modules/aiModule/catCommand.js +1006 -0
- package/app/modules/broadcastModule/noticeCommand.js +416 -0
- package/app/modules/gameModule/diceCommand.js +67 -0
- package/app/modules/menuModule/common.js +311 -0
- package/app/modules/menuModule/menus.js +59 -0
- package/app/modules/playModule/playCommand.js +1615 -0
- package/app/modules/quoteModule/quoteCommand.js +851 -0
- package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
- package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
- package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
- package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
- package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
- package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
- package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
- package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
- package/app/modules/statsModule/globalRankingCommand.js +65 -0
- package/app/modules/statsModule/noMessageCommand.js +288 -0
- package/app/modules/statsModule/rankingCommand.js +60 -0
- package/app/modules/statsModule/rankingCommon.js +889 -0
- package/app/modules/stickerModule/addStickerMetadata.js +239 -0
- package/app/modules/stickerModule/convertToWebp.js +390 -0
- package/app/modules/stickerModule/stickerCommand.js +454 -0
- package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
- package/app/modules/stickerModule/stickerTextCommand.js +657 -0
- package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
- package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
- package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
- package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
- package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
- package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
- package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
- package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
- package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
- package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
- package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
- package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
- package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
- package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
- package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
- package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
- package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
- package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
- package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
- package/app/modules/stickerPackModule/stickerPackService.js +788 -0
- package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
- package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
- package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
- package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
- package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
- package/app/modules/systemMetricsModule/pingCommand.js +421 -0
- package/app/modules/tiktokModule/tiktokCommand.js +798 -0
- package/app/modules/userModule/userCommand.js +1217 -0
- package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
- package/app/observability/metrics.js +734 -0
- package/app/services/captchaService.js +492 -0
- package/app/services/dbWriteQueue.js +572 -0
- package/app/services/groupMetadataService.js +279 -0
- package/app/services/lidMapService.js +663 -0
- package/app/services/messagePersistenceService.js +56 -0
- package/app/services/newsBroadcastService.js +351 -0
- package/app/services/pokeApiService.js +398 -0
- package/app/services/queueUtils.js +57 -0
- package/app/services/socketState.js +7 -0
- package/app/store/aiPromptStore.js +38 -0
- package/app/store/groupConfigStore.js +58 -0
- package/app/store/premiumUserStore.js +36 -0
- package/app/utils/antiLink/antiLinkModule.js +804 -0
- package/app/utils/http/getImageBufferModule.js +18 -0
- package/app/utils/json/jsonSanitizer.js +113 -0
- package/app/utils/json/jsonSanitizer.test.js +40 -0
- package/app/utils/logger/loggerModule.js +262 -0
- package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
- package/database/index.js +2052 -0
- package/database/init.js +516 -0
- package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
- package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
- package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
- package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
- package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
- package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
- package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
- package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
- package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
- package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
- package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
- package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
- package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
- package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
- package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
- package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
- package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
- package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
- package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
- package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
- package/docker-compose.yml +103 -0
- package/ecosystem.prod.config.cjs +35 -0
- package/eslint.config.js +61 -0
- package/index.js +437 -0
- package/ml/clip_classifier/Dockerfile +16 -0
- package/ml/clip_classifier/README.md +120 -0
- package/ml/clip_classifier/adaptive_scoring.py +40 -0
- package/ml/clip_classifier/classifier.py +654 -0
- package/ml/clip_classifier/embedding_store.py +481 -0
- package/ml/clip_classifier/env_loader.py +15 -0
- package/ml/clip_classifier/llm_label_expander.py +144 -0
- package/ml/clip_classifier/main.py +213 -0
- package/ml/clip_classifier/requirements.txt +10 -0
- package/ml/clip_classifier/similarity_engine.py +74 -0
- package/observability/alert-rules.yml +60 -0
- package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
- package/observability/grafana/dashboards/omnizap-overview.json +170 -0
- package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
- package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
- package/observability/loki-config.yml +38 -0
- package/observability/mysql-exporter.cnf +5 -0
- package/observability/mysql-setup.sql +46 -0
- package/observability/prometheus.yml +32 -0
- package/observability/promtail-config.yml +84 -0
- package/package.json +109 -0
- package/public/api-docs/index.html +144 -0
- package/public/css/github-project-panel.css +297 -0
- package/public/css/stickers-admin.css +1272 -0
- package/public/css/styles.css +671 -0
- package/public/index.html +1311 -0
- package/public/js/apps/apiDocsApp.js +310 -0
- package/public/js/apps/createPackApp.js +2069 -0
- package/public/js/apps/homeApp.js +396 -0
- package/public/js/apps/stickersAdminApp.js +1744 -0
- package/public/js/apps/stickersApp.js +4830 -0
- package/public/js/catalog.js +1019 -0
- package/public/js/github-panel/components/CommitList.js +34 -0
- package/public/js/github-panel/components/ErrorState.js +16 -0
- package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
- package/public/js/github-panel/components/ReleaseList.js +38 -0
- package/public/js/github-panel/components/SkeletonPanel.js +22 -0
- package/public/js/github-panel/components/StatCard.js +15 -0
- package/public/js/github-panel/index.js +15 -0
- package/public/js/github-panel/useGithubRepoData.js +154 -0
- package/public/js/github-panel/vendor/react.js +11 -0
- package/public/js/runtime/react-runtime.js +19 -0
- package/public/licenca/index.html +106 -0
- package/public/stickers/admin/index.html +23 -0
- package/public/stickers/create/index.html +47 -0
- package/public/stickers/index.html +48 -0
- package/public/termos-de-uso/index.html +125 -0
- package/scripts/cache-bust.mjs +107 -0
- package/scripts/deploy.sh +458 -0
- package/scripts/github-deploy-notify.mjs +174 -0
- package/scripts/release.sh +129 -0
|
@@ -0,0 +1,2082 @@
|
|
|
1
|
+
import { getEffectText, getAbility, getEvolutionChain, getFlavorText, getLocalizedGenus, getLocalizedName, getMove, getNature, getPokemon, getPokemonImage, getSpecies, getType } from '../../services/pokeApiService.js';
|
|
2
|
+
import logger from '../../utils/logger/loggerModule.js';
|
|
3
|
+
|
|
4
|
+
const MIN_LEVEL = 1;
|
|
5
|
+
const MAX_LEVEL = 100;
|
|
6
|
+
const MIN_WILD_LEVEL = 2;
|
|
7
|
+
const DEFAULT_CAPTURE_RATE = 120;
|
|
8
|
+
const DEFAULT_WILD_MAX_ID = Math.max(1025, Number(process.env.RPG_WILD_MAX_POKE_ID) || 1025);
|
|
9
|
+
const MOVE_SAMPLE_LIMIT = Math.max(12, Number(process.env.RPG_MOVE_SAMPLE_LIMIT) || 24);
|
|
10
|
+
const DEFAULT_SHINY_CHANCE = 0.01;
|
|
11
|
+
const RAW_SHINY_CHANCE = Number(process.env.RPG_SHINY_CHANCE ?? DEFAULT_SHINY_CHANCE);
|
|
12
|
+
const SHINY_CHANCE = Number.isFinite(RAW_SHINY_CHANCE) ? Math.max(0, Math.min(1, RAW_SHINY_CHANCE)) : DEFAULT_SHINY_CHANCE;
|
|
13
|
+
const MAX_BIOME_LOOKUP_ATTEMPTS = Math.max(2, Number(process.env.RPG_BIOME_LOOKUP_ATTEMPTS) || 6);
|
|
14
|
+
const MAX_SPECIES_FILTER_ATTEMPTS = Math.max(2, Number(process.env.RPG_SPECIES_FILTER_ATTEMPTS) || 5);
|
|
15
|
+
const LEGENDARY_SPAWN_CHANCE = Math.max(0.005, Math.min(1, Number(process.env.RPG_LEGENDARY_SPAWN_CHANCE) || 0.06));
|
|
16
|
+
const MYTHICAL_SPAWN_CHANCE = Math.max(0.001, Math.min(1, Number(process.env.RPG_MYTHICAL_SPAWN_CHANCE) || 0.03));
|
|
17
|
+
const MAX_ENCOUNTER_LEVEL_DIFF = 1;
|
|
18
|
+
|
|
19
|
+
const PHYSICAL_CLASS = 'physical';
|
|
20
|
+
const SPECIAL_CLASS = 'special';
|
|
21
|
+
const STATUS_CLASS = 'status';
|
|
22
|
+
const MOVESET_SIZE = 4;
|
|
23
|
+
const MOVESET_BACKUP_CANDIDATES = ['struggle'];
|
|
24
|
+
const MOVESET_NEUTRAL_FALLBACK_NAME = 'neutral-strike';
|
|
25
|
+
|
|
26
|
+
const BASE_STAT_NAMES = ['hp', 'attack', 'defense', 'special-attack', 'special-defense', 'speed'];
|
|
27
|
+
const STAT_STAGE_KEYS = ['attack', 'defense', 'specialAttack', 'specialDefense', 'speed', 'accuracy', 'evasion'];
|
|
28
|
+
const MIN_STAT_STAGE = -6;
|
|
29
|
+
const MAX_STAT_STAGE = 6;
|
|
30
|
+
const PARALYSIS_SKIP_CHANCE = 0.25;
|
|
31
|
+
const FREEZE_THAW_CHANCE = 0.2;
|
|
32
|
+
const CONFUSION_SELF_HIT_CHANCE = 0.33;
|
|
33
|
+
const CONFUSION_MIN_TURNS = 1;
|
|
34
|
+
const CONFUSION_MAX_TURNS = 4;
|
|
35
|
+
const SLEEP_MIN_TURNS = 1;
|
|
36
|
+
const SLEEP_MAX_TURNS = 3;
|
|
37
|
+
const BURN_DAMAGE_RATIO = 1 / 16;
|
|
38
|
+
const POISON_DAMAGE_RATIO = 1 / 8;
|
|
39
|
+
const TOXIC_BASE_DAMAGE_RATIO = 1 / 16;
|
|
40
|
+
const BATTLE_DAMAGE_SCALE = Math.max(0.35, Math.min(1.25, Number(process.env.RPG_BATTLE_DAMAGE_SCALE) || 0.68));
|
|
41
|
+
const BATTLE_DAMAGE_MAX_HP_RATIO = Math.max(0.2, Math.min(0.95, Number(process.env.RPG_BATTLE_DAMAGE_MAX_HP_RATIO) || 0.5));
|
|
42
|
+
const BATTLE_DAMAGE_SUPER_EFFECTIVE_BONUS_RATIO = Math.max(0, Math.min(0.45, Number(process.env.RPG_BATTLE_DAMAGE_SUPER_EFFECTIVE_BONUS_RATIO) || 0.2));
|
|
43
|
+
const BATTLE_DAMAGE_ULTRA_EFFECTIVE_BONUS_RATIO = Math.max(0, Math.min(0.5, Number(process.env.RPG_BATTLE_DAMAGE_ULTRA_EFFECTIVE_BONUS_RATIO) || 0.25));
|
|
44
|
+
|
|
45
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
46
|
+
const randomInt = (min, max) => {
|
|
47
|
+
const low = Math.ceil(min);
|
|
48
|
+
const high = Math.floor(max);
|
|
49
|
+
if (high <= low) return low;
|
|
50
|
+
return Math.floor(Math.random() * (high - low + 1)) + low;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const randomFloat = (min, max) => Math.random() * (max - min) + min;
|
|
54
|
+
|
|
55
|
+
const capitalize = (value) => {
|
|
56
|
+
const raw = String(value || '').trim();
|
|
57
|
+
if (!raw) return 'Desconhecido';
|
|
58
|
+
return raw
|
|
59
|
+
.split('-')
|
|
60
|
+
.map((part) => (part ? `${part.slice(0, 1).toUpperCase()}${part.slice(1)}` : ''))
|
|
61
|
+
.join(' ');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const extractIdFromUrl = (url) => {
|
|
65
|
+
const match = String(url || '').match(/\/(\d+)\/?$/);
|
|
66
|
+
return match ? Number(match[1]) : null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const toNumber = (value, fallback = 0) => {
|
|
70
|
+
const parsed = Number(value);
|
|
71
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const toPositiveInt = (value, fallback = 0) => {
|
|
75
|
+
const parsed = Math.floor(toNumber(value, fallback));
|
|
76
|
+
return parsed >= 0 ? parsed : fallback;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const toInt = (value, fallback = 0) => {
|
|
80
|
+
const parsed = Math.trunc(toNumber(value, fallback));
|
|
81
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const normalizeIvs = (ivs) => {
|
|
85
|
+
const source = ivs && typeof ivs === 'object' ? ivs : {};
|
|
86
|
+
return {
|
|
87
|
+
hp: clamp(toPositiveInt(source.hp, randomInt(8, 31)), 0, 31),
|
|
88
|
+
attack: clamp(toPositiveInt(source.attack, randomInt(8, 31)), 0, 31),
|
|
89
|
+
defense: clamp(toPositiveInt(source.defense, randomInt(8, 31)), 0, 31),
|
|
90
|
+
specialAttack: clamp(toPositiveInt(source.specialAttack, randomInt(8, 31)), 0, 31),
|
|
91
|
+
specialDefense: clamp(toPositiveInt(source.specialDefense, randomInt(8, 31)), 0, 31),
|
|
92
|
+
speed: clamp(toPositiveInt(source.speed, randomInt(8, 31)), 0, 31),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const getBaseStats = (pokemonData) => {
|
|
97
|
+
const statMap = {};
|
|
98
|
+
for (const stat of pokemonData?.stats || []) {
|
|
99
|
+
const name = stat?.stat?.name;
|
|
100
|
+
if (!name) continue;
|
|
101
|
+
statMap[name] = toPositiveInt(stat?.base_stat, 1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
hp: statMap.hp || 45,
|
|
106
|
+
attack: statMap.attack || 49,
|
|
107
|
+
defense: statMap.defense || 49,
|
|
108
|
+
specialAttack: statMap['special-attack'] || 50,
|
|
109
|
+
specialDefense: statMap['special-defense'] || 50,
|
|
110
|
+
speed: statMap.speed || 45,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const calculateMaxHp = ({ baseHp, ivHp, level }) => {
|
|
115
|
+
const computed = Math.floor(((2 * baseHp + ivHp) * level) / 100) + level + 10;
|
|
116
|
+
return Math.max(10, computed);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const calculateStat = ({ base, iv, level }) => {
|
|
120
|
+
const computed = Math.floor(((2 * base + iv) * level) / 100) + 5;
|
|
121
|
+
return Math.max(1, computed);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const normalizeStatKey = (name) => {
|
|
125
|
+
const key = String(name || '')
|
|
126
|
+
.trim()
|
|
127
|
+
.toLowerCase();
|
|
128
|
+
if (key === 'special-attack') return 'specialAttack';
|
|
129
|
+
if (key === 'special-defense') return 'specialDefense';
|
|
130
|
+
if (key === 'attack') return 'attack';
|
|
131
|
+
if (key === 'defense') return 'defense';
|
|
132
|
+
if (key === 'speed') return 'speed';
|
|
133
|
+
if (key === 'accuracy') return 'accuracy';
|
|
134
|
+
if (key === 'evasion') return 'evasion';
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const hashString = (value) => {
|
|
139
|
+
const raw = String(value || '');
|
|
140
|
+
let hash = 0;
|
|
141
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
142
|
+
hash = (hash * 31 + raw.charCodeAt(index)) >>> 0;
|
|
143
|
+
}
|
|
144
|
+
return hash;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const normalizeAilmentKey = (value) => {
|
|
148
|
+
const key = String(value || '')
|
|
149
|
+
.trim()
|
|
150
|
+
.toLowerCase();
|
|
151
|
+
if (!key) return null;
|
|
152
|
+
if (key === 'paralysis' || key === 'paralyze' || key === 'par') return 'paralysis';
|
|
153
|
+
if (key === 'burn' || key === 'brn') return 'burn';
|
|
154
|
+
if (key === 'poison' || key === 'psn') return 'poison';
|
|
155
|
+
if (key === 'toxic' || key === 'bad-poison') return 'toxic';
|
|
156
|
+
if (key === 'sleep' || key === 'slp') return 'sleep';
|
|
157
|
+
if (key === 'freeze' || key === 'frz') return 'freeze';
|
|
158
|
+
if (key === 'confusion' || key === 'confuse' || key === 'conf') return 'confusion';
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const stageMultiplier = (stage) => {
|
|
163
|
+
const safeStage = clamp(toPositiveInt(Math.abs(stage), 0), 0, 6);
|
|
164
|
+
if (stage >= 0) return (2 + safeStage) / 2;
|
|
165
|
+
return 2 / (2 + safeStage);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const shouldApplyChance = (chancePercent) => {
|
|
169
|
+
const chance = clamp(toNumber(chancePercent, 0), 0, 100);
|
|
170
|
+
if (chance <= 0) return false;
|
|
171
|
+
if (chance >= 100) return true;
|
|
172
|
+
return Math.random() * 100 < chance;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const createDefaultStatStages = () => ({
|
|
176
|
+
attack: 0,
|
|
177
|
+
defense: 0,
|
|
178
|
+
specialAttack: 0,
|
|
179
|
+
specialDefense: 0,
|
|
180
|
+
speed: 0,
|
|
181
|
+
accuracy: 0,
|
|
182
|
+
evasion: 0,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const buildStatusEffectList = (pokemon = {}) => {
|
|
186
|
+
const effects = [];
|
|
187
|
+
const nonVolatile = normalizeAilmentKey(pokemon?.nonVolatileStatus);
|
|
188
|
+
if (nonVolatile) effects.push(nonVolatile);
|
|
189
|
+
if (toPositiveInt(pokemon?.confusionTurns, 0) > 0) effects.push('confusion');
|
|
190
|
+
return effects;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const syncPokemonCombatState = (pokemon = {}) => {
|
|
194
|
+
if (!pokemon || typeof pokemon !== 'object') return pokemon;
|
|
195
|
+
const maxHpRaw = toInt(pokemon?.maxHp, NaN);
|
|
196
|
+
const currentHpRaw = toInt(pokemon?.currentHp, NaN);
|
|
197
|
+
pokemon.maxHp = Number.isFinite(maxHpRaw) && maxHpRaw > 0 ? maxHpRaw : Math.max(1, Number.isFinite(currentHpRaw) ? currentHpRaw : 1);
|
|
198
|
+
pokemon.currentHp = Number.isFinite(currentHpRaw) ? clamp(currentHpRaw, 0, pokemon.maxHp) : pokemon.maxHp;
|
|
199
|
+
|
|
200
|
+
const sourceStages = pokemon?.statStages && typeof pokemon.statStages === 'object' ? pokemon.statStages : {};
|
|
201
|
+
const nextStages = createDefaultStatStages();
|
|
202
|
+
for (const key of STAT_STAGE_KEYS) {
|
|
203
|
+
nextStages[key] = clamp(toInt(sourceStages[key], 0), MIN_STAT_STAGE, MAX_STAT_STAGE);
|
|
204
|
+
}
|
|
205
|
+
pokemon.statStages = nextStages;
|
|
206
|
+
pokemon.nonVolatileStatus = normalizeAilmentKey(pokemon?.nonVolatileStatus);
|
|
207
|
+
pokemon.sleepTurns = toPositiveInt(pokemon?.sleepTurns, 0);
|
|
208
|
+
pokemon.toxicCounter = Math.max(1, toPositiveInt(pokemon?.toxicCounter, 1));
|
|
209
|
+
pokemon.confusionTurns = toPositiveInt(pokemon?.confusionTurns, 0);
|
|
210
|
+
pokemon.statusEffects = buildStatusEffectList(pokemon);
|
|
211
|
+
return pokemon;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const resolveStatStage = (pokemon = {}, statKey) => {
|
|
215
|
+
if (!STAT_STAGE_KEYS.includes(statKey)) return 0;
|
|
216
|
+
return clamp(toInt(pokemon?.statStages?.[statKey], 0), MIN_STAT_STAGE, MAX_STAT_STAGE);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const applyStatStageDelta = (pokemon = {}, statKey, delta) => {
|
|
220
|
+
if (!STAT_STAGE_KEYS.includes(statKey)) return 0;
|
|
221
|
+
syncPokemonCombatState(pokemon);
|
|
222
|
+
const current = resolveStatStage(pokemon, statKey);
|
|
223
|
+
const desired = clamp(current + toInt(delta, 0), MIN_STAT_STAGE, MAX_STAT_STAGE);
|
|
224
|
+
pokemon.statStages[statKey] = desired;
|
|
225
|
+
return desired - current;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const clearNonVolatileStatus = (pokemon = {}) => {
|
|
229
|
+
syncPokemonCombatState(pokemon);
|
|
230
|
+
pokemon.nonVolatileStatus = null;
|
|
231
|
+
pokemon.sleepTurns = 0;
|
|
232
|
+
pokemon.toxicCounter = 1;
|
|
233
|
+
pokemon.statusEffects = buildStatusEffectList(pokemon);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const setNonVolatileStatus = (pokemon = {}, statusKey) => {
|
|
237
|
+
const normalized = normalizeAilmentKey(statusKey);
|
|
238
|
+
if (!normalized || normalized === 'confusion') return false;
|
|
239
|
+
syncPokemonCombatState(pokemon);
|
|
240
|
+
if (pokemon.nonVolatileStatus) return false;
|
|
241
|
+
pokemon.nonVolatileStatus = normalized;
|
|
242
|
+
if (normalized === 'sleep') {
|
|
243
|
+
pokemon.sleepTurns = randomInt(SLEEP_MIN_TURNS, SLEEP_MAX_TURNS);
|
|
244
|
+
} else {
|
|
245
|
+
pokemon.sleepTurns = 0;
|
|
246
|
+
}
|
|
247
|
+
pokemon.toxicCounter = normalized === 'toxic' ? 1 : 1;
|
|
248
|
+
pokemon.statusEffects = buildStatusEffectList(pokemon);
|
|
249
|
+
return true;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const setConfusionStatus = (pokemon = {}) => {
|
|
253
|
+
syncPokemonCombatState(pokemon);
|
|
254
|
+
if (pokemon.confusionTurns > 0) return false;
|
|
255
|
+
pokemon.confusionTurns = randomInt(CONFUSION_MIN_TURNS, CONFUSION_MAX_TURNS);
|
|
256
|
+
pokemon.statusEffects = buildStatusEffectList(pokemon);
|
|
257
|
+
return true;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const resolveEffectiveStat = ({ pokemon, statKey, applyBurnPenalty = false }) => {
|
|
261
|
+
syncPokemonCombatState(pokemon);
|
|
262
|
+
const base = Math.max(1, toPositiveInt(pokemon?.stats?.[statKey], 1));
|
|
263
|
+
const stage = resolveStatStage(pokemon, statKey);
|
|
264
|
+
let effective = Math.max(1, Math.round(base * stageMultiplier(stage)));
|
|
265
|
+
|
|
266
|
+
if (statKey === 'attack' && applyBurnPenalty && pokemon?.nonVolatileStatus === 'burn') {
|
|
267
|
+
effective = Math.max(1, Math.round(effective * 0.5));
|
|
268
|
+
}
|
|
269
|
+
if (statKey === 'speed' && pokemon?.nonVolatileStatus === 'paralysis') {
|
|
270
|
+
effective = Math.max(1, Math.round(effective * 0.5));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return effective;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const resolveEffectiveAccuracy = ({ move, attacker, defender }) => {
|
|
277
|
+
const baseAccuracy = clamp(toPositiveInt(move?.accuracy, 100), 1, 100);
|
|
278
|
+
const accuracyStage = resolveStatStage(attacker, 'accuracy');
|
|
279
|
+
const evasionStage = resolveStatStage(defender, 'evasion');
|
|
280
|
+
const accuracyMultiplier = stageMultiplier(accuracyStage);
|
|
281
|
+
const evasionMultiplier = stageMultiplier(evasionStage);
|
|
282
|
+
return clamp(Math.round(baseAccuracy * (accuracyMultiplier / Math.max(0.1, evasionMultiplier))), 1, 100);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const isAilmentBlockedByType = (pokemon = {}, ailmentKey) => {
|
|
286
|
+
const types = Array.isArray(pokemon?.types)
|
|
287
|
+
? pokemon.types
|
|
288
|
+
.map((entry) =>
|
|
289
|
+
String(entry || '')
|
|
290
|
+
.trim()
|
|
291
|
+
.toLowerCase(),
|
|
292
|
+
)
|
|
293
|
+
.filter(Boolean)
|
|
294
|
+
: [];
|
|
295
|
+
|
|
296
|
+
if (ailmentKey === 'burn' && types.includes('fire')) return true;
|
|
297
|
+
if ((ailmentKey === 'poison' || ailmentKey === 'toxic') && (types.includes('poison') || types.includes('steel'))) return true;
|
|
298
|
+
if (ailmentKey === 'paralysis' && types.includes('electric')) return true;
|
|
299
|
+
if (ailmentKey === 'freeze' && types.includes('ice')) return true;
|
|
300
|
+
return false;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const formatStageLabel = (statKey) => {
|
|
304
|
+
if (statKey === 'attack') return 'Ataque';
|
|
305
|
+
if (statKey === 'defense') return 'Defesa';
|
|
306
|
+
if (statKey === 'specialAttack') return 'Ataque Especial';
|
|
307
|
+
if (statKey === 'specialDefense') return 'Defesa Especial';
|
|
308
|
+
if (statKey === 'speed') return 'Velocidade';
|
|
309
|
+
if (statKey === 'accuracy') return 'Precisão';
|
|
310
|
+
if (statKey === 'evasion') return 'Evasão';
|
|
311
|
+
return statKey;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const applyNatureAndAbilityModifiers = ({ stats, natureData = null, abilityKey = null }) => {
|
|
315
|
+
const next = { ...stats };
|
|
316
|
+
const increased = normalizeStatKey(natureData?.increased_stat?.name);
|
|
317
|
+
const decreased = normalizeStatKey(natureData?.decreased_stat?.name);
|
|
318
|
+
|
|
319
|
+
if (increased && next[increased]) {
|
|
320
|
+
next[increased] = Math.max(1, Math.round(next[increased] * 1.1));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (decreased && next[decreased]) {
|
|
324
|
+
next[decreased] = Math.max(1, Math.round(next[decreased] * 0.9));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const abilityToken = String(abilityKey || '')
|
|
328
|
+
.trim()
|
|
329
|
+
.toLowerCase();
|
|
330
|
+
if (abilityToken) {
|
|
331
|
+
const modKeys = ['attack', 'defense', 'specialAttack', 'specialDefense', 'speed'];
|
|
332
|
+
const selectedKey = modKeys[hashString(abilityToken) % modKeys.length];
|
|
333
|
+
next[selectedKey] = Math.max(1, Math.round(next[selectedKey] * 1.05));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return next;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const normalizeTypeRelations = (typeData) => {
|
|
340
|
+
const relation = typeData?.damage_relations || {};
|
|
341
|
+
return {
|
|
342
|
+
doubleTo: (relation.double_damage_to || []).map((entry) => entry?.name).filter(Boolean),
|
|
343
|
+
halfTo: (relation.half_damage_to || []).map((entry) => entry?.name).filter(Boolean),
|
|
344
|
+
noTo: (relation.no_damage_to || []).map((entry) => entry?.name).filter(Boolean),
|
|
345
|
+
};
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const normalizeMove = (move, index) => {
|
|
349
|
+
const power = toPositiveInt(move?.power, 0);
|
|
350
|
+
const accuracy = clamp(toPositiveInt(move?.accuracy, 100), 1, 100);
|
|
351
|
+
const damageClass = String(move?.damageClass || STATUS_CLASS).toLowerCase();
|
|
352
|
+
const type = String(move?.type || 'normal').toLowerCase();
|
|
353
|
+
const moveName = String(move?.name || `move-${index + 1}`).toLowerCase();
|
|
354
|
+
const effectMeta = move?.effectMeta && typeof move.effectMeta === 'object' ? move.effectMeta : {};
|
|
355
|
+
const rawStatChanges = Array.isArray(move?.statChanges) ? move.statChanges : Array.isArray(effectMeta.statChanges) ? effectMeta.statChanges : [];
|
|
356
|
+
const statChanges = rawStatChanges
|
|
357
|
+
.map((entry) => {
|
|
358
|
+
const key = normalizeStatKey(entry?.stat || entry?.name || entry?.stat?.name);
|
|
359
|
+
const change = toInt(entry?.change, 0);
|
|
360
|
+
if (!key || !Number.isFinite(change) || change === 0) return null;
|
|
361
|
+
return { stat: key, change: clamp(change, -3, 3) };
|
|
362
|
+
})
|
|
363
|
+
.filter(Boolean);
|
|
364
|
+
const normalizedAilment = normalizeAilmentKey(move?.ailment || effectMeta?.ailment);
|
|
365
|
+
const normalizedTarget = String(move?.target || effectMeta?.target || '')
|
|
366
|
+
.trim()
|
|
367
|
+
.toLowerCase();
|
|
368
|
+
const rawAilmentChance = toNumber(move?.ailmentChance ?? effectMeta?.ailmentChance, NaN);
|
|
369
|
+
const rawStatChance = toNumber(move?.statChance ?? effectMeta?.statChance, NaN);
|
|
370
|
+
const ailmentChance = Number.isFinite(rawAilmentChance) ? clamp(rawAilmentChance, 0, 100) : normalizedAilment && damageClass === STATUS_CLASS ? 100 : 0;
|
|
371
|
+
const rawHealing = toNumber(move?.healing ?? effectMeta?.healing, 0);
|
|
372
|
+
const rawDrain = toNumber(move?.drain ?? effectMeta?.drain, 0);
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
id: toPositiveInt(move?.id, 0),
|
|
376
|
+
name: moveName,
|
|
377
|
+
displayName: capitalize(moveName),
|
|
378
|
+
power,
|
|
379
|
+
accuracy,
|
|
380
|
+
pp: toPositiveInt(move?.pp, 35),
|
|
381
|
+
damageClass: [PHYSICAL_CLASS, SPECIAL_CLASS, STATUS_CLASS].includes(damageClass) ? damageClass : STATUS_CLASS,
|
|
382
|
+
type,
|
|
383
|
+
typeDamage: {
|
|
384
|
+
doubleTo: Array.isArray(move?.typeDamage?.doubleTo) ? move.typeDamage.doubleTo : [],
|
|
385
|
+
halfTo: Array.isArray(move?.typeDamage?.halfTo) ? move.typeDamage.halfTo : [],
|
|
386
|
+
noTo: Array.isArray(move?.typeDamage?.noTo) ? move.typeDamage.noTo : [],
|
|
387
|
+
},
|
|
388
|
+
effectMeta: {
|
|
389
|
+
target: normalizedTarget || null,
|
|
390
|
+
statChanges,
|
|
391
|
+
statChance: Number.isFinite(rawStatChance) ? clamp(rawStatChance, 0, 100) : 100,
|
|
392
|
+
ailment: normalizedAilment,
|
|
393
|
+
ailmentChance,
|
|
394
|
+
healing: clamp(rawHealing, 0, 100),
|
|
395
|
+
drain: clamp(rawDrain, -100, 100),
|
|
396
|
+
},
|
|
397
|
+
shortEffect: String(move?.shortEffect || '').trim() || null,
|
|
398
|
+
loreText: String(move?.loreText || '').trim() || null,
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const loadMoveSnapshot = async (idOrName) => {
|
|
403
|
+
const moveData = await getMove(idOrName);
|
|
404
|
+
const typeName = String(moveData?.type?.name || 'normal').toLowerCase();
|
|
405
|
+
const typeData = await getType(typeName);
|
|
406
|
+
const typeDamage = normalizeTypeRelations(typeData);
|
|
407
|
+
|
|
408
|
+
return normalizeMove(
|
|
409
|
+
{
|
|
410
|
+
id: moveData?.id,
|
|
411
|
+
name: moveData?.name,
|
|
412
|
+
power: moveData?.power,
|
|
413
|
+
accuracy: moveData?.accuracy,
|
|
414
|
+
pp: moveData?.pp,
|
|
415
|
+
damageClass: moveData?.damage_class?.name,
|
|
416
|
+
type: typeName,
|
|
417
|
+
typeDamage,
|
|
418
|
+
target: moveData?.target?.name,
|
|
419
|
+
statChanges: (moveData?.stat_changes || []).map((entry) => ({
|
|
420
|
+
stat: entry?.stat?.name,
|
|
421
|
+
change: entry?.change,
|
|
422
|
+
})),
|
|
423
|
+
statChance: moveData?.meta?.stat_chance ?? moveData?.effect_chance ?? null,
|
|
424
|
+
ailment: moveData?.meta?.ailment?.name || null,
|
|
425
|
+
ailmentChance: moveData?.meta?.ailment_chance ?? moveData?.effect_chance ?? null,
|
|
426
|
+
healing: moveData?.meta?.healing ?? 0,
|
|
427
|
+
drain: moveData?.meta?.drain ?? 0,
|
|
428
|
+
shortEffect: getEffectText(moveData?.effect_entries, { preferLong: false }),
|
|
429
|
+
loreText: getFlavorText(moveData?.flavor_text_entries),
|
|
430
|
+
},
|
|
431
|
+
0,
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const ensureFourMoves = async (moves) => {
|
|
436
|
+
const normalized = (moves || []).map((move, index) => normalizeMove(move, index)).slice(0, MOVESET_SIZE);
|
|
437
|
+
|
|
438
|
+
if (!normalized.length) {
|
|
439
|
+
normalized.push(await loadMoveSnapshot('struggle'));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
while (normalized.length < MOVESET_SIZE) {
|
|
443
|
+
normalized.push({ ...normalized[normalized.length % normalized.length] });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return normalized.slice(0, MOVESET_SIZE);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const buildMoveCandidateList = (pokemonData) => {
|
|
450
|
+
const fromPokemon = (pokemonData?.moves || []).map((entry) => entry?.move?.name).filter(Boolean);
|
|
451
|
+
const unique = new Set();
|
|
452
|
+
const merged = [];
|
|
453
|
+
const reserveForFallback = Math.max(0, MOVESET_BACKUP_CANDIDATES.length);
|
|
454
|
+
const maxFromPokemon = Math.max(1, MOVE_SAMPLE_LIMIT - reserveForFallback);
|
|
455
|
+
|
|
456
|
+
[...fromPokemon.slice(0, maxFromPokemon), ...MOVESET_BACKUP_CANDIDATES].forEach((name) => {
|
|
457
|
+
const key = String(name || '')
|
|
458
|
+
.trim()
|
|
459
|
+
.toLowerCase();
|
|
460
|
+
if (!key || unique.has(key)) return;
|
|
461
|
+
unique.add(key);
|
|
462
|
+
merged.push(key);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return merged.slice(0, MOVE_SAMPLE_LIMIT);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const resolvePokemonTypeList = (pokemonData) => {
|
|
469
|
+
return (pokemonData?.types || [])
|
|
470
|
+
.sort((a, b) => toPositiveInt(a?.slot, 0) - toPositiveInt(b?.slot, 0))
|
|
471
|
+
.map((entry) => entry?.type?.name)
|
|
472
|
+
.filter(Boolean)
|
|
473
|
+
.map((name) => String(name).toLowerCase());
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const isOffensiveMove = (move) => {
|
|
477
|
+
return toPositiveInt(move?.power, 0) > 0 && String(move?.damageClass || '').toLowerCase() !== STATUS_CLASS;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const normalizeMoveType = (move) =>
|
|
481
|
+
String(move?.type || '')
|
|
482
|
+
.trim()
|
|
483
|
+
.toLowerCase();
|
|
484
|
+
|
|
485
|
+
const calcOffensiveMoveScore = ({ move, pokemonTypeSet, penalizeNormal = false }) => {
|
|
486
|
+
const power = Math.max(0, toPositiveInt(move?.power, 0));
|
|
487
|
+
const accuracy = clamp(toPositiveInt(move?.accuracy, 100), 1, 100);
|
|
488
|
+
let score = power * (accuracy / 100);
|
|
489
|
+
const moveType = normalizeMoveType(move);
|
|
490
|
+
if (moveType && pokemonTypeSet.has(moveType)) score *= 1.2;
|
|
491
|
+
if (penalizeNormal && moveType === 'normal') score *= 0.1;
|
|
492
|
+
return score;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const calcSupportMoveScore = (move) => {
|
|
496
|
+
const accuracy = clamp(toPositiveInt(move?.accuracy, 100), 1, 100);
|
|
497
|
+
const meta = move?.effectMeta || {};
|
|
498
|
+
let score = accuracy / 100;
|
|
499
|
+
if (meta?.ailment) score += 2.5;
|
|
500
|
+
if (Array.isArray(meta?.statChanges) && meta.statChanges.length) score += 2;
|
|
501
|
+
if (Math.abs(toNumber(meta?.healing, 0)) > 0) score += 1.5;
|
|
502
|
+
if (Math.abs(toNumber(meta?.drain, 0)) > 0) score += 1;
|
|
503
|
+
return score;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const compareByScoreDesc = (a, b) => {
|
|
507
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
508
|
+
const bPower = toPositiveInt(b.move?.power, 0);
|
|
509
|
+
const aPower = toPositiveInt(a.move?.power, 0);
|
|
510
|
+
if (bPower !== aPower) return bPower - aPower;
|
|
511
|
+
return toPositiveInt(b.move?.accuracy, 100) - toPositiveInt(a.move?.accuracy, 100);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const sortOffensiveMovesByScore = ({ moves = [], pokemonTypeSet, penalizeNormal = false }) => {
|
|
515
|
+
return moves
|
|
516
|
+
.map((move) => ({
|
|
517
|
+
move,
|
|
518
|
+
score: calcOffensiveMoveScore({
|
|
519
|
+
move,
|
|
520
|
+
pokemonTypeSet,
|
|
521
|
+
penalizeNormal,
|
|
522
|
+
}),
|
|
523
|
+
}))
|
|
524
|
+
.sort(compareByScoreDesc)
|
|
525
|
+
.map((entry) => entry.move);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const sortSupportMovesByScore = (moves = []) => {
|
|
529
|
+
return moves
|
|
530
|
+
.map((move) => ({
|
|
531
|
+
move,
|
|
532
|
+
score: calcSupportMoveScore(move),
|
|
533
|
+
}))
|
|
534
|
+
.sort(compareByScoreDesc)
|
|
535
|
+
.map((entry) => entry.move);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const countNormalOffensiveMoves = (moves = []) => {
|
|
539
|
+
return moves.filter((move) => isOffensiveMove(move) && normalizeMoveType(move) === 'normal').length;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const buildNeutralFallbackMove = (suffix = '') =>
|
|
543
|
+
normalizeMove(
|
|
544
|
+
{
|
|
545
|
+
id: 0,
|
|
546
|
+
name: suffix ? `${MOVESET_NEUTRAL_FALLBACK_NAME}-${suffix}` : MOVESET_NEUTRAL_FALLBACK_NAME,
|
|
547
|
+
power: 50,
|
|
548
|
+
accuracy: 100,
|
|
549
|
+
pp: 35,
|
|
550
|
+
damageClass: PHYSICAL_CLASS,
|
|
551
|
+
type: 'neutral',
|
|
552
|
+
typeDamage: {
|
|
553
|
+
doubleTo: [],
|
|
554
|
+
halfTo: [],
|
|
555
|
+
noTo: [],
|
|
556
|
+
},
|
|
557
|
+
target: 'selected-pokemon',
|
|
558
|
+
shortEffect: 'Golpe neutro de segurança para evitar travamento por imunidade.',
|
|
559
|
+
},
|
|
560
|
+
0,
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const resolveBlockedTypesByNoDamageIntersection = (offensiveMoves = []) => {
|
|
564
|
+
if (!offensiveMoves.length) return [];
|
|
565
|
+
const first = offensiveMoves[0];
|
|
566
|
+
const firstNoTo = new Set(Array.isArray(first?.typeDamage?.noTo) ? first.typeDamage.noTo : []);
|
|
567
|
+
const blocked = new Set(firstNoTo);
|
|
568
|
+
|
|
569
|
+
offensiveMoves.slice(1).forEach((move) => {
|
|
570
|
+
const noTo = new Set(Array.isArray(move?.typeDamage?.noTo) ? move.typeDamage.noTo : []);
|
|
571
|
+
for (const blockedType of Array.from(blocked)) {
|
|
572
|
+
if (!noTo.has(blockedType)) {
|
|
573
|
+
blocked.delete(blockedType);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
return Array.from(blocked);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const findFirstIndex = (items = [], predicate) => {
|
|
582
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
583
|
+
if (predicate(items[index], index)) return index;
|
|
584
|
+
}
|
|
585
|
+
return -1;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const pickReplacementIndexForMove = ({ selected = [], pokemonTypeSet, keepOneStab = false }) => {
|
|
589
|
+
const supportIndex = findFirstIndex(selected, (move) => !isOffensiveMove(move));
|
|
590
|
+
if (supportIndex >= 0) return supportIndex;
|
|
591
|
+
|
|
592
|
+
const offensiveIndexes = selected.map((move, index) => ({ move, index })).filter((entry) => isOffensiveMove(entry.move));
|
|
593
|
+
if (!offensiveIndexes.length) return selected.length - 1;
|
|
594
|
+
|
|
595
|
+
const stabIndexes = offensiveIndexes.filter((entry) => pokemonTypeSet.has(normalizeMoveType(entry.move)));
|
|
596
|
+
const canReplace = offensiveIndexes.filter((entry) => !(keepOneStab && stabIndexes.length <= 1 && stabIndexes[0]?.index === entry.index));
|
|
597
|
+
const candidates = canReplace.length ? canReplace : offensiveIndexes;
|
|
598
|
+
|
|
599
|
+
candidates.sort((left, right) => {
|
|
600
|
+
const leftScore = calcOffensiveMoveScore({ move: left.move, pokemonTypeSet });
|
|
601
|
+
const rightScore = calcOffensiveMoveScore({ move: right.move, pokemonTypeSet });
|
|
602
|
+
return leftScore - rightScore;
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
return candidates[0]?.index ?? selected.length - 1;
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const tryAddMove = ({ selected, move, usedNames, allowMultipleNormal, pokemonTypeSet, forceReplace = false, keepOneStab = false }) => {
|
|
609
|
+
if (!move) return false;
|
|
610
|
+
const moveName = String(move?.name || '')
|
|
611
|
+
.trim()
|
|
612
|
+
.toLowerCase();
|
|
613
|
+
if (!moveName || usedNames.has(moveName)) return false;
|
|
614
|
+
|
|
615
|
+
const moveType = normalizeMoveType(move);
|
|
616
|
+
const isNormalOffensive = isOffensiveMove(move) && moveType === 'normal';
|
|
617
|
+
if (!allowMultipleNormal && isNormalOffensive && countNormalOffensiveMoves(selected) >= 1) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (selected.length < MOVESET_SIZE && !forceReplace) {
|
|
622
|
+
selected.push(move);
|
|
623
|
+
usedNames.add(moveName);
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const replaceIndex = pickReplacementIndexForMove({
|
|
628
|
+
selected,
|
|
629
|
+
pokemonTypeSet,
|
|
630
|
+
keepOneStab,
|
|
631
|
+
});
|
|
632
|
+
if (replaceIndex < 0) return false;
|
|
633
|
+
const previousName = String(selected[replaceIndex]?.name || '')
|
|
634
|
+
.trim()
|
|
635
|
+
.toLowerCase();
|
|
636
|
+
selected[replaceIndex] = move;
|
|
637
|
+
if (previousName) usedNames.delete(previousName);
|
|
638
|
+
usedNames.add(moveName);
|
|
639
|
+
return true;
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const pickBestCoverageCandidate = ({ selectedOffensive = [], offensivePool = [], usedNames, pokemonTypeSet, allowMultipleNormal, stabType = null }) => {
|
|
643
|
+
const blockedTypes = resolveBlockedTypesByNoDamageIntersection(selectedOffensive);
|
|
644
|
+
const candidates = offensivePool.filter((move) => {
|
|
645
|
+
const name = String(move?.name || '')
|
|
646
|
+
.trim()
|
|
647
|
+
.toLowerCase();
|
|
648
|
+
if (!name || usedNames.has(name)) return false;
|
|
649
|
+
if (!allowMultipleNormal && normalizeMoveType(move) === 'normal' && countNormalOffensiveMoves(selectedOffensive) >= 1) return false;
|
|
650
|
+
return true;
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
let best = null;
|
|
654
|
+
for (const move of candidates) {
|
|
655
|
+
const moveType = normalizeMoveType(move);
|
|
656
|
+
const base = calcOffensiveMoveScore({
|
|
657
|
+
move,
|
|
658
|
+
pokemonTypeSet,
|
|
659
|
+
penalizeNormal: !allowMultipleNormal && countNormalOffensiveMoves(selectedOffensive) >= 1,
|
|
660
|
+
});
|
|
661
|
+
let score = base;
|
|
662
|
+
if (stabType && moveType && moveType !== stabType) score += 35;
|
|
663
|
+
if (moveType && moveType !== 'normal') score += 8;
|
|
664
|
+
|
|
665
|
+
let unblockCount = 0;
|
|
666
|
+
let neutralCount = 0;
|
|
667
|
+
for (const defType of blockedTypes) {
|
|
668
|
+
const multiplier = resolveTypeMultiplier(move, [defType]);
|
|
669
|
+
if (multiplier > 0) {
|
|
670
|
+
unblockCount += 1;
|
|
671
|
+
if (multiplier === 1) neutralCount += 1;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
score += unblockCount * 45;
|
|
676
|
+
score += neutralCount * 25;
|
|
677
|
+
|
|
678
|
+
if (!best || score > best.score) {
|
|
679
|
+
best = { move, score };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return best?.move || null;
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const pickBattleMoves = async (pokemonData) => {
|
|
687
|
+
const candidateNames = buildMoveCandidateList(pokemonData);
|
|
688
|
+
const pokemonTypes = resolvePokemonTypeList(pokemonData);
|
|
689
|
+
const pokemonTypeSet = new Set(pokemonTypes);
|
|
690
|
+
const loadedMoves = [];
|
|
691
|
+
|
|
692
|
+
for (const moveName of candidateNames) {
|
|
693
|
+
try {
|
|
694
|
+
const move = await loadMoveSnapshot(moveName);
|
|
695
|
+
loadedMoves.push(move);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
logger.debug('Movimento ignorado no carregamento do RPG Pokemon.', {
|
|
698
|
+
moveName,
|
|
699
|
+
error: error.message,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const offensivePool = loadedMoves.filter((move) => isOffensiveMove(move));
|
|
705
|
+
const supportPool = loadedMoves.filter((move) => !isOffensiveMove(move));
|
|
706
|
+
const hasNonNormalOffensive = offensivePool.some((move) => normalizeMoveType(move) !== 'normal');
|
|
707
|
+
const isPureNormal = pokemonTypes.length === 1 && pokemonTypes[0] === 'normal';
|
|
708
|
+
const allowMultipleNormal = isPureNormal && !hasNonNormalOffensive;
|
|
709
|
+
|
|
710
|
+
const selected = [];
|
|
711
|
+
const usedNames = new Set();
|
|
712
|
+
|
|
713
|
+
const stabPool = sortOffensiveMovesByScore({
|
|
714
|
+
moves: offensivePool.filter((move) => pokemonTypeSet.has(normalizeMoveType(move))),
|
|
715
|
+
pokemonTypeSet,
|
|
716
|
+
});
|
|
717
|
+
const bestStab = stabPool[0] || null;
|
|
718
|
+
const selectedStab = tryAddMove({
|
|
719
|
+
selected,
|
|
720
|
+
move: bestStab,
|
|
721
|
+
usedNames,
|
|
722
|
+
allowMultipleNormal,
|
|
723
|
+
pokemonTypeSet,
|
|
724
|
+
});
|
|
725
|
+
const stabType = selectedStab ? normalizeMoveType(bestStab) : null;
|
|
726
|
+
|
|
727
|
+
const selectedOffensiveNow = selected.filter((move) => isOffensiveMove(move));
|
|
728
|
+
const bestCoverage = pickBestCoverageCandidate({
|
|
729
|
+
selectedOffensive: selectedOffensiveNow.length ? selectedOffensiveNow : stabPool.slice(0, 1),
|
|
730
|
+
offensivePool,
|
|
731
|
+
usedNames,
|
|
732
|
+
pokemonTypeSet,
|
|
733
|
+
allowMultipleNormal,
|
|
734
|
+
stabType,
|
|
735
|
+
});
|
|
736
|
+
tryAddMove({
|
|
737
|
+
selected,
|
|
738
|
+
move: bestCoverage,
|
|
739
|
+
usedNames,
|
|
740
|
+
allowMultipleNormal,
|
|
741
|
+
pokemonTypeSet,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const supportCandidates = sortSupportMovesByScore(supportPool);
|
|
745
|
+
const bestUtility = supportCandidates.find((move) => {
|
|
746
|
+
const moveName = String(move?.name || '')
|
|
747
|
+
.trim()
|
|
748
|
+
.toLowerCase();
|
|
749
|
+
return moveName && !usedNames.has(moveName);
|
|
750
|
+
});
|
|
751
|
+
tryAddMove({
|
|
752
|
+
selected,
|
|
753
|
+
move: bestUtility,
|
|
754
|
+
usedNames,
|
|
755
|
+
allowMultipleNormal,
|
|
756
|
+
pokemonTypeSet,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const scoredOffensive = sortOffensiveMovesByScore({
|
|
760
|
+
moves: offensivePool,
|
|
761
|
+
pokemonTypeSet,
|
|
762
|
+
});
|
|
763
|
+
for (const move of scoredOffensive) {
|
|
764
|
+
if (selected.length >= MOVESET_SIZE) break;
|
|
765
|
+
tryAddMove({
|
|
766
|
+
selected,
|
|
767
|
+
move,
|
|
768
|
+
usedNames,
|
|
769
|
+
allowMultipleNormal,
|
|
770
|
+
pokemonTypeSet,
|
|
771
|
+
keepOneStab: stabPool.length > 0,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
for (const move of supportCandidates) {
|
|
776
|
+
if (selected.length >= MOVESET_SIZE) break;
|
|
777
|
+
tryAddMove({
|
|
778
|
+
selected,
|
|
779
|
+
move,
|
|
780
|
+
usedNames,
|
|
781
|
+
allowMultipleNormal,
|
|
782
|
+
pokemonTypeSet,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (stabPool.length > 0 && !selected.some((move) => isOffensiveMove(move) && pokemonTypeSet.has(normalizeMoveType(move)))) {
|
|
787
|
+
tryAddMove({
|
|
788
|
+
selected,
|
|
789
|
+
move: stabPool[0],
|
|
790
|
+
usedNames,
|
|
791
|
+
allowMultipleNormal,
|
|
792
|
+
pokemonTypeSet,
|
|
793
|
+
forceReplace: true,
|
|
794
|
+
keepOneStab: false,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let selectedOffensive = selected.filter((move) => isOffensiveMove(move));
|
|
799
|
+
if (!selectedOffensive.length) {
|
|
800
|
+
const bestOffensive = scoredOffensive[0] || buildNeutralFallbackMove();
|
|
801
|
+
tryAddMove({
|
|
802
|
+
selected,
|
|
803
|
+
move: bestOffensive,
|
|
804
|
+
usedNames,
|
|
805
|
+
allowMultipleNormal: true,
|
|
806
|
+
pokemonTypeSet,
|
|
807
|
+
forceReplace: selected.length >= MOVESET_SIZE,
|
|
808
|
+
});
|
|
809
|
+
selectedOffensive = selected.filter((move) => isOffensiveMove(move));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let blockedTypes = resolveBlockedTypesByNoDamageIntersection(selectedOffensive);
|
|
813
|
+
if (blockedTypes.length) {
|
|
814
|
+
const breaker = pickBestCoverageCandidate({
|
|
815
|
+
selectedOffensive,
|
|
816
|
+
offensivePool,
|
|
817
|
+
usedNames,
|
|
818
|
+
pokemonTypeSet,
|
|
819
|
+
allowMultipleNormal,
|
|
820
|
+
stabType,
|
|
821
|
+
});
|
|
822
|
+
if (breaker) {
|
|
823
|
+
tryAddMove({
|
|
824
|
+
selected,
|
|
825
|
+
move: breaker,
|
|
826
|
+
usedNames,
|
|
827
|
+
allowMultipleNormal,
|
|
828
|
+
pokemonTypeSet,
|
|
829
|
+
forceReplace: selected.length >= MOVESET_SIZE,
|
|
830
|
+
keepOneStab: stabPool.length > 0,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
selectedOffensive = selected.filter((move) => isOffensiveMove(move));
|
|
834
|
+
blockedTypes = resolveBlockedTypesByNoDamageIntersection(selectedOffensive);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (blockedTypes.length) {
|
|
838
|
+
const neutralFallback = buildNeutralFallbackMove();
|
|
839
|
+
tryAddMove({
|
|
840
|
+
selected,
|
|
841
|
+
move: neutralFallback,
|
|
842
|
+
usedNames,
|
|
843
|
+
allowMultipleNormal: true,
|
|
844
|
+
pokemonTypeSet,
|
|
845
|
+
forceReplace: selected.length >= MOVESET_SIZE,
|
|
846
|
+
keepOneStab: stabPool.length > 0,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (!allowMultipleNormal) {
|
|
851
|
+
let neutralIndex = 1;
|
|
852
|
+
while (countNormalOffensiveMoves(selected) > 1) {
|
|
853
|
+
const replaceIndex = findFirstIndex(selected, (move) => isOffensiveMove(move) && normalizeMoveType(move) === 'normal');
|
|
854
|
+
if (replaceIndex < 0) break;
|
|
855
|
+
const replacement = buildNeutralFallbackMove(`normal-limit-${neutralIndex}`);
|
|
856
|
+
const previousName = String(selected[replaceIndex]?.name || '')
|
|
857
|
+
.trim()
|
|
858
|
+
.toLowerCase();
|
|
859
|
+
if (previousName) usedNames.delete(previousName);
|
|
860
|
+
selected[replaceIndex] = replacement;
|
|
861
|
+
usedNames.add(replacement.name);
|
|
862
|
+
neutralIndex += 1;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
let neutralFillIndex = 1;
|
|
867
|
+
while (selected.length < MOVESET_SIZE) {
|
|
868
|
+
const neutralFallback = buildNeutralFallbackMove(`fill-${neutralFillIndex}`);
|
|
869
|
+
tryAddMove({
|
|
870
|
+
selected,
|
|
871
|
+
move: neutralFallback,
|
|
872
|
+
usedNames,
|
|
873
|
+
allowMultipleNormal: true,
|
|
874
|
+
pokemonTypeSet,
|
|
875
|
+
});
|
|
876
|
+
neutralFillIndex += 1;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return ensureFourMoves(selected);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const isStoredMoveValid = (move) => {
|
|
883
|
+
if (!move || typeof move !== 'object') return false;
|
|
884
|
+
const name = String(move.name || '').trim();
|
|
885
|
+
const type = String(move.type || '').trim();
|
|
886
|
+
return Boolean(name && type);
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
const resolveMoveSet = async (pokemonData, storedMoves = null) => {
|
|
890
|
+
const normalizedStored = Array.isArray(storedMoves)
|
|
891
|
+
? storedMoves
|
|
892
|
+
.filter(isStoredMoveValid)
|
|
893
|
+
.map((move, index) => normalizeMove(move, index))
|
|
894
|
+
.slice(0, 4)
|
|
895
|
+
: [];
|
|
896
|
+
|
|
897
|
+
if (normalizedStored.length) {
|
|
898
|
+
return ensureFourMoves(normalizedStored);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const picked = await pickBattleMoves(pokemonData);
|
|
902
|
+
return ensureFourMoves(picked);
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
const resolveTypeMultiplier = (move, defenderTypes = []) => {
|
|
906
|
+
const relation = move?.typeDamage || {};
|
|
907
|
+
const doubleTo = new Set(Array.isArray(relation.doubleTo) ? relation.doubleTo : []);
|
|
908
|
+
const halfTo = new Set(Array.isArray(relation.halfTo) ? relation.halfTo : []);
|
|
909
|
+
const noTo = new Set(Array.isArray(relation.noTo) ? relation.noTo : []);
|
|
910
|
+
|
|
911
|
+
let multiplier = 1;
|
|
912
|
+
|
|
913
|
+
defenderTypes.forEach((defType) => {
|
|
914
|
+
if (noTo.has(defType)) {
|
|
915
|
+
multiplier *= 0;
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (doubleTo.has(defType)) multiplier *= 2;
|
|
920
|
+
if (halfTo.has(defType)) multiplier *= 0.5;
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
return multiplier;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const resolveDamageCapByEffectiveness = ({ multiplier, defenderMaxHp }) => {
|
|
927
|
+
if (multiplier <= 0) return 0;
|
|
928
|
+
|
|
929
|
+
let ratio = BATTLE_DAMAGE_MAX_HP_RATIO;
|
|
930
|
+
if (multiplier >= 2) ratio += BATTLE_DAMAGE_SUPER_EFFECTIVE_BONUS_RATIO;
|
|
931
|
+
if (multiplier >= 4) ratio += BATTLE_DAMAGE_ULTRA_EFFECTIVE_BONUS_RATIO;
|
|
932
|
+
|
|
933
|
+
return Math.max(1, Math.floor(defenderMaxHp * Math.min(1, ratio)));
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
const resolveMoveEffectTarget = (move) => {
|
|
937
|
+
const targetKey = String(move?.effectMeta?.target || '')
|
|
938
|
+
.trim()
|
|
939
|
+
.toLowerCase();
|
|
940
|
+
if (!targetKey) return 'opponent';
|
|
941
|
+
if (targetKey.includes('user') || targetKey.includes('ally')) return 'self';
|
|
942
|
+
return 'opponent';
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
const formatAilmentLabel = (ailmentKey) => {
|
|
946
|
+
if (ailmentKey === 'burn') return 'queimado';
|
|
947
|
+
if (ailmentKey === 'poison') return 'envenenado';
|
|
948
|
+
if (ailmentKey === 'toxic') return 'envenenado gravemente';
|
|
949
|
+
if (ailmentKey === 'paralysis') return 'paralisado';
|
|
950
|
+
if (ailmentKey === 'sleep') return 'adormeceu';
|
|
951
|
+
if (ailmentKey === 'freeze') return 'congelado';
|
|
952
|
+
if (ailmentKey === 'confusion') return 'confuso';
|
|
953
|
+
return 'afetado';
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
const calculateConfusionSelfDamage = (pokemon = {}) => {
|
|
957
|
+
const level = clamp(toPositiveInt(pokemon?.level, 1), MIN_LEVEL, MAX_LEVEL);
|
|
958
|
+
const attackStat = resolveEffectiveStat({ pokemon, statKey: 'attack', applyBurnPenalty: false });
|
|
959
|
+
const defenseStat = resolveEffectiveStat({ pokemon, statKey: 'defense', applyBurnPenalty: false });
|
|
960
|
+
const baseDamage = (((2 * level) / 5 + 2) * 40 * (attackStat / Math.max(1, defenseStat))) / 50 + 2;
|
|
961
|
+
const finalDamage = Math.max(1, Math.floor(baseDamage * randomFloat(0.85, 1)));
|
|
962
|
+
pokemon.currentHp = clamp(toPositiveInt(pokemon.currentHp, 0) - finalDamage, 0, toPositiveInt(pokemon.maxHp, 1));
|
|
963
|
+
return finalDamage;
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const processTurnStartStatus = ({ actor, actorLabel }) => {
|
|
967
|
+
syncPokemonCombatState(actor);
|
|
968
|
+
const logs = [];
|
|
969
|
+
|
|
970
|
+
if (toPositiveInt(actor?.currentHp, 0) <= 0) {
|
|
971
|
+
return { canAct: false, logs };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const nonVolatile = normalizeAilmentKey(actor?.nonVolatileStatus);
|
|
975
|
+
if (nonVolatile === 'sleep') {
|
|
976
|
+
if (actor.sleepTurns <= 0) {
|
|
977
|
+
actor.sleepTurns = randomInt(SLEEP_MIN_TURNS, SLEEP_MAX_TURNS);
|
|
978
|
+
}
|
|
979
|
+
actor.sleepTurns = Math.max(0, actor.sleepTurns - 1);
|
|
980
|
+
if (actor.sleepTurns > 0) {
|
|
981
|
+
logs.push(`${actorLabel} está dormindo e não conseguiu agir.`);
|
|
982
|
+
actor.statusEffects = buildStatusEffectList(actor);
|
|
983
|
+
return { canAct: false, logs };
|
|
984
|
+
}
|
|
985
|
+
clearNonVolatileStatus(actor);
|
|
986
|
+
logs.push(`${actorLabel} acordou.`);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (normalizeAilmentKey(actor?.nonVolatileStatus) === 'freeze') {
|
|
990
|
+
if (shouldApplyChance(FREEZE_THAW_CHANCE * 100)) {
|
|
991
|
+
clearNonVolatileStatus(actor);
|
|
992
|
+
logs.push(`${actorLabel} descongelou.`);
|
|
993
|
+
} else {
|
|
994
|
+
logs.push(`${actorLabel} está congelado e não conseguiu agir.`);
|
|
995
|
+
actor.statusEffects = buildStatusEffectList(actor);
|
|
996
|
+
return { canAct: false, logs };
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (normalizeAilmentKey(actor?.nonVolatileStatus) === 'paralysis' && shouldApplyChance(PARALYSIS_SKIP_CHANCE * 100)) {
|
|
1001
|
+
logs.push(`${actorLabel} está paralisado e não conseguiu agir.`);
|
|
1002
|
+
actor.statusEffects = buildStatusEffectList(actor);
|
|
1003
|
+
return { canAct: false, logs };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (actor.confusionTurns > 0) {
|
|
1007
|
+
actor.confusionTurns = Math.max(0, actor.confusionTurns - 1);
|
|
1008
|
+
if (shouldApplyChance(CONFUSION_SELF_HIT_CHANCE * 100)) {
|
|
1009
|
+
const selfDamage = calculateConfusionSelfDamage(actor);
|
|
1010
|
+
logs.push(`${actorLabel} está confuso e se feriu em *${selfDamage}* de dano.`);
|
|
1011
|
+
if (toPositiveInt(actor.currentHp, 0) <= 0) {
|
|
1012
|
+
logs.push(`${actorLabel} desmaiou.`);
|
|
1013
|
+
}
|
|
1014
|
+
if (actor.confusionTurns <= 0) {
|
|
1015
|
+
logs.push(`${actorLabel} não está mais confuso.`);
|
|
1016
|
+
}
|
|
1017
|
+
actor.statusEffects = buildStatusEffectList(actor);
|
|
1018
|
+
return { canAct: false, logs };
|
|
1019
|
+
}
|
|
1020
|
+
if (actor.confusionTurns <= 0) {
|
|
1021
|
+
logs.push(`${actorLabel} não está mais confuso.`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
actor.statusEffects = buildStatusEffectList(actor);
|
|
1026
|
+
return { canAct: true, logs };
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const applyResidualDamageToPokemon = ({ pokemon, label, logs = [] }) => {
|
|
1030
|
+
syncPokemonCombatState(pokemon);
|
|
1031
|
+
if (toPositiveInt(pokemon?.currentHp, 0) <= 0) return;
|
|
1032
|
+
|
|
1033
|
+
const maxHp = Math.max(1, toPositiveInt(pokemon?.maxHp, 1));
|
|
1034
|
+
const status = normalizeAilmentKey(pokemon?.nonVolatileStatus);
|
|
1035
|
+
let damage = 0;
|
|
1036
|
+
let causeText = '';
|
|
1037
|
+
|
|
1038
|
+
if (status === 'burn') {
|
|
1039
|
+
damage = Math.max(1, Math.floor(maxHp * BURN_DAMAGE_RATIO));
|
|
1040
|
+
causeText = 'queimadura';
|
|
1041
|
+
} else if (status === 'poison') {
|
|
1042
|
+
damage = Math.max(1, Math.floor(maxHp * POISON_DAMAGE_RATIO));
|
|
1043
|
+
causeText = 'veneno';
|
|
1044
|
+
} else if (status === 'toxic') {
|
|
1045
|
+
const toxicCounter = Math.max(1, toPositiveInt(pokemon.toxicCounter, 1));
|
|
1046
|
+
damage = Math.max(1, Math.floor(maxHp * TOXIC_BASE_DAMAGE_RATIO * toxicCounter));
|
|
1047
|
+
pokemon.toxicCounter = toxicCounter + 1;
|
|
1048
|
+
causeText = 'veneno severo';
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (damage <= 0) return;
|
|
1052
|
+
pokemon.currentHp = clamp(toPositiveInt(pokemon.currentHp, 0) - damage, 0, maxHp);
|
|
1053
|
+
logs.push(`${label} sofreu *${damage}* de dano por ${causeText}.`);
|
|
1054
|
+
if (toPositiveInt(pokemon.currentHp, 0) <= 0) {
|
|
1055
|
+
logs.push(`${label} desmaiou.`);
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
const applyEndTurnResidualEffects = ({ snapshot, logs = [], myLabel = 'Seu Pokémon', enemyLabel = 'Inimigo' }) => {
|
|
1060
|
+
applyResidualDamageToPokemon({
|
|
1061
|
+
pokemon: snapshot?.my,
|
|
1062
|
+
label: myLabel,
|
|
1063
|
+
logs,
|
|
1064
|
+
});
|
|
1065
|
+
applyResidualDamageToPokemon({
|
|
1066
|
+
pokemon: snapshot?.enemy,
|
|
1067
|
+
label: enemyLabel,
|
|
1068
|
+
logs,
|
|
1069
|
+
});
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
const applyMoveStatChanges = ({ attacker, defender, move, attackerLabel, defenderLabel, logs = [] }) => {
|
|
1073
|
+
const changes = Array.isArray(move?.effectMeta?.statChanges) ? move.effectMeta.statChanges : [];
|
|
1074
|
+
if (!changes.length) return;
|
|
1075
|
+
const statChance = clamp(toNumber(move?.effectMeta?.statChance, 100), 0, 100);
|
|
1076
|
+
if (!shouldApplyChance(statChance)) return;
|
|
1077
|
+
|
|
1078
|
+
const effectTarget = resolveMoveEffectTarget(move);
|
|
1079
|
+
const targetPokemon = effectTarget === 'self' ? attacker : defender;
|
|
1080
|
+
const targetLabel = effectTarget === 'self' ? attackerLabel : defenderLabel;
|
|
1081
|
+
|
|
1082
|
+
for (const change of changes) {
|
|
1083
|
+
const statKey = normalizeStatKey(change?.stat);
|
|
1084
|
+
const delta = toInt(change?.change, 0);
|
|
1085
|
+
if (!statKey || delta === 0) continue;
|
|
1086
|
+
const applied = applyStatStageDelta(targetPokemon, statKey, delta);
|
|
1087
|
+
if (applied === 0) {
|
|
1088
|
+
logs.push(`${targetLabel} não pode ter ${formatStageLabel(statKey)} alterado além do limite.`);
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
logs.push(`${targetLabel} ${applied > 0 ? 'aumentou' : 'reduziu'} ${formatStageLabel(statKey)} em ${Math.abs(applied)} estágio(s).`);
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
const applyMoveAilment = ({ attacker, defender, move, attackerLabel, defenderLabel, logs = [] }) => {
|
|
1096
|
+
const ailment = normalizeAilmentKey(move?.effectMeta?.ailment);
|
|
1097
|
+
if (!ailment) return;
|
|
1098
|
+
|
|
1099
|
+
const rawChance = toNumber(move?.effectMeta?.ailmentChance, 0);
|
|
1100
|
+
const defaultChance = ailment && String(move?.damageClass || '').toLowerCase() === STATUS_CLASS ? 100 : 0;
|
|
1101
|
+
const chance = rawChance > 0 ? clamp(rawChance, 0, 100) : defaultChance;
|
|
1102
|
+
if (!shouldApplyChance(chance)) return;
|
|
1103
|
+
|
|
1104
|
+
const effectTarget = resolveMoveEffectTarget(move);
|
|
1105
|
+
const targetPokemon = effectTarget === 'self' ? attacker : defender;
|
|
1106
|
+
const targetLabel = effectTarget === 'self' ? attackerLabel : defenderLabel;
|
|
1107
|
+
syncPokemonCombatState(targetPokemon);
|
|
1108
|
+
if (toPositiveInt(targetPokemon?.currentHp, 0) <= 0) return;
|
|
1109
|
+
|
|
1110
|
+
if (ailment === 'confusion') {
|
|
1111
|
+
const appliedConfusion = setConfusionStatus(targetPokemon);
|
|
1112
|
+
if (appliedConfusion) {
|
|
1113
|
+
logs.push(`${targetLabel} ficou confuso.`);
|
|
1114
|
+
} else {
|
|
1115
|
+
logs.push(`${targetLabel} já está confuso.`);
|
|
1116
|
+
}
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (isAilmentBlockedByType(targetPokemon, ailment)) {
|
|
1121
|
+
logs.push(`${targetLabel} é imune a ${formatAilmentLabel(ailment)}.`);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const applied = setNonVolatileStatus(targetPokemon, ailment);
|
|
1126
|
+
if (applied) {
|
|
1127
|
+
logs.push(`${targetLabel} ficou ${formatAilmentLabel(ailment)}.`);
|
|
1128
|
+
} else {
|
|
1129
|
+
logs.push(`${targetLabel} já possui uma condição de status.`);
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
const applyMoveRecoveryAndRecoil = ({ attacker, move, damageDone = 0, attackerLabel, logs = [] }) => {
|
|
1134
|
+
syncPokemonCombatState(attacker);
|
|
1135
|
+
if (toPositiveInt(attacker?.currentHp, 0) <= 0) return;
|
|
1136
|
+
|
|
1137
|
+
const maxHp = Math.max(1, toPositiveInt(attacker?.maxHp, 1));
|
|
1138
|
+
const healingPct = clamp(toNumber(move?.effectMeta?.healing, 0), 0, 100);
|
|
1139
|
+
if (healingPct > 0) {
|
|
1140
|
+
const healAmount = Math.max(1, Math.floor(maxHp * (healingPct / 100)));
|
|
1141
|
+
const before = toPositiveInt(attacker.currentHp, 0);
|
|
1142
|
+
attacker.currentHp = clamp(before + healAmount, 0, maxHp);
|
|
1143
|
+
const recovered = Math.max(0, attacker.currentHp - before);
|
|
1144
|
+
if (recovered > 0) {
|
|
1145
|
+
logs.push(`${attackerLabel} recuperou *${recovered}* de HP.`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const drainPct = clamp(toNumber(move?.effectMeta?.drain, 0), -100, 100);
|
|
1150
|
+
if (drainPct > 0 && damageDone > 0) {
|
|
1151
|
+
const healAmount = Math.max(1, Math.floor(damageDone * (drainPct / 100)));
|
|
1152
|
+
const before = toPositiveInt(attacker.currentHp, 0);
|
|
1153
|
+
attacker.currentHp = clamp(before + healAmount, 0, maxHp);
|
|
1154
|
+
const recovered = Math.max(0, attacker.currentHp - before);
|
|
1155
|
+
if (recovered > 0) {
|
|
1156
|
+
logs.push(`${attackerLabel} drenou energia e recuperou *${recovered}* de HP.`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (drainPct < 0 && damageDone > 0) {
|
|
1161
|
+
const recoil = Math.max(1, Math.floor(damageDone * (Math.abs(drainPct) / 100)));
|
|
1162
|
+
attacker.currentHp = clamp(toPositiveInt(attacker.currentHp, 0) - recoil, 0, maxHp);
|
|
1163
|
+
logs.push(`${attackerLabel} sofreu *${recoil}* de dano de recuo.`);
|
|
1164
|
+
if (toPositiveInt(attacker.currentHp, 0) <= 0) {
|
|
1165
|
+
logs.push(`${attackerLabel} desmaiou.`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
const applyDamage = ({ attacker, defender, move }) => {
|
|
1171
|
+
syncPokemonCombatState(attacker);
|
|
1172
|
+
syncPokemonCombatState(defender);
|
|
1173
|
+
const accuracyRoll = randomInt(1, 100);
|
|
1174
|
+
const accuracy = resolveEffectiveAccuracy({ move, attacker, defender });
|
|
1175
|
+
if (accuracyRoll > accuracy) {
|
|
1176
|
+
return {
|
|
1177
|
+
hit: false,
|
|
1178
|
+
damage: 0,
|
|
1179
|
+
critical: false,
|
|
1180
|
+
stab: 1,
|
|
1181
|
+
multiplier: 1,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const power = toPositiveInt(move?.power, 0);
|
|
1186
|
+
if (power <= 0) {
|
|
1187
|
+
return {
|
|
1188
|
+
hit: true,
|
|
1189
|
+
damage: 0,
|
|
1190
|
+
critical: false,
|
|
1191
|
+
stab: 1,
|
|
1192
|
+
multiplier: 1,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const damageClass = String(move?.damageClass || PHYSICAL_CLASS).toLowerCase();
|
|
1197
|
+
const attackStat = damageClass === SPECIAL_CLASS ? resolveEffectiveStat({ pokemon: attacker, statKey: 'specialAttack', applyBurnPenalty: false }) : resolveEffectiveStat({ pokemon: attacker, statKey: 'attack', applyBurnPenalty: true });
|
|
1198
|
+
const defenseStat = damageClass === SPECIAL_CLASS ? resolveEffectiveStat({ pokemon: defender, statKey: 'specialDefense', applyBurnPenalty: false }) : resolveEffectiveStat({ pokemon: defender, statKey: 'defense', applyBurnPenalty: false });
|
|
1199
|
+
|
|
1200
|
+
const level = clamp(toPositiveInt(attacker?.level, 1), MIN_LEVEL, MAX_LEVEL);
|
|
1201
|
+
const baseDamage = (((2 * level) / 5 + 2) * power * (attackStat / Math.max(1, defenseStat))) / 50 + 2;
|
|
1202
|
+
const stab = Array.isArray(attacker?.types) && attacker.types.includes(move?.type) ? 1.2 : 1;
|
|
1203
|
+
const multiplier = resolveTypeMultiplier(move, defender?.types || []);
|
|
1204
|
+
const randomFactor = randomFloat(0.85, 1);
|
|
1205
|
+
const defenderMaxHp = Math.max(1, toPositiveInt(defender?.maxHp, 1));
|
|
1206
|
+
const damageCap = resolveDamageCapByEffectiveness({ multiplier, defenderMaxHp });
|
|
1207
|
+
|
|
1208
|
+
let finalDamage = Math.floor(baseDamage * stab * multiplier * randomFactor * BATTLE_DAMAGE_SCALE);
|
|
1209
|
+
if (multiplier > 0) {
|
|
1210
|
+
finalDamage = Math.min(Math.max(1, finalDamage), damageCap);
|
|
1211
|
+
} else {
|
|
1212
|
+
finalDamage = 0;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
defender.currentHp = clamp(toPositiveInt(defender.currentHp, 0) - finalDamage, 0, defender.maxHp);
|
|
1216
|
+
|
|
1217
|
+
return {
|
|
1218
|
+
hit: true,
|
|
1219
|
+
damage: finalDamage,
|
|
1220
|
+
critical: false,
|
|
1221
|
+
stab,
|
|
1222
|
+
multiplier,
|
|
1223
|
+
};
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const formatTypeEffectText = (multiplier) => {
|
|
1227
|
+
if (multiplier === 0) return 'Não teve efeito.';
|
|
1228
|
+
if (multiplier >= 2) return 'Super efetivo!';
|
|
1229
|
+
if (multiplier > 0 && multiplier < 1) return 'Pouco efetivo.';
|
|
1230
|
+
return '';
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
const performAction = ({ attacker, defender, move, attackerLabel, defenderLabel }) => {
|
|
1234
|
+
syncPokemonCombatState(attacker);
|
|
1235
|
+
syncPokemonCombatState(defender);
|
|
1236
|
+
const result = applyDamage({ attacker, defender, move });
|
|
1237
|
+
const lines = [];
|
|
1238
|
+
|
|
1239
|
+
if (!result.hit) {
|
|
1240
|
+
return [`${attackerLabel} usou *${move.displayName}* e errou.`];
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const moveClass = String(move?.damageClass || PHYSICAL_CLASS).toLowerCase();
|
|
1244
|
+
if (moveClass === STATUS_CLASS) {
|
|
1245
|
+
lines.push(`${attackerLabel} usou *${move.displayName}*.`);
|
|
1246
|
+
} else if (result.damage <= 0) {
|
|
1247
|
+
lines.push(`${attackerLabel} usou *${move.displayName}*, mas não causou dano.`);
|
|
1248
|
+
} else {
|
|
1249
|
+
lines.push(`${attackerLabel} usou *${move.displayName}* e causou *${result.damage}* de dano.`);
|
|
1250
|
+
const effectText = formatTypeEffectText(result.multiplier);
|
|
1251
|
+
if (effectText) lines.push(effectText);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
applyMoveStatChanges({
|
|
1255
|
+
attacker,
|
|
1256
|
+
defender,
|
|
1257
|
+
move,
|
|
1258
|
+
attackerLabel,
|
|
1259
|
+
defenderLabel,
|
|
1260
|
+
logs: lines,
|
|
1261
|
+
});
|
|
1262
|
+
applyMoveAilment({
|
|
1263
|
+
attacker,
|
|
1264
|
+
defender,
|
|
1265
|
+
move,
|
|
1266
|
+
attackerLabel,
|
|
1267
|
+
defenderLabel,
|
|
1268
|
+
logs: lines,
|
|
1269
|
+
});
|
|
1270
|
+
applyMoveRecoveryAndRecoil({
|
|
1271
|
+
attacker,
|
|
1272
|
+
move,
|
|
1273
|
+
damageDone: result.damage,
|
|
1274
|
+
attackerLabel,
|
|
1275
|
+
logs: lines,
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
if (defender.currentHp <= 0) {
|
|
1279
|
+
lines.push(`${defenderLabel} desmaiou.`);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return lines;
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
const cloneSnapshot = (snapshot) => JSON.parse(JSON.stringify(snapshot));
|
|
1286
|
+
|
|
1287
|
+
const resolveActionOrder = (snapshot, playerMoveIndex, enemyMoveIndex) => {
|
|
1288
|
+
syncPokemonCombatState(snapshot?.my);
|
|
1289
|
+
syncPokemonCombatState(snapshot?.enemy);
|
|
1290
|
+
const mySpeed = resolveEffectiveStat({ pokemon: snapshot?.my, statKey: 'speed' });
|
|
1291
|
+
const enemySpeed = resolveEffectiveStat({ pokemon: snapshot?.enemy, statKey: 'speed' });
|
|
1292
|
+
|
|
1293
|
+
const playerAction = { actor: 'my', moveIndex: playerMoveIndex };
|
|
1294
|
+
const enemyAction = { actor: 'enemy', moveIndex: enemyMoveIndex };
|
|
1295
|
+
|
|
1296
|
+
if (mySpeed === enemySpeed) {
|
|
1297
|
+
return Math.random() > 0.5 ? [playerAction, enemyAction] : [enemyAction, playerAction];
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
return mySpeed > enemySpeed ? [playerAction, enemyAction] : [enemyAction, playerAction];
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
const resolveWinner = (snapshot) => {
|
|
1304
|
+
if (snapshot?.enemy?.currentHp <= 0) return 'player';
|
|
1305
|
+
if (snapshot?.my?.currentHp <= 0) return 'enemy';
|
|
1306
|
+
return null;
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
const pickEnemyMoveIndex = (snapshot) => {
|
|
1310
|
+
const moves = Array.isArray(snapshot?.enemy?.moves) ? snapshot.enemy.moves : [];
|
|
1311
|
+
if (!moves.length) return 0;
|
|
1312
|
+
return randomInt(0, moves.length - 1);
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
const parsePokemonIdFromTypeEntry = (entry) => {
|
|
1316
|
+
const url = entry?.pokemon?.url;
|
|
1317
|
+
const idFromUrl = extractIdFromUrl(url);
|
|
1318
|
+
if (Number.isFinite(idFromUrl) && idFromUrl > 0) {
|
|
1319
|
+
return idFromUrl;
|
|
1320
|
+
}
|
|
1321
|
+
return null;
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
const parsePokemonLookupFromAreaEntry = (entry) => {
|
|
1325
|
+
const url = entry?.pokemon?.url;
|
|
1326
|
+
const idFromUrl = extractIdFromUrl(url);
|
|
1327
|
+
if (Number.isFinite(idFromUrl) && idFromUrl > 0) {
|
|
1328
|
+
return idFromUrl;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const name = String(entry?.pokemon?.name || '')
|
|
1332
|
+
.trim()
|
|
1333
|
+
.toLowerCase();
|
|
1334
|
+
if (name) return name;
|
|
1335
|
+
return null;
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
const pickPokemonFromEncounterPool = async (encounterPool = []) => {
|
|
1339
|
+
const lookups = (Array.isArray(encounterPool) ? encounterPool : [])
|
|
1340
|
+
.map((entry) => {
|
|
1341
|
+
if (typeof entry === 'number') return entry;
|
|
1342
|
+
if (typeof entry === 'string') return entry.trim().toLowerCase();
|
|
1343
|
+
return parsePokemonLookupFromAreaEntry(entry);
|
|
1344
|
+
})
|
|
1345
|
+
.filter(Boolean);
|
|
1346
|
+
|
|
1347
|
+
if (!lookups.length) return null;
|
|
1348
|
+
|
|
1349
|
+
const selectedLookup = lookups[randomInt(0, lookups.length - 1)];
|
|
1350
|
+
try {
|
|
1351
|
+
const pokemonData = await getPokemon(selectedLookup);
|
|
1352
|
+
return pokemonData || null;
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
logger.warn('Falha ao resolver Pokémon por encounter_pool.', {
|
|
1355
|
+
selectedLookup,
|
|
1356
|
+
error: error.message,
|
|
1357
|
+
});
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
const pickPokemonByPreferredTypes = async (preferredTypes = []) => {
|
|
1363
|
+
const normalizedTypes = (Array.isArray(preferredTypes) ? preferredTypes : [])
|
|
1364
|
+
.map((type) =>
|
|
1365
|
+
String(type || '')
|
|
1366
|
+
.trim()
|
|
1367
|
+
.toLowerCase(),
|
|
1368
|
+
)
|
|
1369
|
+
.filter(Boolean);
|
|
1370
|
+
|
|
1371
|
+
if (!normalizedTypes.length) return null;
|
|
1372
|
+
|
|
1373
|
+
for (let attempt = 0; attempt < MAX_BIOME_LOOKUP_ATTEMPTS; attempt += 1) {
|
|
1374
|
+
const selectedType = normalizedTypes[randomInt(0, normalizedTypes.length - 1)];
|
|
1375
|
+
try {
|
|
1376
|
+
const typeData = await getType(selectedType);
|
|
1377
|
+
const pokemonIds = (typeData?.pokemon || []).map(parsePokemonIdFromTypeEntry).filter((id) => Number.isFinite(id) && id > 0 && id <= DEFAULT_WILD_MAX_ID);
|
|
1378
|
+
|
|
1379
|
+
if (!pokemonIds.length) continue;
|
|
1380
|
+
const chosenId = pokemonIds[randomInt(0, pokemonIds.length - 1)];
|
|
1381
|
+
const pokemonData = await getPokemon(chosenId);
|
|
1382
|
+
if (pokemonData) return pokemonData;
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
logger.warn('Falha ao resolver spawn por tipo de bioma.', {
|
|
1385
|
+
selectedType,
|
|
1386
|
+
error: error.message,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
return null;
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
const shouldAcceptSpeciesForEncounter = ({ speciesData, preferredHabitats = [] }) => {
|
|
1395
|
+
const normalizedHabitats = (Array.isArray(preferredHabitats) ? preferredHabitats : [])
|
|
1396
|
+
.map((habitat) =>
|
|
1397
|
+
String(habitat || '')
|
|
1398
|
+
.trim()
|
|
1399
|
+
.toLowerCase(),
|
|
1400
|
+
)
|
|
1401
|
+
.filter(Boolean);
|
|
1402
|
+
|
|
1403
|
+
const habitatName = String(speciesData?.habitat?.name || '')
|
|
1404
|
+
.trim()
|
|
1405
|
+
.toLowerCase();
|
|
1406
|
+
const isLegendary = Boolean(speciesData?.is_legendary);
|
|
1407
|
+
const isMythical = Boolean(speciesData?.is_mythical);
|
|
1408
|
+
|
|
1409
|
+
if (isMythical && Math.random() > MYTHICAL_SPAWN_CHANCE) return false;
|
|
1410
|
+
if (isLegendary && Math.random() > LEGENDARY_SPAWN_CHANCE) return false;
|
|
1411
|
+
|
|
1412
|
+
if (!normalizedHabitats.length) return true;
|
|
1413
|
+
if (!habitatName) return Math.random() <= 0.5;
|
|
1414
|
+
if (normalizedHabitats.includes(habitatName)) return true;
|
|
1415
|
+
return Math.random() <= 0.35;
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
const findEvolutionNodeBySpeciesName = (chainNode, speciesName) => {
|
|
1419
|
+
if (!chainNode || typeof chainNode !== 'object') return null;
|
|
1420
|
+
const currentName = String(chainNode?.species?.name || '').toLowerCase();
|
|
1421
|
+
if (currentName && currentName === String(speciesName || '').toLowerCase()) {
|
|
1422
|
+
return chainNode;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
for (const next of chainNode?.evolves_to || []) {
|
|
1426
|
+
const found = findEvolutionNodeBySpeciesName(next, speciesName);
|
|
1427
|
+
if (found) return found;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
return null;
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
const isBlockedEvolutionDetail = (detail = {}) => {
|
|
1434
|
+
if (!detail || typeof detail !== 'object') return true;
|
|
1435
|
+
const hasValue = (value) => value !== null && value !== undefined;
|
|
1436
|
+
|
|
1437
|
+
return Boolean(detail?.item || detail?.held_item || detail?.known_move || detail?.known_move_type || detail?.location || detail?.party_species || detail?.party_type || detail?.trade_species || detail?.needs_overworld_rain || detail?.turn_upside_down || String(detail?.time_of_day || '').trim() || hasValue(detail?.min_happiness) || hasValue(detail?.min_affection) || hasValue(detail?.min_beauty) || hasValue(detail?.gender) || hasValue(detail?.relative_physical_stats));
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
const resolveEligibleEvolution = (chainNode, level) => {
|
|
1441
|
+
const candidates = [];
|
|
1442
|
+
for (const nextNode of chainNode?.evolves_to || []) {
|
|
1443
|
+
const details = Array.isArray(nextNode?.evolution_details) ? nextNode.evolution_details : [];
|
|
1444
|
+
for (const detail of details) {
|
|
1445
|
+
const triggerName = String(detail?.trigger?.name || '').toLowerCase();
|
|
1446
|
+
if (triggerName && triggerName !== 'level-up') continue;
|
|
1447
|
+
if (isBlockedEvolutionDetail(detail)) continue;
|
|
1448
|
+
|
|
1449
|
+
const minLevel = Number(detail?.min_level);
|
|
1450
|
+
if (!Number.isFinite(minLevel)) continue;
|
|
1451
|
+
if (level < minLevel) continue;
|
|
1452
|
+
|
|
1453
|
+
candidates.push({
|
|
1454
|
+
node: nextNode,
|
|
1455
|
+
minLevel,
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (!candidates.length) return null;
|
|
1461
|
+
candidates.sort((a, b) => a.minLevel - b.minLevel);
|
|
1462
|
+
return candidates[0];
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
const resolveEligibleEvolutionByItem = (chainNode, itemKey) => {
|
|
1466
|
+
const normalizedItem = String(itemKey || '')
|
|
1467
|
+
.trim()
|
|
1468
|
+
.toLowerCase();
|
|
1469
|
+
if (!normalizedItem) return null;
|
|
1470
|
+
|
|
1471
|
+
const candidates = [];
|
|
1472
|
+
for (const nextNode of chainNode?.evolves_to || []) {
|
|
1473
|
+
const details = Array.isArray(nextNode?.evolution_details) ? nextNode.evolution_details : [];
|
|
1474
|
+
for (const detail of details) {
|
|
1475
|
+
const triggerName = String(detail?.trigger?.name || '').toLowerCase();
|
|
1476
|
+
if (triggerName !== 'use-item') continue;
|
|
1477
|
+
const requiredItem = String(detail?.item?.name || '')
|
|
1478
|
+
.trim()
|
|
1479
|
+
.toLowerCase();
|
|
1480
|
+
if (!requiredItem || requiredItem !== normalizedItem) continue;
|
|
1481
|
+
candidates.push(nextNode);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return candidates[0] || null;
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
const resolveSpeciesIdFromNode = (chainNode) => {
|
|
1489
|
+
return extractIdFromUrl(chainNode?.species?.url) || null;
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
export const createRandomIvs = () => ({
|
|
1493
|
+
hp: randomInt(0, 31),
|
|
1494
|
+
attack: randomInt(0, 31),
|
|
1495
|
+
defense: randomInt(0, 31),
|
|
1496
|
+
specialAttack: randomInt(0, 31),
|
|
1497
|
+
specialDefense: randomInt(0, 31),
|
|
1498
|
+
speed: randomInt(0, 31),
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
export const calculateRequiredXpForLevel = (level) => {
|
|
1502
|
+
const safeLevel = clamp(toPositiveInt(level, 1), MIN_LEVEL, MAX_LEVEL);
|
|
1503
|
+
return safeLevel * safeLevel * 25;
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
export const calculatePlayerLevelFromXp = (xp) => {
|
|
1507
|
+
const safeXp = Math.max(0, toPositiveInt(xp, 0));
|
|
1508
|
+
return clamp(Math.floor(Math.sqrt(safeXp / 120)) + 1, MIN_LEVEL, MAX_LEVEL);
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
export const applyPokemonXpGain = ({ currentLevel, currentXp, gainedXp }) => {
|
|
1512
|
+
let level = clamp(toPositiveInt(currentLevel, 1), MIN_LEVEL, MAX_LEVEL);
|
|
1513
|
+
const xp = Math.max(0, toPositiveInt(currentXp, 0) + Math.max(0, toPositiveInt(gainedXp, 0)));
|
|
1514
|
+
|
|
1515
|
+
while (level < MAX_LEVEL && xp >= calculateRequiredXpForLevel(level + 1)) {
|
|
1516
|
+
level += 1;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
return { level, xp };
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
export const buildPokemonSnapshot = async ({ pokemonData, speciesData = null, level, currentHp = null, ivs = null, storedMoves = null, natureData = null, abilityData = null, isShiny = false }) => {
|
|
1523
|
+
const safeLevel = clamp(toPositiveInt(level, 5), MIN_LEVEL, MAX_LEVEL);
|
|
1524
|
+
const resolvedIvs = normalizeIvs(ivs || createRandomIvs());
|
|
1525
|
+
const baseStats = getBaseStats(pokemonData);
|
|
1526
|
+
|
|
1527
|
+
const maxHp = calculateMaxHp({
|
|
1528
|
+
baseHp: baseStats.hp,
|
|
1529
|
+
ivHp: resolvedIvs.hp,
|
|
1530
|
+
level: safeLevel,
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
const baseCalculatedStats = {
|
|
1534
|
+
attack: calculateStat({ base: baseStats.attack, iv: resolvedIvs.attack, level: safeLevel }),
|
|
1535
|
+
defense: calculateStat({ base: baseStats.defense, iv: resolvedIvs.defense, level: safeLevel }),
|
|
1536
|
+
specialAttack: calculateStat({ base: baseStats.specialAttack, iv: resolvedIvs.specialAttack, level: safeLevel }),
|
|
1537
|
+
specialDefense: calculateStat({ base: baseStats.specialDefense, iv: resolvedIvs.specialDefense, level: safeLevel }),
|
|
1538
|
+
speed: calculateStat({ base: baseStats.speed, iv: resolvedIvs.speed, level: safeLevel }),
|
|
1539
|
+
};
|
|
1540
|
+
const abilityKey =
|
|
1541
|
+
String(abilityData?.name || '')
|
|
1542
|
+
.trim()
|
|
1543
|
+
.toLowerCase() || null;
|
|
1544
|
+
const stats = applyNatureAndAbilityModifiers({
|
|
1545
|
+
stats: baseCalculatedStats,
|
|
1546
|
+
natureData,
|
|
1547
|
+
abilityKey,
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
const types = (pokemonData?.types || [])
|
|
1551
|
+
.sort((a, b) => toPositiveInt(a?.slot, 0) - toPositiveInt(b?.slot, 0))
|
|
1552
|
+
.map((entry) => entry?.type?.name)
|
|
1553
|
+
.filter(Boolean)
|
|
1554
|
+
.map((name) => String(name).toLowerCase());
|
|
1555
|
+
|
|
1556
|
+
const moves = await resolveMoveSet(pokemonData, storedMoves);
|
|
1557
|
+
const speciesId = extractIdFromUrl(pokemonData?.species?.url) || toPositiveInt(speciesData?.id, 0);
|
|
1558
|
+
const localizedName = getLocalizedName(speciesData?.names, pokemonData?.name);
|
|
1559
|
+
const localizedGenus = getLocalizedGenus(speciesData?.genera);
|
|
1560
|
+
const flavorText = getFlavorText(speciesData?.flavor_text_entries);
|
|
1561
|
+
const abilityEffectText = getEffectText(abilityData?.effect_entries, { preferLong: false });
|
|
1562
|
+
const resolvedCurrentHp = currentHp === null || currentHp === undefined ? maxHp : clamp(toPositiveInt(currentHp, maxHp), 0, maxHp);
|
|
1563
|
+
|
|
1564
|
+
return {
|
|
1565
|
+
pokeId: toPositiveInt(pokemonData?.id, 0),
|
|
1566
|
+
name: String(pokemonData?.name || 'unknown').toLowerCase(),
|
|
1567
|
+
displayName: capitalize(localizedName || pokemonData?.name),
|
|
1568
|
+
level: safeLevel,
|
|
1569
|
+
currentHp: resolvedCurrentHp,
|
|
1570
|
+
maxHp,
|
|
1571
|
+
types,
|
|
1572
|
+
baseStats,
|
|
1573
|
+
ivs: resolvedIvs,
|
|
1574
|
+
stats,
|
|
1575
|
+
moves,
|
|
1576
|
+
isShiny: Boolean(isShiny),
|
|
1577
|
+
imageUrl: getPokemonImage(pokemonData, { shiny: isShiny }),
|
|
1578
|
+
sprite: pokemonData?.sprites?.front_default || null,
|
|
1579
|
+
speciesId,
|
|
1580
|
+
captureRate: toPositiveInt(speciesData?.capture_rate, DEFAULT_CAPTURE_RATE),
|
|
1581
|
+
growthRate:
|
|
1582
|
+
String(speciesData?.growth_rate?.name || '')
|
|
1583
|
+
.trim()
|
|
1584
|
+
.toLowerCase() || null,
|
|
1585
|
+
habitat:
|
|
1586
|
+
String(speciesData?.habitat?.name || '')
|
|
1587
|
+
.trim()
|
|
1588
|
+
.toLowerCase() || null,
|
|
1589
|
+
isLegendary: Boolean(speciesData?.is_legendary),
|
|
1590
|
+
isMythical: Boolean(speciesData?.is_mythical),
|
|
1591
|
+
genus: localizedGenus || null,
|
|
1592
|
+
flavorText: flavorText || null,
|
|
1593
|
+
nature: natureData
|
|
1594
|
+
? {
|
|
1595
|
+
key:
|
|
1596
|
+
String(natureData?.name || '')
|
|
1597
|
+
.trim()
|
|
1598
|
+
.toLowerCase() || null,
|
|
1599
|
+
name: capitalize(natureData?.name),
|
|
1600
|
+
}
|
|
1601
|
+
: null,
|
|
1602
|
+
ability: abilityData
|
|
1603
|
+
? {
|
|
1604
|
+
key: abilityKey,
|
|
1605
|
+
name: capitalize(abilityData?.name),
|
|
1606
|
+
effectText: abilityEffectText || null,
|
|
1607
|
+
}
|
|
1608
|
+
: null,
|
|
1609
|
+
};
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
export const buildPlayerBattleSnapshot = async ({ playerPokemonRow }) => {
|
|
1613
|
+
const pokemonData = await getPokemon(playerPokemonRow.poke_id);
|
|
1614
|
+
let speciesData = null;
|
|
1615
|
+
try {
|
|
1616
|
+
const speciesLookup = pokemonData?.species?.name || extractIdFromUrl(pokemonData?.species?.url);
|
|
1617
|
+
if (speciesLookup) {
|
|
1618
|
+
speciesData = await getSpecies(speciesLookup);
|
|
1619
|
+
}
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
logger.debug('Species ignorada no snapshot do jogador.', {
|
|
1622
|
+
pokeId: playerPokemonRow?.poke_id,
|
|
1623
|
+
error: error.message,
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
let natureData = null;
|
|
1628
|
+
if (playerPokemonRow?.nature_key) {
|
|
1629
|
+
try {
|
|
1630
|
+
natureData = await getNature(playerPokemonRow.nature_key);
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
logger.debug('Nature ignorada no snapshot do jogador.', {
|
|
1633
|
+
natureKey: playerPokemonRow.nature_key,
|
|
1634
|
+
error: error.message,
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
let abilityData = null;
|
|
1639
|
+
const abilityLookup = playerPokemonRow?.ability_key || playerPokemonRow?.ability_name || null;
|
|
1640
|
+
if (abilityLookup) {
|
|
1641
|
+
try {
|
|
1642
|
+
abilityData = await getAbility(abilityLookup);
|
|
1643
|
+
} catch (error) {
|
|
1644
|
+
logger.debug('Ability ignorada no snapshot do jogador.', {
|
|
1645
|
+
abilityLookup,
|
|
1646
|
+
error: error.message,
|
|
1647
|
+
});
|
|
1648
|
+
abilityData = { name: abilityLookup };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
return buildPokemonSnapshot({
|
|
1653
|
+
pokemonData,
|
|
1654
|
+
speciesData,
|
|
1655
|
+
level: playerPokemonRow.level,
|
|
1656
|
+
currentHp: playerPokemonRow.current_hp,
|
|
1657
|
+
ivs: playerPokemonRow.ivs_json,
|
|
1658
|
+
storedMoves: playerPokemonRow.moves_json,
|
|
1659
|
+
natureData,
|
|
1660
|
+
abilityData,
|
|
1661
|
+
isShiny: Boolean(playerPokemonRow.is_shiny),
|
|
1662
|
+
});
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
export const createWildEncounter = async ({ playerLevel, preferredTypes = [], preferredHabitats = [], encounterPool = [] }) => {
|
|
1666
|
+
const referenceLevel = clamp(toPositiveInt(playerLevel, 1), MIN_LEVEL, MAX_LEVEL);
|
|
1667
|
+
const minLevel = clamp(referenceLevel - MAX_ENCOUNTER_LEVEL_DIFF, MIN_WILD_LEVEL, MAX_LEVEL);
|
|
1668
|
+
const maxLevel = clamp(referenceLevel + MAX_ENCOUNTER_LEVEL_DIFF, minLevel, MAX_LEVEL);
|
|
1669
|
+
const wildLevel = randomInt(minLevel, maxLevel);
|
|
1670
|
+
const isShiny = Math.random() <= SHINY_CHANCE;
|
|
1671
|
+
|
|
1672
|
+
let selectedPokemon = null;
|
|
1673
|
+
let selectedSpecies = null;
|
|
1674
|
+
|
|
1675
|
+
for (let attempt = 0; attempt < MAX_SPECIES_FILTER_ATTEMPTS; attempt += 1) {
|
|
1676
|
+
let candidate = (await pickPokemonFromEncounterPool(encounterPool)) || (await pickPokemonByPreferredTypes(preferredTypes));
|
|
1677
|
+
|
|
1678
|
+
if (!candidate) {
|
|
1679
|
+
const wildId = randomInt(1, DEFAULT_WILD_MAX_ID);
|
|
1680
|
+
try {
|
|
1681
|
+
candidate = await getPokemon(wildId);
|
|
1682
|
+
} catch {
|
|
1683
|
+
candidate = await getPokemon(25);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const speciesId = extractIdFromUrl(candidate?.species?.url) || candidate?.id;
|
|
1688
|
+
if (!speciesId) continue;
|
|
1689
|
+
|
|
1690
|
+
const speciesData = await getSpecies(speciesId);
|
|
1691
|
+
if (!shouldAcceptSpeciesForEncounter({ speciesData, preferredHabitats })) {
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
selectedPokemon = candidate;
|
|
1696
|
+
selectedSpecies = speciesData;
|
|
1697
|
+
break;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if (!selectedPokemon) {
|
|
1701
|
+
selectedPokemon = await getPokemon(25);
|
|
1702
|
+
const fallbackSpeciesId = extractIdFromUrl(selectedPokemon?.species?.url) || selectedPokemon?.id;
|
|
1703
|
+
selectedSpecies = await getSpecies(fallbackSpeciesId);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const abilities = (selectedPokemon?.abilities || []).filter((entry) => !entry?.is_hidden);
|
|
1707
|
+
const abilityEntry = abilities.length ? abilities[randomInt(0, abilities.length - 1)] : selectedPokemon?.abilities?.[0];
|
|
1708
|
+
const abilityKey =
|
|
1709
|
+
String(abilityEntry?.ability?.name || '')
|
|
1710
|
+
.trim()
|
|
1711
|
+
.toLowerCase() || null;
|
|
1712
|
+
let abilityData = null;
|
|
1713
|
+
if (abilityKey) {
|
|
1714
|
+
try {
|
|
1715
|
+
abilityData = await getAbility(abilityKey);
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
logger.debug('Ability ignorada no encontro selvagem.', {
|
|
1718
|
+
abilityKey,
|
|
1719
|
+
error: error.message,
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
let natureData = null;
|
|
1724
|
+
try {
|
|
1725
|
+
natureData = await getNature(randomInt(1, 25));
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
logger.debug('Nature aleatória indisponível no encontro.', {
|
|
1728
|
+
error: error.message,
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const enemySnapshot = await buildPokemonSnapshot({
|
|
1733
|
+
pokemonData: selectedPokemon,
|
|
1734
|
+
speciesData: selectedSpecies,
|
|
1735
|
+
level: wildLevel,
|
|
1736
|
+
currentHp: null,
|
|
1737
|
+
ivs: createRandomIvs(),
|
|
1738
|
+
natureData,
|
|
1739
|
+
abilityData,
|
|
1740
|
+
isShiny,
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
return {
|
|
1744
|
+
enemySnapshot,
|
|
1745
|
+
speciesData: selectedSpecies,
|
|
1746
|
+
isShiny,
|
|
1747
|
+
};
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
export const resolveBattleTurn = ({ battleSnapshot, playerMoveSlot }) => {
|
|
1751
|
+
const snapshot = cloneSnapshot(battleSnapshot);
|
|
1752
|
+
const logs = [];
|
|
1753
|
+
syncPokemonCombatState(snapshot?.my);
|
|
1754
|
+
syncPokemonCombatState(snapshot?.enemy);
|
|
1755
|
+
|
|
1756
|
+
const selectedIndex = clamp(toPositiveInt(playerMoveSlot, 1) - 1, 0, 3);
|
|
1757
|
+
const playerMoves = Array.isArray(snapshot?.my?.moves) ? snapshot.my.moves : [];
|
|
1758
|
+
const enemyMoves = Array.isArray(snapshot?.enemy?.moves) ? snapshot.enemy.moves : [];
|
|
1759
|
+
|
|
1760
|
+
if (!playerMoves[selectedIndex]) {
|
|
1761
|
+
return {
|
|
1762
|
+
snapshot,
|
|
1763
|
+
logs: ['Movimento inválido. Use /rpg atacar 1, 2, 3 ou 4.'],
|
|
1764
|
+
winner: resolveWinner(snapshot),
|
|
1765
|
+
validTurn: false,
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
if (!enemyMoves.length) {
|
|
1770
|
+
return {
|
|
1771
|
+
snapshot,
|
|
1772
|
+
logs: ['O inimigo não tem movimentos válidos.'],
|
|
1773
|
+
winner: resolveWinner(snapshot),
|
|
1774
|
+
validTurn: false,
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const enemyMoveIndex = pickEnemyMoveIndex(snapshot);
|
|
1779
|
+
const orderedActions = resolveActionOrder(snapshot, selectedIndex, enemyMoveIndex);
|
|
1780
|
+
|
|
1781
|
+
for (const action of orderedActions) {
|
|
1782
|
+
if (resolveWinner(snapshot)) break;
|
|
1783
|
+
|
|
1784
|
+
const isPlayerAction = action.actor === 'my';
|
|
1785
|
+
const attacker = isPlayerAction ? snapshot.my : snapshot.enemy;
|
|
1786
|
+
const defender = isPlayerAction ? snapshot.enemy : snapshot.my;
|
|
1787
|
+
const attackerLabel = isPlayerAction ? `Seu ${snapshot.my.displayName}` : ` ${snapshot.enemy.displayName}`.trim();
|
|
1788
|
+
const defenderLabel = isPlayerAction ? ` ${snapshot.enemy.displayName}`.trim() : `Seu ${snapshot.my.displayName}`;
|
|
1789
|
+
|
|
1790
|
+
const preMoveStatus = processTurnStartStatus({
|
|
1791
|
+
actor: attacker,
|
|
1792
|
+
actorLabel: attackerLabel,
|
|
1793
|
+
});
|
|
1794
|
+
logs.push(...preMoveStatus.logs);
|
|
1795
|
+
if (!preMoveStatus.canAct) continue;
|
|
1796
|
+
|
|
1797
|
+
const move = Array.isArray(attacker?.moves) ? attacker.moves[action.moveIndex] : null;
|
|
1798
|
+
logs.push(
|
|
1799
|
+
...performAction({
|
|
1800
|
+
attacker,
|
|
1801
|
+
defender,
|
|
1802
|
+
move,
|
|
1803
|
+
attackerLabel,
|
|
1804
|
+
defenderLabel,
|
|
1805
|
+
}),
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
if (!resolveWinner(snapshot)) {
|
|
1810
|
+
applyEndTurnResidualEffects({
|
|
1811
|
+
snapshot,
|
|
1812
|
+
logs,
|
|
1813
|
+
myLabel: `Seu ${snapshot?.my?.displayName || 'Pokémon'}`,
|
|
1814
|
+
enemyLabel: `${snapshot?.enemy?.displayName || 'Inimigo'}`,
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
return {
|
|
1819
|
+
snapshot,
|
|
1820
|
+
logs,
|
|
1821
|
+
winner: resolveWinner(snapshot),
|
|
1822
|
+
validTurn: true,
|
|
1823
|
+
};
|
|
1824
|
+
};
|
|
1825
|
+
|
|
1826
|
+
export const resolveSingleAttack = ({ attackerSnapshot, defenderSnapshot, moveSlot = 1, attackerLabel = 'Atacante', defenderLabel = 'Defensor' }) => {
|
|
1827
|
+
const attacker = cloneSnapshot(attackerSnapshot || {});
|
|
1828
|
+
const defender = cloneSnapshot(defenderSnapshot || {});
|
|
1829
|
+
syncPokemonCombatState(attacker);
|
|
1830
|
+
syncPokemonCombatState(defender);
|
|
1831
|
+
const logs = [];
|
|
1832
|
+
|
|
1833
|
+
const moves = Array.isArray(attacker?.moves) ? attacker.moves : [];
|
|
1834
|
+
const selectedIndex = clamp(toPositiveInt(moveSlot, 1) - 1, 0, 3);
|
|
1835
|
+
const selectedMove = moves[selectedIndex];
|
|
1836
|
+
|
|
1837
|
+
if (!selectedMove) {
|
|
1838
|
+
return {
|
|
1839
|
+
attacker,
|
|
1840
|
+
defender,
|
|
1841
|
+
logs: ['Movimento inválido. Use 1, 2, 3 ou 4.'],
|
|
1842
|
+
damage: 0,
|
|
1843
|
+
validMove: false,
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const preMoveStatus = processTurnStartStatus({
|
|
1848
|
+
actor: attacker,
|
|
1849
|
+
actorLabel: attackerLabel,
|
|
1850
|
+
});
|
|
1851
|
+
logs.push(...preMoveStatus.logs);
|
|
1852
|
+
if (!preMoveStatus.canAct) {
|
|
1853
|
+
if (!resolveWinner({ my: attacker, enemy: defender })) {
|
|
1854
|
+
applyEndTurnResidualEffects({
|
|
1855
|
+
snapshot: { my: attacker, enemy: defender },
|
|
1856
|
+
logs,
|
|
1857
|
+
myLabel: attackerLabel,
|
|
1858
|
+
enemyLabel: defenderLabel,
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
return {
|
|
1862
|
+
attacker,
|
|
1863
|
+
defender,
|
|
1864
|
+
logs,
|
|
1865
|
+
damage: 0,
|
|
1866
|
+
validMove: true,
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const beforeHp = toPositiveInt(defender.currentHp, 0);
|
|
1871
|
+
const actionLogs = performAction({
|
|
1872
|
+
attacker,
|
|
1873
|
+
defender,
|
|
1874
|
+
move: selectedMove,
|
|
1875
|
+
attackerLabel,
|
|
1876
|
+
defenderLabel,
|
|
1877
|
+
});
|
|
1878
|
+
logs.push(...actionLogs);
|
|
1879
|
+
const afterHp = toPositiveInt(defender.currentHp, 0);
|
|
1880
|
+
|
|
1881
|
+
if (!resolveWinner({ my: attacker, enemy: defender })) {
|
|
1882
|
+
applyEndTurnResidualEffects({
|
|
1883
|
+
snapshot: { my: attacker, enemy: defender },
|
|
1884
|
+
logs,
|
|
1885
|
+
myLabel: attackerLabel,
|
|
1886
|
+
enemyLabel: defenderLabel,
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
return {
|
|
1891
|
+
attacker,
|
|
1892
|
+
defender,
|
|
1893
|
+
logs,
|
|
1894
|
+
damage: Math.max(0, beforeHp - afterHp),
|
|
1895
|
+
validMove: true,
|
|
1896
|
+
};
|
|
1897
|
+
};
|
|
1898
|
+
|
|
1899
|
+
export const resolveCaptureAttempt = ({ battleSnapshot }) => {
|
|
1900
|
+
const snapshot = cloneSnapshot(battleSnapshot);
|
|
1901
|
+
syncPokemonCombatState(snapshot?.my);
|
|
1902
|
+
syncPokemonCombatState(snapshot?.enemy);
|
|
1903
|
+
const logs = [];
|
|
1904
|
+
|
|
1905
|
+
const enemy = snapshot?.enemy;
|
|
1906
|
+
const my = snapshot?.my;
|
|
1907
|
+
|
|
1908
|
+
if (!enemy || !my || enemy.currentHp <= 0) {
|
|
1909
|
+
return {
|
|
1910
|
+
snapshot,
|
|
1911
|
+
success: false,
|
|
1912
|
+
chance: 0,
|
|
1913
|
+
logs: ['Não há alvo válido para captura agora.'],
|
|
1914
|
+
winner: resolveWinner(snapshot),
|
|
1915
|
+
validAction: false,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const captureBonus = clamp(toNumber(snapshot?.my?.captureBonus, 0), 0, 1);
|
|
1920
|
+
const guaranteedCapture = Boolean(snapshot?.my?.guaranteedCapture);
|
|
1921
|
+
const hpFactor = clamp((enemy.maxHp - enemy.currentHp) / Math.max(1, enemy.maxHp), 0, 1);
|
|
1922
|
+
const captureRateFactor = clamp(toPositiveInt(enemy.captureRate, DEFAULT_CAPTURE_RATE) / 255, 0, 1);
|
|
1923
|
+
const chance = guaranteedCapture ? 1 : clamp(0.1 + hpFactor * 0.6 + captureRateFactor * 0.25 + captureBonus, 0.05, 0.95);
|
|
1924
|
+
const success = guaranteedCapture || Math.random() <= chance;
|
|
1925
|
+
|
|
1926
|
+
if (success) {
|
|
1927
|
+
logs.push(`Você lançou uma Poké Bola e capturou *${enemy.displayName}*!`);
|
|
1928
|
+
return {
|
|
1929
|
+
snapshot,
|
|
1930
|
+
success: true,
|
|
1931
|
+
chance,
|
|
1932
|
+
logs,
|
|
1933
|
+
winner: 'player',
|
|
1934
|
+
validAction: true,
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
logs.push(`A captura falhou (${Math.round(chance * 100)}% de chance).`);
|
|
1939
|
+
|
|
1940
|
+
if (enemy.currentHp > 0 && my.currentHp > 0) {
|
|
1941
|
+
const enemyLabel = ` ${snapshot.enemy.displayName}`.trim();
|
|
1942
|
+
const preMoveStatus = processTurnStartStatus({
|
|
1943
|
+
actor: snapshot.enemy,
|
|
1944
|
+
actorLabel: enemyLabel,
|
|
1945
|
+
});
|
|
1946
|
+
logs.push(...preMoveStatus.logs);
|
|
1947
|
+
|
|
1948
|
+
if (preMoveStatus.canAct) {
|
|
1949
|
+
const enemyMoveIndex = pickEnemyMoveIndex(snapshot);
|
|
1950
|
+
const enemyMove = snapshot.enemy.moves[enemyMoveIndex];
|
|
1951
|
+
logs.push(
|
|
1952
|
+
...performAction({
|
|
1953
|
+
attacker: snapshot.enemy,
|
|
1954
|
+
defender: snapshot.my,
|
|
1955
|
+
move: enemyMove,
|
|
1956
|
+
attackerLabel: enemyLabel,
|
|
1957
|
+
defenderLabel: `Seu ${snapshot.my.displayName}`,
|
|
1958
|
+
}),
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
if (!resolveWinner(snapshot)) {
|
|
1964
|
+
applyEndTurnResidualEffects({
|
|
1965
|
+
snapshot,
|
|
1966
|
+
logs,
|
|
1967
|
+
myLabel: `Seu ${snapshot?.my?.displayName || 'Pokémon'}`,
|
|
1968
|
+
enemyLabel: `${snapshot?.enemy?.displayName || 'Inimigo'}`,
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
return {
|
|
1973
|
+
snapshot,
|
|
1974
|
+
success: false,
|
|
1975
|
+
chance,
|
|
1976
|
+
logs,
|
|
1977
|
+
winner: resolveWinner(snapshot),
|
|
1978
|
+
validAction: true,
|
|
1979
|
+
};
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
export const buildEvolutionChainId = (speciesData) => {
|
|
1983
|
+
const chainUrl = speciesData?.evolution_chain?.url;
|
|
1984
|
+
return extractIdFromUrl(chainUrl);
|
|
1985
|
+
};
|
|
1986
|
+
|
|
1987
|
+
export const buildMoveSnapshotByName = async (idOrName) => {
|
|
1988
|
+
return loadMoveSnapshot(idOrName);
|
|
1989
|
+
};
|
|
1990
|
+
|
|
1991
|
+
export const resolveEvolutionByLevel = async ({ pokeId, level }) => {
|
|
1992
|
+
const currentPokemon = await getPokemon(pokeId);
|
|
1993
|
+
const currentSpeciesId = extractIdFromUrl(currentPokemon?.species?.url) || toPositiveInt(currentPokemon?.id, 0);
|
|
1994
|
+
if (!currentSpeciesId) return null;
|
|
1995
|
+
|
|
1996
|
+
const speciesData = await getSpecies(currentSpeciesId);
|
|
1997
|
+
const chainId = buildEvolutionChainId(speciesData);
|
|
1998
|
+
if (!chainId) return null;
|
|
1999
|
+
|
|
2000
|
+
const chainData = await getEvolutionChain(chainId);
|
|
2001
|
+
let currentNode = findEvolutionNodeBySpeciesName(chainData?.chain, currentPokemon?.species?.name || currentPokemon?.name);
|
|
2002
|
+
if (!currentNode) return null;
|
|
2003
|
+
|
|
2004
|
+
let evolvedPokemon = null;
|
|
2005
|
+
const evolvedFrom = currentPokemon;
|
|
2006
|
+
|
|
2007
|
+
while (true) {
|
|
2008
|
+
const nextEvolution = resolveEligibleEvolution(currentNode, level);
|
|
2009
|
+
if (!nextEvolution) break;
|
|
2010
|
+
|
|
2011
|
+
const nextSpeciesId = resolveSpeciesIdFromNode(nextEvolution.node);
|
|
2012
|
+
const nextLookup = nextSpeciesId || String(nextEvolution.node?.species?.name || '').toLowerCase();
|
|
2013
|
+
if (!nextLookup) break;
|
|
2014
|
+
|
|
2015
|
+
try {
|
|
2016
|
+
evolvedPokemon = await getPokemon(nextLookup);
|
|
2017
|
+
currentNode = nextEvolution.node;
|
|
2018
|
+
} catch (error) {
|
|
2019
|
+
logger.warn('Falha ao carregar forma evoluida na PokéAPI.', {
|
|
2020
|
+
pokeId,
|
|
2021
|
+
nextLookup,
|
|
2022
|
+
error: error.message,
|
|
2023
|
+
});
|
|
2024
|
+
break;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
if (!evolvedPokemon) return null;
|
|
2029
|
+
|
|
2030
|
+
return {
|
|
2031
|
+
from: {
|
|
2032
|
+
pokeId: toPositiveInt(evolvedFrom?.id, 0),
|
|
2033
|
+
name: capitalize(evolvedFrom?.name),
|
|
2034
|
+
},
|
|
2035
|
+
to: {
|
|
2036
|
+
pokeId: toPositiveInt(evolvedPokemon?.id, 0),
|
|
2037
|
+
name: capitalize(evolvedPokemon?.name),
|
|
2038
|
+
},
|
|
2039
|
+
pokemonData: evolvedPokemon,
|
|
2040
|
+
};
|
|
2041
|
+
};
|
|
2042
|
+
|
|
2043
|
+
export const resolveEvolutionByItem = async ({ pokeId, itemKey }) => {
|
|
2044
|
+
const normalizedItem = String(itemKey || '')
|
|
2045
|
+
.trim()
|
|
2046
|
+
.toLowerCase();
|
|
2047
|
+
if (!normalizedItem) return null;
|
|
2048
|
+
|
|
2049
|
+
const currentPokemon = await getPokemon(pokeId);
|
|
2050
|
+
const currentSpeciesId = extractIdFromUrl(currentPokemon?.species?.url) || toPositiveInt(currentPokemon?.id, 0);
|
|
2051
|
+
if (!currentSpeciesId) return null;
|
|
2052
|
+
|
|
2053
|
+
const speciesData = await getSpecies(currentSpeciesId);
|
|
2054
|
+
const chainId = buildEvolutionChainId(speciesData);
|
|
2055
|
+
if (!chainId) return null;
|
|
2056
|
+
|
|
2057
|
+
const chainData = await getEvolutionChain(chainId);
|
|
2058
|
+
const currentNode = findEvolutionNodeBySpeciesName(chainData?.chain, currentPokemon?.species?.name || currentPokemon?.name);
|
|
2059
|
+
if (!currentNode) return null;
|
|
2060
|
+
|
|
2061
|
+
const nextNode = resolveEligibleEvolutionByItem(currentNode, normalizedItem);
|
|
2062
|
+
if (!nextNode) return null;
|
|
2063
|
+
|
|
2064
|
+
const nextSpeciesId = resolveSpeciesIdFromNode(nextNode);
|
|
2065
|
+
const nextLookup = nextSpeciesId || String(nextNode?.species?.name || '').toLowerCase();
|
|
2066
|
+
if (!nextLookup) return null;
|
|
2067
|
+
|
|
2068
|
+
const evolvedPokemon = await getPokemon(nextLookup);
|
|
2069
|
+
return {
|
|
2070
|
+
from: {
|
|
2071
|
+
pokeId: toPositiveInt(currentPokemon?.id, 0),
|
|
2072
|
+
name: capitalize(currentPokemon?.name),
|
|
2073
|
+
},
|
|
2074
|
+
to: {
|
|
2075
|
+
pokeId: toPositiveInt(evolvedPokemon?.id, 0),
|
|
2076
|
+
name: capitalize(evolvedPokemon?.name),
|
|
2077
|
+
},
|
|
2078
|
+
pokemonData: evolvedPokemon,
|
|
2079
|
+
};
|
|
2080
|
+
};
|
|
2081
|
+
|
|
2082
|
+
export const listBaseStatNames = () => [...BASE_STAT_NAMES];
|