@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.
Files changed (166) hide show
  1. package/.env.example +534 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/RELEASE-v2.1.2.md +83 -0
  5. package/app/config/adminIdentity.js +87 -0
  6. package/app/config/baileysConfig.js +693 -0
  7. package/app/config/groupUtils.js +388 -0
  8. package/app/connection/socketController.js +992 -0
  9. package/app/controllers/messageController.js +354 -0
  10. package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
  11. package/app/modules/adminModule/groupEventHandlers.js +355 -0
  12. package/app/modules/aiModule/catCommand.js +1006 -0
  13. package/app/modules/broadcastModule/noticeCommand.js +416 -0
  14. package/app/modules/gameModule/diceCommand.js +67 -0
  15. package/app/modules/menuModule/common.js +311 -0
  16. package/app/modules/menuModule/menus.js +59 -0
  17. package/app/modules/playModule/playCommand.js +1615 -0
  18. package/app/modules/quoteModule/quoteCommand.js +851 -0
  19. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
  20. package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
  21. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
  22. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  23. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
  24. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  25. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  26. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  27. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  28. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
  29. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
  30. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  31. package/app/modules/statsModule/globalRankingCommand.js +65 -0
  32. package/app/modules/statsModule/noMessageCommand.js +288 -0
  33. package/app/modules/statsModule/rankingCommand.js +60 -0
  34. package/app/modules/statsModule/rankingCommon.js +889 -0
  35. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  36. package/app/modules/stickerModule/convertToWebp.js +390 -0
  37. package/app/modules/stickerModule/stickerCommand.js +454 -0
  38. package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
  39. package/app/modules/stickerModule/stickerTextCommand.js +657 -0
  40. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  41. package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
  42. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
  43. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
  44. package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
  45. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
  46. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  47. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
  48. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
  49. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
  50. package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
  51. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
  52. package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
  53. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
  54. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
  55. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  56. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
  57. package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
  58. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
  59. package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
  60. package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
  61. package/app/modules/stickerPackModule/stickerPackService.js +788 -0
  62. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
  63. package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
  64. package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
  65. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
  66. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
  67. package/app/modules/systemMetricsModule/pingCommand.js +421 -0
  68. package/app/modules/tiktokModule/tiktokCommand.js +798 -0
  69. package/app/modules/userModule/userCommand.js +1217 -0
  70. package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
  71. package/app/observability/metrics.js +734 -0
  72. package/app/services/captchaService.js +492 -0
  73. package/app/services/dbWriteQueue.js +572 -0
  74. package/app/services/groupMetadataService.js +279 -0
  75. package/app/services/lidMapService.js +663 -0
  76. package/app/services/messagePersistenceService.js +56 -0
  77. package/app/services/newsBroadcastService.js +351 -0
  78. package/app/services/pokeApiService.js +398 -0
  79. package/app/services/queueUtils.js +57 -0
  80. package/app/services/socketState.js +7 -0
  81. package/app/store/aiPromptStore.js +38 -0
  82. package/app/store/groupConfigStore.js +58 -0
  83. package/app/store/premiumUserStore.js +36 -0
  84. package/app/utils/antiLink/antiLinkModule.js +804 -0
  85. package/app/utils/http/getImageBufferModule.js +18 -0
  86. package/app/utils/json/jsonSanitizer.js +113 -0
  87. package/app/utils/json/jsonSanitizer.test.js +40 -0
  88. package/app/utils/logger/loggerModule.js +262 -0
  89. package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
  90. package/database/index.js +2052 -0
  91. package/database/init.js +516 -0
  92. package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
  93. package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
  94. package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
  95. package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
  96. package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
  97. package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
  98. package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
  99. package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
  100. package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
  101. package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
  102. package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
  103. package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
  104. package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
  105. package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
  106. package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
  107. package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
  108. package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
  109. package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
  110. package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
  111. package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
  112. package/docker-compose.yml +103 -0
  113. package/ecosystem.prod.config.cjs +35 -0
  114. package/eslint.config.js +61 -0
  115. package/index.js +437 -0
  116. package/ml/clip_classifier/Dockerfile +16 -0
  117. package/ml/clip_classifier/README.md +120 -0
  118. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  119. package/ml/clip_classifier/classifier.py +654 -0
  120. package/ml/clip_classifier/embedding_store.py +481 -0
  121. package/ml/clip_classifier/env_loader.py +15 -0
  122. package/ml/clip_classifier/llm_label_expander.py +144 -0
  123. package/ml/clip_classifier/main.py +213 -0
  124. package/ml/clip_classifier/requirements.txt +10 -0
  125. package/ml/clip_classifier/similarity_engine.py +74 -0
  126. package/observability/alert-rules.yml +60 -0
  127. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  128. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  129. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  130. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  131. package/observability/loki-config.yml +38 -0
  132. package/observability/mysql-exporter.cnf +5 -0
  133. package/observability/mysql-setup.sql +46 -0
  134. package/observability/prometheus.yml +32 -0
  135. package/observability/promtail-config.yml +84 -0
  136. package/package.json +109 -0
  137. package/public/api-docs/index.html +144 -0
  138. package/public/css/github-project-panel.css +297 -0
  139. package/public/css/stickers-admin.css +1272 -0
  140. package/public/css/styles.css +671 -0
  141. package/public/index.html +1311 -0
  142. package/public/js/apps/apiDocsApp.js +310 -0
  143. package/public/js/apps/createPackApp.js +2069 -0
  144. package/public/js/apps/homeApp.js +396 -0
  145. package/public/js/apps/stickersAdminApp.js +1744 -0
  146. package/public/js/apps/stickersApp.js +4830 -0
  147. package/public/js/catalog.js +1019 -0
  148. package/public/js/github-panel/components/CommitList.js +34 -0
  149. package/public/js/github-panel/components/ErrorState.js +16 -0
  150. package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
  151. package/public/js/github-panel/components/ReleaseList.js +38 -0
  152. package/public/js/github-panel/components/SkeletonPanel.js +22 -0
  153. package/public/js/github-panel/components/StatCard.js +15 -0
  154. package/public/js/github-panel/index.js +15 -0
  155. package/public/js/github-panel/useGithubRepoData.js +154 -0
  156. package/public/js/github-panel/vendor/react.js +11 -0
  157. package/public/js/runtime/react-runtime.js +19 -0
  158. package/public/licenca/index.html +106 -0
  159. package/public/stickers/admin/index.html +23 -0
  160. package/public/stickers/create/index.html +47 -0
  161. package/public/stickers/index.html +48 -0
  162. package/public/termos-de-uso/index.html +125 -0
  163. package/scripts/cache-bust.mjs +107 -0
  164. package/scripts/deploy.sh +458 -0
  165. package/scripts/github-deploy-notify.mjs +174 -0
  166. package/scripts/release.sh +129 -0
@@ -0,0 +1,760 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { createWildEncounter, resolveBattleTurn, resolveCaptureAttempt, resolveEvolutionByItem, resolveEvolutionByLevel, resolveSingleAttack } from './rpgBattleService.js';
5
+
6
+ const PREFERRED_MOVE_NAMES = ['tackle', 'quick-attack', 'scratch', 'pound', 'ember', 'water-gun', 'vine-whip', 'bite', 'gust', 'swift', 'struggle'];
7
+
8
+ const ensurePokeApiCache = () => {
9
+ if (!(globalThis.__omnizapPokeApiCache instanceof Map)) {
10
+ globalThis.__omnizapPokeApiCache = new Map();
11
+ }
12
+ return globalThis.__omnizapPokeApiCache;
13
+ };
14
+
15
+ const setCache = (cache, key, data) => {
16
+ cache.set(key, {
17
+ data,
18
+ expiresAt: Date.now() + 60 * 60 * 1000,
19
+ });
20
+ };
21
+
22
+ const buildTypeData = ({ pokemonIds = [], doubleDamageTo = [], halfDamageTo = [], noDamageTo = [] } = {}) => ({
23
+ damage_relations: {
24
+ double_damage_to: doubleDamageTo.map((name) => ({ name })),
25
+ half_damage_to: halfDamageTo.map((name) => ({ name })),
26
+ no_damage_to: noDamageTo.map((name) => ({ name })),
27
+ },
28
+ pokemon: pokemonIds.map((id) => ({
29
+ pokemon: {
30
+ name: `poke-${id}`,
31
+ url: `https://pokeapi.co/api/v2/pokemon/${id}/`,
32
+ },
33
+ })),
34
+ });
35
+
36
+ const buildMoveData = (name, type = 'normal', power = 40) => ({
37
+ id: Math.max(1, name.length * 17),
38
+ name,
39
+ power,
40
+ accuracy: 100,
41
+ pp: 35,
42
+ damage_class: { name: power > 0 ? 'physical' : 'status' },
43
+ type: { name: type },
44
+ });
45
+
46
+ const buildPokemonData = ({ id, name, speciesId = id, primaryType = 'normal', moves = PREFERRED_MOVE_NAMES, hp = 45, attack = 49, defense = 49, specialAttack = 65, specialDefense = 65, speed = 45 }) => ({
47
+ id,
48
+ name,
49
+ species: {
50
+ name,
51
+ url: `https://pokeapi.co/api/v2/pokemon-species/${speciesId}/`,
52
+ },
53
+ types: [{ slot: 1, type: { name: primaryType } }],
54
+ stats: [
55
+ { base_stat: hp, stat: { name: 'hp' } },
56
+ { base_stat: attack, stat: { name: 'attack' } },
57
+ { base_stat: defense, stat: { name: 'defense' } },
58
+ { base_stat: specialAttack, stat: { name: 'special-attack' } },
59
+ { base_stat: specialDefense, stat: { name: 'special-defense' } },
60
+ { base_stat: speed, stat: { name: 'speed' } },
61
+ ],
62
+ moves: moves.map((moveName) => ({ move: { name: moveName } })),
63
+ sprites: {
64
+ front_default: `https://img.local/${id}-front.png`,
65
+ front_shiny: `https://img.local/${id}-shiny.png`,
66
+ other: {
67
+ 'official-artwork': {
68
+ front_default: `https://img.local/${id}-artwork.png`,
69
+ },
70
+ },
71
+ },
72
+ });
73
+
74
+ const seedCorePokemonData = (cache) => {
75
+ setCache(cache, 'pokemon:1', buildPokemonData({ id: 1, name: 'bulbasaur', primaryType: 'grass', speed: 45 }));
76
+ setCache(cache, 'pokemon:4', buildPokemonData({ id: 4, name: 'charmander', primaryType: 'fire', speed: 65 }));
77
+ setCache(cache, 'pokemon:5', buildPokemonData({ id: 5, name: 'charmeleon', primaryType: 'fire', speciesId: 5, speed: 80 }));
78
+ setCache(cache, 'pokemon:6', buildPokemonData({ id: 6, name: 'charizard', primaryType: 'fire', speciesId: 6, speed: 100 }));
79
+ setCache(cache, 'pokemon:25', buildPokemonData({ id: 25, name: 'pikachu', primaryType: 'electric', speed: 90 }));
80
+ setCache(cache, 'pokemon:26', buildPokemonData({ id: 26, name: 'raichu', primaryType: 'electric', speciesId: 26, speed: 110 }));
81
+
82
+ setCache(cache, 'species:1', {
83
+ id: 1,
84
+ capture_rate: 45,
85
+ evolution_chain: { url: 'https://pokeapi.co/api/v2/evolution-chain/1/' },
86
+ });
87
+ setCache(cache, 'species:4', {
88
+ id: 4,
89
+ capture_rate: 45,
90
+ evolution_chain: { url: 'https://pokeapi.co/api/v2/evolution-chain/2/' },
91
+ });
92
+ setCache(cache, 'species:25', {
93
+ id: 25,
94
+ capture_rate: 190,
95
+ evolution_chain: { url: 'https://pokeapi.co/api/v2/evolution-chain/10/' },
96
+ });
97
+ setCache(cache, 'species:26', {
98
+ id: 26,
99
+ capture_rate: 75,
100
+ evolution_chain: { url: 'https://pokeapi.co/api/v2/evolution-chain/10/' },
101
+ });
102
+
103
+ setCache(cache, 'evolution-chain:1', {
104
+ chain: {
105
+ species: { name: 'bulbasaur', url: 'https://pokeapi.co/api/v2/pokemon-species/1/' },
106
+ evolves_to: [
107
+ {
108
+ species: { name: 'ivysaur', url: 'https://pokeapi.co/api/v2/pokemon-species/2/' },
109
+ evolution_details: [{ trigger: { name: 'level-up' }, min_level: 16 }],
110
+ evolves_to: [],
111
+ },
112
+ ],
113
+ },
114
+ });
115
+
116
+ setCache(cache, 'evolution-chain:2', {
117
+ chain: {
118
+ species: { name: 'charmander', url: 'https://pokeapi.co/api/v2/pokemon-species/4/' },
119
+ evolves_to: [
120
+ {
121
+ species: { name: 'charmeleon', url: 'https://pokeapi.co/api/v2/pokemon-species/5/' },
122
+ evolution_details: [{ trigger: { name: 'level-up' }, min_level: 16 }],
123
+ evolves_to: [
124
+ {
125
+ species: { name: 'charizard', url: 'https://pokeapi.co/api/v2/pokemon-species/6/' },
126
+ evolution_details: [{ trigger: { name: 'level-up' }, min_level: 36 }],
127
+ evolves_to: [],
128
+ },
129
+ ],
130
+ },
131
+ ],
132
+ },
133
+ });
134
+
135
+ setCache(cache, 'evolution-chain:10', {
136
+ chain: {
137
+ species: { name: 'pikachu', url: 'https://pokeapi.co/api/v2/pokemon-species/25/' },
138
+ evolves_to: [
139
+ {
140
+ species: { name: 'raichu', url: 'https://pokeapi.co/api/v2/pokemon-species/26/' },
141
+ evolution_details: [{ trigger: { name: 'use-item' }, item: { name: 'thunder-stone' } }],
142
+ evolves_to: [],
143
+ },
144
+ ],
145
+ },
146
+ });
147
+ };
148
+
149
+ const seedCoreTypeAndMoveData = (cache) => {
150
+ setCache(cache, 'type:grass', buildTypeData({ pokemonIds: [1] }));
151
+ setCache(cache, 'type:electric', buildTypeData({ pokemonIds: [25] }));
152
+ setCache(cache, 'type:normal', buildTypeData());
153
+ setCache(cache, 'type:fire', buildTypeData());
154
+ setCache(cache, 'type:water', buildTypeData());
155
+ setCache(cache, 'type:flying', buildTypeData());
156
+ setCache(cache, 'type:dark', buildTypeData());
157
+
158
+ for (const moveName of PREFERRED_MOVE_NAMES) {
159
+ setCache(cache, `move:${moveName}`, buildMoveData(moveName, 'normal', moveName === 'struggle' ? 50 : 40));
160
+ }
161
+
162
+ for (let id = 1; id <= 25; id += 1) {
163
+ setCache(cache, `nature:${id}`, {
164
+ id,
165
+ name: `nature-${id}`,
166
+ increased_stat: null,
167
+ decreased_stat: null,
168
+ });
169
+ }
170
+ };
171
+
172
+ const withRandomSequence = async (values, fn) => {
173
+ const originalRandom = Math.random;
174
+ let index = 0;
175
+
176
+ Math.random = () => {
177
+ if (!values.length) return 0.5;
178
+ const value = values[Math.min(index, values.length - 1)];
179
+ index += 1;
180
+ return value;
181
+ };
182
+
183
+ try {
184
+ return await fn();
185
+ } finally {
186
+ Math.random = originalRandom;
187
+ }
188
+ };
189
+
190
+ const buildMoveSnapshot = ({ name = 'tackle', displayName = 'Tackle', power = 40, accuracy = 100, damageClass = 'physical', type = 'normal', effectMeta = {} } = {}) => ({
191
+ name,
192
+ displayName,
193
+ power,
194
+ accuracy,
195
+ damageClass,
196
+ type,
197
+ typeDamage: { doubleTo: [], halfTo: [], noTo: [] },
198
+ effectMeta: {
199
+ target: effectMeta.target || 'selected-pokemon',
200
+ statChanges: Array.isArray(effectMeta.statChanges) ? effectMeta.statChanges : [],
201
+ statChance: Number.isFinite(Number(effectMeta.statChance)) ? Number(effectMeta.statChance) : 100,
202
+ ailment: effectMeta.ailment || null,
203
+ ailmentChance: Number.isFinite(Number(effectMeta.ailmentChance)) ? Number(effectMeta.ailmentChance) : 0,
204
+ healing: Number.isFinite(Number(effectMeta.healing)) ? Number(effectMeta.healing) : 0,
205
+ drain: Number.isFinite(Number(effectMeta.drain)) ? Number(effectMeta.drain) : 0,
206
+ },
207
+ });
208
+
209
+ test('fluxo crítico: explorar -> batalha -> capturar deve ser executável com snapshot válido', { concurrency: false }, async () => {
210
+ const cache = ensurePokeApiCache();
211
+ cache.clear();
212
+ seedCoreTypeAndMoveData(cache);
213
+ seedCorePokemonData(cache);
214
+
215
+ const encounter = await withRandomSequence([0.4, 0.8, 0.0, 0.0], async () =>
216
+ createWildEncounter({
217
+ playerLevel: 8,
218
+ preferredTypes: ['grass'],
219
+ }),
220
+ );
221
+
222
+ assert.equal(encounter.enemySnapshot.pokeId, 1);
223
+ assert.equal(encounter.enemySnapshot.isShiny, false);
224
+
225
+ const battleSnapshot = {
226
+ my: {
227
+ displayName: 'Pikachu',
228
+ level: 12,
229
+ currentHp: 55,
230
+ maxHp: 55,
231
+ types: ['electric'],
232
+ stats: {
233
+ attack: 80,
234
+ defense: 60,
235
+ specialAttack: 80,
236
+ specialDefense: 60,
237
+ speed: 110,
238
+ },
239
+ moves: [
240
+ {
241
+ displayName: 'Tackle',
242
+ name: 'tackle',
243
+ power: 40,
244
+ accuracy: 100,
245
+ damageClass: 'physical',
246
+ type: 'normal',
247
+ typeDamage: { doubleTo: [], halfTo: [], noTo: [] },
248
+ },
249
+ {
250
+ displayName: 'Tackle',
251
+ name: 'tackle',
252
+ power: 40,
253
+ accuracy: 100,
254
+ damageClass: 'physical',
255
+ type: 'normal',
256
+ typeDamage: { doubleTo: [], halfTo: [], noTo: [] },
257
+ },
258
+ {
259
+ displayName: 'Tackle',
260
+ name: 'tackle',
261
+ power: 40,
262
+ accuracy: 100,
263
+ damageClass: 'physical',
264
+ type: 'normal',
265
+ typeDamage: { doubleTo: [], halfTo: [], noTo: [] },
266
+ },
267
+ {
268
+ displayName: 'Tackle',
269
+ name: 'tackle',
270
+ power: 40,
271
+ accuracy: 100,
272
+ damageClass: 'physical',
273
+ type: 'normal',
274
+ typeDamage: { doubleTo: [], halfTo: [], noTo: [] },
275
+ },
276
+ ],
277
+ },
278
+ enemy: encounter.enemySnapshot,
279
+ };
280
+
281
+ const turnResult = await withRandomSequence([0.0, 0.0, 0.0, 0.0], async () =>
282
+ resolveBattleTurn({
283
+ battleSnapshot,
284
+ playerMoveSlot: 1,
285
+ }),
286
+ );
287
+
288
+ assert.equal(turnResult.validTurn, true);
289
+ assert.ok(turnResult.logs.length > 0);
290
+
291
+ turnResult.snapshot.enemy.currentHp = 1;
292
+ turnResult.snapshot.my.currentHp = Math.max(1, turnResult.snapshot.my.currentHp);
293
+
294
+ const captureResult = await withRandomSequence([0.0], async () =>
295
+ resolveCaptureAttempt({
296
+ battleSnapshot: turnResult.snapshot,
297
+ }),
298
+ );
299
+
300
+ assert.equal(captureResult.validAction, true);
301
+ assert.equal(captureResult.success, true);
302
+ assert.equal(captureResult.winner, 'player');
303
+ });
304
+
305
+ test('deve marcar encontro como shiny e usar sprite shiny quando o roll atender a chance', { concurrency: false }, async () => {
306
+ const cache = ensurePokeApiCache();
307
+ cache.clear();
308
+ seedCoreTypeAndMoveData(cache);
309
+ seedCorePokemonData(cache);
310
+
311
+ const encounter = await withRandomSequence([0.4, 0.0, 0.0, 0.0], async () =>
312
+ createWildEncounter({
313
+ playerLevel: 10,
314
+ preferredTypes: ['grass'],
315
+ }),
316
+ );
317
+
318
+ assert.equal(encounter.isShiny, true);
319
+ assert.equal(encounter.enemySnapshot.isShiny, true);
320
+ assert.equal(encounter.enemySnapshot.imageUrl, 'https://img.local/1-shiny.png');
321
+ });
322
+
323
+ test('bioma deve priorizar spawn por tipo preferencial', { concurrency: false }, async () => {
324
+ const cache = ensurePokeApiCache();
325
+ cache.clear();
326
+ seedCoreTypeAndMoveData(cache);
327
+ seedCorePokemonData(cache);
328
+
329
+ const encounter = await withRandomSequence([0.6, 0.9, 0.0, 0.0], async () =>
330
+ createWildEncounter({
331
+ playerLevel: 10,
332
+ preferredTypes: ['electric'],
333
+ }),
334
+ );
335
+
336
+ assert.equal(encounter.enemySnapshot.pokeId, 25);
337
+ assert.ok(encounter.enemySnapshot.types.includes('electric'));
338
+ });
339
+
340
+ test('moveset inicial deve garantir STAB e limitar normal quando houver alternativas', { concurrency: false }, async () => {
341
+ const cache = ensurePokeApiCache();
342
+ cache.clear();
343
+ seedCoreTypeAndMoveData(cache);
344
+
345
+ setCache(
346
+ cache,
347
+ 'pokemon:1',
348
+ buildPokemonData({
349
+ id: 1,
350
+ name: 'bulbasaur',
351
+ primaryType: 'grass',
352
+ moves: ['tackle', 'scratch', 'vine-whip', 'bite', 'growl', 'struggle'],
353
+ }),
354
+ );
355
+ setCache(cache, 'species:1', {
356
+ id: 1,
357
+ capture_rate: 45,
358
+ evolution_chain: { url: 'https://pokeapi.co/api/v2/evolution-chain/1/' },
359
+ });
360
+
361
+ setCache(cache, 'type:grass', buildTypeData({ pokemonIds: [1] }));
362
+ setCache(cache, 'type:normal', buildTypeData({ noDamageTo: ['ghost'] }));
363
+ setCache(cache, 'type:dark', buildTypeData());
364
+
365
+ setCache(cache, 'move:tackle', buildMoveData('tackle', 'normal', 40));
366
+ setCache(cache, 'move:scratch', buildMoveData('scratch', 'normal', 40));
367
+ setCache(cache, 'move:vine-whip', buildMoveData('vine-whip', 'grass', 45));
368
+ setCache(cache, 'move:bite', buildMoveData('bite', 'dark', 60));
369
+ setCache(cache, 'move:growl', buildMoveData('growl', 'normal', 0));
370
+ setCache(cache, 'move:struggle', buildMoveData('struggle', 'normal', 50));
371
+
372
+ const encounter = await withRandomSequence([0.8, 0.4, 0.2, 0.1], async () =>
373
+ createWildEncounter({
374
+ playerLevel: 10,
375
+ preferredTypes: ['grass'],
376
+ }),
377
+ );
378
+
379
+ const offensiveMoves = encounter.enemySnapshot.moves.filter((move) => Number(move?.power) > 0 && move?.damageClass !== 'status');
380
+ const hasStab = offensiveMoves.some((move) => String(move?.type || '').toLowerCase() === 'grass');
381
+ const normalCount = offensiveMoves.filter((move) => String(move?.type || '').toLowerCase() === 'normal').length;
382
+
383
+ assert.equal(hasStab, true);
384
+ assert.ok(normalCount <= 1);
385
+ });
386
+
387
+ test('moveset inicial deve evitar softlock por imunidade com fallback neutro', { concurrency: false }, async () => {
388
+ const cache = ensurePokeApiCache();
389
+ cache.clear();
390
+ seedCoreTypeAndMoveData(cache);
391
+
392
+ setCache(
393
+ cache,
394
+ 'pokemon:66',
395
+ buildPokemonData({
396
+ id: 66,
397
+ name: 'machop',
398
+ primaryType: 'fighting',
399
+ moves: ['karate-chop', 'tackle', 'leer', 'struggle'],
400
+ }),
401
+ );
402
+ setCache(cache, 'species:66', {
403
+ id: 66,
404
+ capture_rate: 180,
405
+ evolution_chain: { url: 'https://pokeapi.co/api/v2/evolution-chain/23/' },
406
+ });
407
+
408
+ setCache(cache, 'type:fighting', buildTypeData({ pokemonIds: [66], noDamageTo: ['ghost'] }));
409
+ setCache(cache, 'type:normal', buildTypeData({ noDamageTo: ['ghost'] }));
410
+
411
+ setCache(cache, 'move:karate-chop', buildMoveData('karate-chop', 'fighting', 50));
412
+ setCache(cache, 'move:tackle', buildMoveData('tackle', 'normal', 40));
413
+ setCache(cache, 'move:leer', buildMoveData('leer', 'normal', 0));
414
+ setCache(cache, 'move:struggle', buildMoveData('struggle', 'normal', 50));
415
+
416
+ const encounter = await withRandomSequence([0.8, 0.4, 0.2, 0.1], async () =>
417
+ createWildEncounter({
418
+ playerLevel: 14,
419
+ preferredTypes: ['fighting'],
420
+ }),
421
+ );
422
+
423
+ const offensiveMoves = encounter.enemySnapshot.moves.filter((move) => Number(move?.power) > 0 && move?.damageClass !== 'status');
424
+ const canHitGhost = offensiveMoves.some((move) => !(move?.typeDamage?.noTo || []).includes('ghost'));
425
+ const hasNeutralFallback = encounter.enemySnapshot.moves.some((move) => String(move?.name || '').startsWith('neutral-strike'));
426
+
427
+ assert.equal(canHitGhost, true);
428
+ assert.equal(hasNeutralFallback, true);
429
+ });
430
+
431
+ test('evolução automática por nível deve seguir evolution-chain da PokéAPI', { concurrency: false }, async () => {
432
+ const cache = ensurePokeApiCache();
433
+ cache.clear();
434
+ seedCoreTypeAndMoveData(cache);
435
+ seedCorePokemonData(cache);
436
+
437
+ const atLevel16 = await resolveEvolutionByLevel({
438
+ pokeId: 4,
439
+ level: 16,
440
+ });
441
+ assert.equal(atLevel16?.from?.pokeId, 4);
442
+ assert.equal(atLevel16?.to?.pokeId, 5);
443
+ assert.equal(atLevel16?.to?.name, 'Charmeleon');
444
+
445
+ const atLevel40 = await resolveEvolutionByLevel({
446
+ pokeId: 4,
447
+ level: 40,
448
+ });
449
+ assert.equal(atLevel40?.to?.pokeId, 6);
450
+ assert.equal(atLevel40?.to?.name, 'Charizard');
451
+ });
452
+
453
+ test('evolução por nível deve retornar nulo quando ainda não atingiu requisito', { concurrency: false }, async () => {
454
+ const cache = ensurePokeApiCache();
455
+ cache.clear();
456
+ seedCoreTypeAndMoveData(cache);
457
+ seedCorePokemonData(cache);
458
+
459
+ const noEvolution = await resolveEvolutionByLevel({
460
+ pokeId: 1,
461
+ level: 15,
462
+ });
463
+ assert.equal(noEvolution, null);
464
+ });
465
+
466
+ test('evolução por item deve falhar para item inválido', { concurrency: false }, async () => {
467
+ const cache = ensurePokeApiCache();
468
+ cache.clear();
469
+ seedCoreTypeAndMoveData(cache);
470
+ seedCorePokemonData(cache);
471
+
472
+ const invalidItem = await resolveEvolutionByItem({
473
+ pokeId: 25,
474
+ itemKey: 'water-stone',
475
+ });
476
+ assert.equal(invalidItem, null);
477
+ });
478
+
479
+ test('evolução por item válida deve resolver próximo estágio', { concurrency: false }, async () => {
480
+ const cache = ensurePokeApiCache();
481
+ cache.clear();
482
+ seedCoreTypeAndMoveData(cache);
483
+ seedCorePokemonData(cache);
484
+
485
+ const validItem = await resolveEvolutionByItem({
486
+ pokeId: 25,
487
+ itemKey: 'thunder-stone',
488
+ });
489
+ assert.equal(validItem?.from?.pokeId, 25);
490
+ assert.equal(validItem?.to?.pokeId, 26);
491
+ assert.equal(validItem?.to?.name, 'Raichu');
492
+ });
493
+
494
+ test('ataque único deve aplicar dano e reduzir HP do alvo', () => {
495
+ const attacker = {
496
+ displayName: 'Pikachu',
497
+ level: 15,
498
+ types: ['electric'],
499
+ stats: {
500
+ attack: 70,
501
+ defense: 55,
502
+ specialAttack: 65,
503
+ specialDefense: 55,
504
+ speed: 90,
505
+ },
506
+ moves: [
507
+ {
508
+ displayName: 'Tackle',
509
+ name: 'tackle',
510
+ power: 40,
511
+ accuracy: 100,
512
+ damageClass: 'physical',
513
+ type: 'normal',
514
+ typeDamage: { doubleTo: [], halfTo: [], noTo: [] },
515
+ },
516
+ ],
517
+ };
518
+ const defender = {
519
+ displayName: 'Bulbasaur',
520
+ level: 12,
521
+ currentHp: 80,
522
+ maxHp: 80,
523
+ types: ['grass'],
524
+ stats: {
525
+ attack: 55,
526
+ defense: 60,
527
+ specialAttack: 55,
528
+ specialDefense: 60,
529
+ speed: 50,
530
+ },
531
+ moves: [],
532
+ };
533
+
534
+ const result = resolveSingleAttack({
535
+ attackerSnapshot: attacker,
536
+ defenderSnapshot: defender,
537
+ moveSlot: 1,
538
+ attackerLabel: 'Pikachu',
539
+ defenderLabel: 'Bulbasaur',
540
+ });
541
+
542
+ assert.equal(result.validMove, true);
543
+ assert.ok(result.damage >= 1);
544
+ assert.ok(result.defender.currentHp < 80);
545
+ });
546
+
547
+ test('paralisia deve poder impedir ação no turno', async () => {
548
+ const caster = {
549
+ displayName: 'Pikachu',
550
+ level: 20,
551
+ currentHp: 95,
552
+ maxHp: 95,
553
+ types: ['electric'],
554
+ stats: {
555
+ attack: 70,
556
+ defense: 55,
557
+ specialAttack: 80,
558
+ specialDefense: 60,
559
+ speed: 95,
560
+ },
561
+ moves: [
562
+ buildMoveSnapshot({
563
+ name: 'thunder-wave',
564
+ displayName: 'Thunder Wave',
565
+ power: 0,
566
+ damageClass: 'status',
567
+ type: 'electric',
568
+ effectMeta: {
569
+ target: 'selected-pokemon',
570
+ ailment: 'paralysis',
571
+ ailmentChance: 100,
572
+ },
573
+ }),
574
+ ],
575
+ };
576
+
577
+ const target = {
578
+ displayName: 'Eevee',
579
+ level: 18,
580
+ currentHp: 100,
581
+ maxHp: 100,
582
+ types: ['normal'],
583
+ stats: {
584
+ attack: 65,
585
+ defense: 60,
586
+ specialAttack: 45,
587
+ specialDefense: 65,
588
+ speed: 55,
589
+ },
590
+ moves: [buildMoveSnapshot()],
591
+ };
592
+
593
+ const setup = await withRandomSequence([0.0], async () =>
594
+ resolveSingleAttack({
595
+ attackerSnapshot: caster,
596
+ defenderSnapshot: target,
597
+ moveSlot: 1,
598
+ attackerLabel: 'Pikachu',
599
+ defenderLabel: 'Eevee',
600
+ }),
601
+ );
602
+
603
+ assert.equal(setup.defender.nonVolatileStatus, 'paralysis');
604
+ assert.ok(setup.logs.some((line) => String(line).includes('paralisado')));
605
+
606
+ const blocked = await withRandomSequence([0.0], async () =>
607
+ resolveSingleAttack({
608
+ attackerSnapshot: setup.defender,
609
+ defenderSnapshot: setup.attacker,
610
+ moveSlot: 1,
611
+ attackerLabel: 'Eevee',
612
+ defenderLabel: 'Pikachu',
613
+ }),
614
+ );
615
+
616
+ assert.equal(blocked.validMove, true);
617
+ assert.equal(blocked.damage, 0);
618
+ assert.ok(blocked.logs.some((line) => String(line).includes('não conseguiu agir')));
619
+ });
620
+
621
+ test('debuff de ataque deve reduzir dano físico no turno seguinte', async () => {
622
+ const attackerBase = {
623
+ displayName: 'GrowlMon',
624
+ level: 22,
625
+ currentHp: 120,
626
+ maxHp: 120,
627
+ types: ['normal'],
628
+ stats: {
629
+ attack: 70,
630
+ defense: 70,
631
+ specialAttack: 60,
632
+ specialDefense: 65,
633
+ speed: 75,
634
+ },
635
+ moves: [
636
+ buildMoveSnapshot({
637
+ name: 'growl',
638
+ displayName: 'Growl',
639
+ power: 0,
640
+ damageClass: 'status',
641
+ effectMeta: {
642
+ target: 'selected-pokemon',
643
+ statChanges: [{ stat: 'attack', change: -1 }],
644
+ statChance: 100,
645
+ },
646
+ }),
647
+ ],
648
+ };
649
+
650
+ const defenderBase = {
651
+ displayName: 'BiteMon',
652
+ level: 22,
653
+ currentHp: 120,
654
+ maxHp: 120,
655
+ types: ['normal'],
656
+ stats: {
657
+ attack: 85,
658
+ defense: 68,
659
+ specialAttack: 50,
660
+ specialDefense: 60,
661
+ speed: 72,
662
+ },
663
+ moves: [buildMoveSnapshot()],
664
+ };
665
+
666
+ const baseline = await withRandomSequence([0.0, 0.0], async () =>
667
+ resolveSingleAttack({
668
+ attackerSnapshot: defenderBase,
669
+ defenderSnapshot: attackerBase,
670
+ moveSlot: 1,
671
+ attackerLabel: 'BiteMon',
672
+ defenderLabel: 'GrowlMon',
673
+ }),
674
+ );
675
+
676
+ const debuffStep = await withRandomSequence([0.0], async () =>
677
+ resolveSingleAttack({
678
+ attackerSnapshot: attackerBase,
679
+ defenderSnapshot: defenderBase,
680
+ moveSlot: 1,
681
+ attackerLabel: 'GrowlMon',
682
+ defenderLabel: 'BiteMon',
683
+ }),
684
+ );
685
+
686
+ assert.ok(debuffStep.logs.some((line) => String(line).includes('reduziu Ataque')));
687
+
688
+ const afterDebuff = await withRandomSequence([0.0, 0.0], async () =>
689
+ resolveSingleAttack({
690
+ attackerSnapshot: debuffStep.defender,
691
+ defenderSnapshot: debuffStep.attacker,
692
+ moveSlot: 1,
693
+ attackerLabel: 'BiteMon',
694
+ defenderLabel: 'GrowlMon',
695
+ }),
696
+ );
697
+
698
+ assert.ok(afterDebuff.damage < baseline.damage);
699
+ });
700
+
701
+ test('queimadura deve causar dano residual ao final do turno', async () => {
702
+ const caster = {
703
+ displayName: 'Flareon',
704
+ level: 24,
705
+ currentHp: 130,
706
+ maxHp: 130,
707
+ types: ['fire'],
708
+ stats: {
709
+ attack: 95,
710
+ defense: 70,
711
+ specialAttack: 85,
712
+ specialDefense: 80,
713
+ speed: 70,
714
+ },
715
+ moves: [
716
+ buildMoveSnapshot({
717
+ name: 'will-o-wisp',
718
+ displayName: 'Will-O-Wisp',
719
+ power: 0,
720
+ damageClass: 'status',
721
+ type: 'fire',
722
+ effectMeta: {
723
+ target: 'selected-pokemon',
724
+ ailment: 'burn',
725
+ ailmentChance: 100,
726
+ },
727
+ }),
728
+ ],
729
+ };
730
+
731
+ const target = {
732
+ displayName: 'Snorlax',
733
+ level: 24,
734
+ currentHp: 160,
735
+ maxHp: 160,
736
+ types: ['normal'],
737
+ stats: {
738
+ attack: 90,
739
+ defense: 75,
740
+ specialAttack: 65,
741
+ specialDefense: 85,
742
+ speed: 30,
743
+ },
744
+ moves: [buildMoveSnapshot()],
745
+ };
746
+
747
+ const result = await withRandomSequence([0.0], async () =>
748
+ resolveSingleAttack({
749
+ attackerSnapshot: caster,
750
+ defenderSnapshot: target,
751
+ moveSlot: 1,
752
+ attackerLabel: 'Flareon',
753
+ defenderLabel: 'Snorlax',
754
+ }),
755
+ );
756
+
757
+ assert.equal(result.defender.nonVolatileStatus, 'burn');
758
+ assert.ok(result.defender.currentHp < target.maxHp);
759
+ assert.ok(result.logs.some((line) => String(line).includes('dano por queimadura')));
760
+ });