@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,786 @@
1
+ import axios from 'axios';
2
+ import { createCanvas, loadImage } from 'canvas';
3
+ import logger from '../../utils/logger/loggerModule.js';
4
+
5
+ const CANVAS_SIZE = 1024;
6
+ const PANEL_RADIUS = 24;
7
+ const IMAGE_CACHE_TTL_MS = Math.max(2 * 60 * 1000, Number(process.env.RPG_BATTLE_CANVAS_CACHE_TTL_MS) || 10 * 60 * 1000);
8
+ const IMAGE_CACHE_LIMIT = Math.max(20, Number(process.env.RPG_BATTLE_CANVAS_CACHE_LIMIT) || 120);
9
+ const IMAGE_TIMEOUT_MS = Math.max(2_000, Number(process.env.RPG_BATTLE_CANVAS_TIMEOUT_MS) || 7_000);
10
+
11
+ const imageCache = globalThis.__omnizapBattleCanvasImageCache instanceof Map ? globalThis.__omnizapBattleCanvasImageCache : new Map();
12
+ globalThis.__omnizapBattleCanvasImageCache = imageCache;
13
+
14
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
15
+ const toInt = (value, fallback = 0) => {
16
+ const parsed = Number(value);
17
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
18
+ };
19
+ const normalizeText = (value) =>
20
+ String(value || '')
21
+ .toLowerCase()
22
+ .normalize('NFD')
23
+ .replace(/[\u0300-\u036f]/g, '');
24
+
25
+ const hexToRgb = (hex) => {
26
+ const raw = String(hex || '')
27
+ .trim()
28
+ .replace('#', '');
29
+ if (!/^[a-f0-9]{6}$/i.test(raw)) return null;
30
+ return {
31
+ r: Number.parseInt(raw.slice(0, 2), 16),
32
+ g: Number.parseInt(raw.slice(2, 4), 16),
33
+ b: Number.parseInt(raw.slice(4, 6), 16),
34
+ };
35
+ };
36
+
37
+ const toRgba = (hex, alpha = 1) => {
38
+ const rgb = hexToRgb(hex);
39
+ if (!rgb) return `rgba(255,255,255,${clamp(alpha, 0, 1)})`;
40
+ return `rgba(${rgb.r},${rgb.g},${rgb.b},${clamp(alpha, 0, 1)})`;
41
+ };
42
+
43
+ const isLightHex = (hex) => {
44
+ const rgb = hexToRgb(hex);
45
+ if (!rgb) return false;
46
+ const luminance = rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114;
47
+ return luminance >= 160;
48
+ };
49
+
50
+ const toRatio = (currentHp, maxHp) => {
51
+ const max = Math.max(1, toInt(maxHp, 1));
52
+ const current = clamp(toInt(currentHp, 0), 0, max);
53
+ return current / max;
54
+ };
55
+
56
+ const hpColorByRatio = (ratio) => {
57
+ if (ratio <= 0.25) return '#ef4444';
58
+ if (ratio <= 0.55) return '#f59e0b';
59
+ return '#22c55e';
60
+ };
61
+
62
+ const TYPE_COLORS = new Map([
63
+ ['normal', '#a8a77a'],
64
+ ['fire', '#ee8130'],
65
+ ['water', '#6390f0'],
66
+ ['electric', '#f7d02c'],
67
+ ['grass', '#7ac74c'],
68
+ ['ice', '#96d9d6'],
69
+ ['fighting', '#c22e28'],
70
+ ['poison', '#a33ea1'],
71
+ ['ground', '#e2bf65'],
72
+ ['flying', '#a98ff3'],
73
+ ['psychic', '#f95587'],
74
+ ['bug', '#a6b91a'],
75
+ ['rock', '#b6a136'],
76
+ ['ghost', '#735797'],
77
+ ['dragon', '#6f35fc'],
78
+ ['dark', '#705746'],
79
+ ['steel', '#b7b7ce'],
80
+ ['fairy', '#d685ad'],
81
+ ]);
82
+
83
+ const STATUS_MAP = new Map([
84
+ ['burn', { icon: '🔥', label: 'BRN', color: '#f97316' }],
85
+ ['brn', { icon: '🔥', label: 'BRN', color: '#f97316' }],
86
+ ['poison', { icon: '☠', label: 'PSN', color: '#a855f7' }],
87
+ ['psn', { icon: '☠', label: 'PSN', color: '#a855f7' }],
88
+ ['toxic', { icon: '☠', label: 'TOX', color: '#7c3aed' }],
89
+ ['bad-poison', { icon: '☠', label: 'TOX', color: '#7c3aed' }],
90
+ ['paralyze', { icon: '⚡', label: 'PAR', color: '#facc15' }],
91
+ ['paralysis', { icon: '⚡', label: 'PAR', color: '#facc15' }],
92
+ ['par', { icon: '⚡', label: 'PAR', color: '#facc15' }],
93
+ ['sleep', { icon: '💤', label: 'SLP', color: '#60a5fa' }],
94
+ ['slp', { icon: '💤', label: 'SLP', color: '#60a5fa' }],
95
+ ['freeze', { icon: '❄️', label: 'FRZ', color: '#67e8f9' }],
96
+ ['frz', { icon: '❄️', label: 'FRZ', color: '#67e8f9' }],
97
+ ['confusion', { icon: '🌀', label: 'CNF', color: '#fbbf24' }],
98
+ ['conf', { icon: '🌀', label: 'CNF', color: '#fbbf24' }],
99
+ ]);
100
+
101
+ const TYPE_ICONS = new Map([
102
+ ['normal', '⚪'],
103
+ ['fire', '🔥'],
104
+ ['water', '💧'],
105
+ ['electric', '⚡'],
106
+ ['grass', '🍃'],
107
+ ['ice', '❄️'],
108
+ ['fighting', '🥊'],
109
+ ['poison', '☠️'],
110
+ ['ground', '🟤'],
111
+ ['flying', '🪽'],
112
+ ['psychic', '🔮'],
113
+ ['bug', '🐞'],
114
+ ['rock', '🪨'],
115
+ ['ghost', '👻'],
116
+ ['dragon', '🐉'],
117
+ ['dark', '🌑'],
118
+ ['steel', '⚙️'],
119
+ ['fairy', '✨'],
120
+ ]);
121
+
122
+ const ROLE_THEMES = {
123
+ player: {
124
+ icon: '👤',
125
+ label: 'JOGADOR',
126
+ accent: '#38bdf8',
127
+ panelBg: 'rgba(8,47,73,0.45)',
128
+ },
129
+ enemy: {
130
+ icon: '⚠️',
131
+ label: 'INIMIGO',
132
+ accent: '#fb7185',
133
+ panelBg: 'rgba(76,5,25,0.42)',
134
+ },
135
+ };
136
+
137
+ const BIOME_THEMES = [
138
+ { match: ['forest', 'floresta', 'grass', 'jungle', 'leaf'], colors: ['#134e4a', '#166534', '#0f766e'] },
139
+ { match: ['volcano', 'fire', 'magma', 'lava'], colors: ['#7f1d1d', '#b91c1c', '#f97316'] },
140
+ { match: ['water', 'ocean', 'sea', 'lake', 'river'], colors: ['#082f49', '#0c4a6e', '#0369a1'] },
141
+ { match: ['mountain', 'rock', 'cave'], colors: ['#292524', '#44403c', '#57534e'] },
142
+ { match: ['desert', 'sand', 'ground'], colors: ['#78350f', '#92400e', '#b45309'] },
143
+ { match: ['ice', 'snow', 'tundra'], colors: ['#0f172a', '#1d4ed8', '#22d3ee'] },
144
+ ];
145
+
146
+ const trimText = (value, max = 120) => {
147
+ const raw = String(value || '')
148
+ .replace(/\s+/g, ' ')
149
+ .trim();
150
+ if (!raw) return '';
151
+ if (raw.length <= max) return raw;
152
+ return `${raw.slice(0, Math.max(24, max - 1)).trimEnd()}…`;
153
+ };
154
+
155
+ const fitText = (ctx, text, maxWidth, baseSize, weight = 700, family = 'Sans') => {
156
+ let size = baseSize;
157
+ while (size > 14) {
158
+ ctx.font = `${weight} ${size}px ${family}`;
159
+ if (ctx.measureText(text).width <= maxWidth) return;
160
+ size -= 1;
161
+ }
162
+ };
163
+
164
+ const drawRoundRect = (ctx, x, y, width, height, radius, fillStyle) => {
165
+ ctx.beginPath();
166
+ ctx.moveTo(x + radius, y);
167
+ ctx.lineTo(x + width - radius, y);
168
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
169
+ ctx.lineTo(x + width, y + height - radius);
170
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
171
+ ctx.lineTo(x + radius, y + height);
172
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
173
+ ctx.lineTo(x, y + radius);
174
+ ctx.quadraticCurveTo(x, y, x + radius, y);
175
+ ctx.closePath();
176
+ if (fillStyle) {
177
+ ctx.fillStyle = fillStyle;
178
+ ctx.fill();
179
+ }
180
+ };
181
+
182
+ const cleanupCache = () => {
183
+ const now = Date.now();
184
+ for (const [key, entry] of imageCache.entries()) {
185
+ if (!entry || entry.expiresAt <= now) {
186
+ imageCache.delete(key);
187
+ }
188
+ }
189
+
190
+ if (imageCache.size <= IMAGE_CACHE_LIMIT) return;
191
+ const overflow = imageCache.size - IMAGE_CACHE_LIMIT;
192
+ const keys = [...imageCache.keys()].slice(0, overflow);
193
+ keys.forEach((key) => imageCache.delete(key));
194
+ };
195
+
196
+ const resolveImage = async (imageUrl) => {
197
+ const url = String(imageUrl || '').trim();
198
+ if (!url || !/^https?:\/\//i.test(url)) return null;
199
+
200
+ cleanupCache();
201
+ const cached = imageCache.get(url);
202
+ if (cached && cached.expiresAt > Date.now()) return cached.image;
203
+
204
+ try {
205
+ const response = await axios.get(url, {
206
+ responseType: 'arraybuffer',
207
+ timeout: IMAGE_TIMEOUT_MS,
208
+ headers: { Accept: 'image/*' },
209
+ });
210
+ const image = await loadImage(Buffer.from(response.data));
211
+ imageCache.set(url, { image, expiresAt: Date.now() + IMAGE_CACHE_TTL_MS });
212
+ return image;
213
+ } catch (error) {
214
+ imageCache.set(url, { image: null, expiresAt: Date.now() + 90_000 });
215
+ logger.debug('Falha ao carregar sprite para frame de batalha.', {
216
+ imageUrl: url,
217
+ error: error.message,
218
+ });
219
+ return null;
220
+ }
221
+ };
222
+
223
+ const resolveBiomeTheme = (biomeLabel) => {
224
+ const normalized = String(biomeLabel || '')
225
+ .trim()
226
+ .toLowerCase();
227
+ if (!normalized) return ['#0f172a', '#1f2937', '#334155'];
228
+ for (const theme of BIOME_THEMES) {
229
+ if (theme.match.some((entry) => normalized.includes(entry))) return theme.colors;
230
+ }
231
+ return ['#1d4ed8', '#1e3a8a', '#334155'];
232
+ };
233
+
234
+ const normalizeTypeList = (types) => {
235
+ if (!Array.isArray(types)) return [];
236
+ return types
237
+ .map((entry) =>
238
+ String(entry || '')
239
+ .trim()
240
+ .toLowerCase(),
241
+ )
242
+ .filter(Boolean)
243
+ .slice(0, 3);
244
+ };
245
+
246
+ const normalizeStatuses = (pokemon = {}) => {
247
+ const candidates = [pokemon?.status, pokemon?.nonVolatileStatus, pokemon?.condition, pokemon?.statusCondition, ...(Array.isArray(pokemon?.statusEffects) ? pokemon.statusEffects : []), ...(Array.isArray(pokemon?.conditions) ? pokemon.conditions : []), ...(Array.isArray(pokemon?.statuses) ? pokemon.statuses : [])];
248
+ const found = [];
249
+ for (const candidate of candidates) {
250
+ const key = String(candidate || '')
251
+ .trim()
252
+ .toLowerCase();
253
+ if (!key) continue;
254
+ const normalized = STATUS_MAP.get(key);
255
+ if (!normalized) continue;
256
+ if (!found.some((entry) => entry.label === normalized.label)) {
257
+ found.push(normalized);
258
+ }
259
+ }
260
+ return found.slice(0, 3);
261
+ };
262
+
263
+ const drawBackground = (ctx, biomeLabel, enemyTypes = []) => {
264
+ const [c1, c2, c3] = resolveBiomeTheme(biomeLabel);
265
+ const gradient = ctx.createLinearGradient(0, 0, CANVAS_SIZE, CANVAS_SIZE);
266
+ gradient.addColorStop(0, c1);
267
+ gradient.addColorStop(0.52, c2);
268
+ gradient.addColorStop(1, c3);
269
+ ctx.fillStyle = gradient;
270
+ ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
271
+
272
+ const enemyType = normalizeTypeList(enemyTypes)[0];
273
+ const enemyTypeColor = TYPE_COLORS.get(enemyType);
274
+ if (enemyTypeColor) {
275
+ const typeAura = ctx.createRadialGradient(CANVAS_SIZE * 0.74, CANVAS_SIZE * 0.3, 50, CANVAS_SIZE * 0.74, CANVAS_SIZE * 0.3, 360);
276
+ typeAura.addColorStop(0, toRgba(enemyTypeColor, 0.35));
277
+ typeAura.addColorStop(1, 'rgba(255,255,255,0)');
278
+ ctx.fillStyle = typeAura;
279
+ ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
280
+ }
281
+
282
+ ctx.globalAlpha = 0.09;
283
+ ctx.strokeStyle = '#ffffff';
284
+ for (let i = 0; i <= 14; i += 1) {
285
+ const y = 160 + i * 42;
286
+ ctx.beginPath();
287
+ ctx.moveTo(40, y);
288
+ ctx.lineTo(CANVAS_SIZE - 40, y + (i % 2 === 0 ? 10 : -10));
289
+ ctx.stroke();
290
+ }
291
+ ctx.globalAlpha = 1;
292
+
293
+ const vignette = ctx.createRadialGradient(CANVAS_SIZE / 2, CANVAS_SIZE / 2, CANVAS_SIZE * 0.2, CANVAS_SIZE / 2, CANVAS_SIZE / 2, CANVAS_SIZE * 0.65);
294
+ vignette.addColorStop(0, 'rgba(255,255,255,0)');
295
+ vignette.addColorStop(1, 'rgba(0,0,0,0.45)');
296
+ ctx.fillStyle = vignette;
297
+ ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
298
+ };
299
+
300
+ const drawArena = (ctx) => {
301
+ ctx.globalAlpha = 0.22;
302
+ drawRoundRect(ctx, 80, 610, 360, 120, 90, '#cbd5e1');
303
+ drawRoundRect(ctx, CANVAS_SIZE - 440, 270, 360, 120, 90, '#cbd5e1');
304
+ ctx.globalAlpha = 1;
305
+ };
306
+
307
+ const drawShinyAura = (ctx, x, y, width, height) => {
308
+ const gradient = ctx.createRadialGradient(x, y, 28, x, y, Math.max(width, height) * 0.65);
309
+ gradient.addColorStop(0, 'rgba(252,211,77,0.66)');
310
+ gradient.addColorStop(0.4, 'rgba(59,130,246,0.26)');
311
+ gradient.addColorStop(1, 'rgba(255,255,255,0)');
312
+ ctx.fillStyle = gradient;
313
+ ctx.beginPath();
314
+ ctx.ellipse(x, y, width * 0.72, height * 0.6, 0, 0, Math.PI * 2);
315
+ ctx.fill();
316
+ };
317
+
318
+ const drawCombatAura = (ctx, { centerX, centerY, width, height, accent = '#ffffff', alpha = 0.2 }) => {
319
+ const gradient = ctx.createRadialGradient(centerX, centerY + 18, 24, centerX, centerY + 18, Math.max(width, height) * 0.65);
320
+ gradient.addColorStop(0, `${accent}55`);
321
+ gradient.addColorStop(0.5, `${accent}20`);
322
+ gradient.addColorStop(1, 'rgba(255,255,255,0)');
323
+ ctx.globalAlpha = clamp(alpha, 0.08, 0.35);
324
+ ctx.fillStyle = gradient;
325
+ ctx.beginPath();
326
+ ctx.ellipse(centerX, centerY + 18, width * 0.62, height * 0.5, 0, 0, Math.PI * 2);
327
+ ctx.fill();
328
+ ctx.globalAlpha = 1;
329
+ };
330
+
331
+ const drawPokemon = async (ctx, pokemon = {}, opts = {}) => {
332
+ const { centerX = 0, centerY = 0, maxWidth = 260, maxHeight = 260, facing = 'right', isPrimary = false, role = 'player', isActive = false, offsetX = 0, offsetY = 0, turn = 1 } = opts;
333
+ const finalCenterX = centerX + toInt(offsetX, 0);
334
+ const finalCenterY = centerY + toInt(offsetY, 0);
335
+ const image = await resolveImage(pokemon?.imageUrl || pokemon?.sprite);
336
+ const scaleBonus = isPrimary ? 1.08 : 0.96;
337
+ const targetMaxW = maxWidth * scaleBonus;
338
+ const targetMaxH = maxHeight * scaleBonus;
339
+ const isShiny = Boolean(pokemon?.isShiny);
340
+ const roleTheme = ROLE_THEMES[role] || ROLE_THEMES.player;
341
+ const activePulse = isActive ? (toInt(turn, 1) % 2 === 0 ? 0.32 : 0.2) : 0;
342
+
343
+ if (image) {
344
+ const ratio = Math.min(targetMaxW / image.width, targetMaxH / image.height);
345
+ const width = Math.max(40, Math.round(image.width * ratio));
346
+ const height = Math.max(40, Math.round(image.height * ratio));
347
+ const drawX = finalCenterX - width / 2;
348
+ const drawY = finalCenterY - height / 2;
349
+
350
+ drawCombatAura(ctx, {
351
+ centerX: finalCenterX,
352
+ centerY: finalCenterY + (isPrimary ? 12 : -8),
353
+ width,
354
+ height,
355
+ accent: roleTheme.accent,
356
+ alpha: isActive ? 0.3 : isPrimary ? 0.23 : 0.17,
357
+ });
358
+
359
+ if (isShiny) drawShinyAura(ctx, finalCenterX, finalCenterY, width, height);
360
+ if (isActive) {
361
+ ctx.globalAlpha = activePulse;
362
+ ctx.strokeStyle = roleTheme.accent;
363
+ ctx.lineWidth = 5;
364
+ ctx.beginPath();
365
+ ctx.ellipse(finalCenterX, finalCenterY + 14, width * 0.54, height * 0.44, 0, 0, Math.PI * 2);
366
+ ctx.stroke();
367
+ ctx.globalAlpha = 1;
368
+ }
369
+
370
+ ctx.save();
371
+ if (facing === 'left') {
372
+ ctx.translate(finalCenterX, finalCenterY);
373
+ ctx.scale(-1, 1);
374
+ ctx.drawImage(image, -width / 2, -height / 2, width, height);
375
+ } else {
376
+ ctx.drawImage(image, drawX, drawY, width, height);
377
+ }
378
+ ctx.restore();
379
+ return;
380
+ }
381
+
382
+ const fallbackW = Math.round(targetMaxW * 0.75);
383
+ const fallbackH = Math.round(targetMaxH * 0.75);
384
+ drawCombatAura(ctx, {
385
+ centerX: finalCenterX,
386
+ centerY: finalCenterY,
387
+ width: fallbackW,
388
+ height: fallbackH,
389
+ accent: roleTheme.accent,
390
+ alpha: isActive ? 0.28 : isPrimary ? 0.2 : 0.16,
391
+ });
392
+ if (isShiny) drawShinyAura(ctx, finalCenterX, finalCenterY, fallbackW, fallbackH);
393
+ if (isActive) {
394
+ ctx.globalAlpha = activePulse;
395
+ ctx.strokeStyle = roleTheme.accent;
396
+ ctx.lineWidth = 4;
397
+ ctx.beginPath();
398
+ ctx.ellipse(finalCenterX, finalCenterY + 8, fallbackW * 0.48, fallbackH * 0.4, 0, 0, Math.PI * 2);
399
+ ctx.stroke();
400
+ ctx.globalAlpha = 1;
401
+ }
402
+ ctx.fillStyle = 'rgba(255,255,255,0.14)';
403
+ drawRoundRect(ctx, finalCenterX - fallbackW / 2, finalCenterY - fallbackH / 2, fallbackW, fallbackH, 28, 'rgba(255,255,255,0.14)');
404
+ ctx.fillStyle = '#ffffff';
405
+ fitText(ctx, trimText(pokemon?.displayName || pokemon?.name || 'Pokemon', 18), fallbackW - 28, 30, 700);
406
+ ctx.textAlign = 'center';
407
+ ctx.textBaseline = 'middle';
408
+ ctx.fillText(trimText(pokemon?.displayName || pokemon?.name || 'Pokemon', 18), finalCenterX, finalCenterY);
409
+ };
410
+
411
+ const drawTypeBadges = (ctx, types = [], x, y) => {
412
+ let cursor = x;
413
+ types.forEach((type) => {
414
+ const label = String(type || '')
415
+ .slice(0, 3)
416
+ .toUpperCase();
417
+ const icon = TYPE_ICONS.get(type) || '◼';
418
+ const width = 76;
419
+ const color = TYPE_COLORS.get(type) || '#475569';
420
+ const textColor = isLightHex(color) ? '#0b1220' : '#f8fafc';
421
+ const gradient = ctx.createLinearGradient(cursor, y, cursor + width, y + 24);
422
+ gradient.addColorStop(0, toRgba(color, 0.92));
423
+ gradient.addColorStop(1, toRgba(color, 0.72));
424
+ drawRoundRect(ctx, cursor, y, width, 24, 12, gradient);
425
+ ctx.strokeStyle = toRgba(color, 1);
426
+ ctx.lineWidth = 1.5;
427
+ drawRoundRect(ctx, cursor, y, width, 24, 12);
428
+ ctx.stroke();
429
+ ctx.fillStyle = textColor;
430
+ ctx.font = '700 13px Sans';
431
+ ctx.textAlign = 'center';
432
+ ctx.textBaseline = 'middle';
433
+ ctx.fillText(`${icon} ${label}`, cursor + width / 2, y + 12);
434
+ cursor += width + 8;
435
+ });
436
+ };
437
+
438
+ const drawStatusBadges = (ctx, statuses = [], x, y) => {
439
+ let cursor = x;
440
+ statuses.forEach((entry) => {
441
+ const width = 70;
442
+ drawRoundRect(ctx, cursor, y, width, 24, 12, entry.color);
443
+ ctx.fillStyle = '#111827';
444
+ ctx.font = '700 14px Sans';
445
+ ctx.textAlign = 'center';
446
+ ctx.textBaseline = 'middle';
447
+ ctx.fillText(`${entry.icon} ${entry.label}`, cursor + width / 2, y + 12);
448
+ cursor += width + 8;
449
+ });
450
+ };
451
+
452
+ const drawStatusPanel = (ctx, pokemon = {}, opts = {}) => {
453
+ const { x = 0, y = 0, width = 360, height = 150, align = 'left', role = 'player', turn = 1, isActive = false } = opts;
454
+ const roleTheme = ROLE_THEMES[role] || ROLE_THEMES.player;
455
+
456
+ drawRoundRect(ctx, x, y, width, height, PANEL_RADIUS, roleTheme.panelBg);
457
+ ctx.strokeStyle = `${roleTheme.accent}b3`;
458
+ ctx.lineWidth = 2.5;
459
+ drawRoundRect(ctx, x, y, width, height, PANEL_RADIUS);
460
+ ctx.stroke();
461
+ if (isActive) {
462
+ ctx.globalAlpha = toInt(turn, 1) % 2 === 0 ? 0.45 : 0.28;
463
+ ctx.strokeStyle = roleTheme.accent;
464
+ ctx.lineWidth = 4;
465
+ drawRoundRect(ctx, x - 2, y - 2, width + 4, height + 4, PANEL_RADIUS + 2);
466
+ ctx.stroke();
467
+ ctx.globalAlpha = 1;
468
+ }
469
+ drawRoundRect(ctx, x + 1, y + 1, width - 2, height - 2, PANEL_RADIUS, 'rgba(0,0,0,0)');
470
+ ctx.strokeStyle = 'rgba(255,255,255,0.14)';
471
+ ctx.lineWidth = 1;
472
+ ctx.stroke();
473
+
474
+ const name = trimText(pokemon?.displayName || pokemon?.name || 'Pokemon', 24);
475
+ const level = Math.max(1, toInt(pokemon?.level, 1));
476
+ const hpCurrent = Math.max(0, toInt(pokemon?.currentHp, 0));
477
+ const hpMax = Math.max(1, toInt(pokemon?.maxHp, 1));
478
+ const hpRatio = toRatio(hpCurrent, hpMax);
479
+ const hpColor = hpColorByRatio(hpRatio);
480
+ const types = normalizeTypeList(pokemon?.types);
481
+ const statuses = normalizeStatuses(pokemon);
482
+ const padding = 16;
483
+ const textX = align === 'left' ? x + padding : x + width - padding;
484
+ const roleBadgeW = 128;
485
+ const roleBadgeX = align === 'left' ? x + 12 : x + width - roleBadgeW - 12;
486
+ drawRoundRect(ctx, roleBadgeX, y + 10, roleBadgeW, 24, 12, `${roleTheme.accent}cc`);
487
+ ctx.fillStyle = '#0b1220';
488
+ ctx.font = '700 13px Sans';
489
+ ctx.textAlign = 'center';
490
+ ctx.textBaseline = 'middle';
491
+ ctx.fillText(`${roleTheme.icon} ${roleTheme.label}`, roleBadgeX + roleBadgeW / 2, y + 22);
492
+
493
+ ctx.fillStyle = '#e2e8f0';
494
+ fitText(ctx, name, width - 120, 28, 800);
495
+ ctx.textAlign = align;
496
+ ctx.textBaseline = 'alphabetic';
497
+ ctx.fillText(name, textX, y + 56);
498
+
499
+ ctx.fillStyle = '#93c5fd';
500
+ ctx.font = '700 16px Sans';
501
+ ctx.fillText(`Lv.${level}`, textX, y + 78);
502
+
503
+ const barX = x + padding;
504
+ const barY = y + 90;
505
+ const barW = width - padding * 2;
506
+ const barH = 18;
507
+ drawRoundRect(ctx, barX, barY, barW, barH, 9, 'rgba(15,23,42,0.8)');
508
+ const hpFillW = Math.max(18, Math.round(barW * hpRatio));
509
+ const hpGradient = ctx.createLinearGradient(barX, barY, barX + hpFillW, barY);
510
+ hpGradient.addColorStop(0, '#e2e8f0');
511
+ hpGradient.addColorStop(0.22, hpColorByRatio(clamp(hpRatio + 0.2, 0, 1)));
512
+ hpGradient.addColorStop(1, hpColor);
513
+ drawRoundRect(ctx, barX, barY, hpFillW, barH, 9, hpGradient);
514
+ if (hpRatio <= 0.3) {
515
+ const pulse = Math.max(0.14, toInt(turn, 1) % 2 === 0 ? 0.32 : 0.2);
516
+ ctx.globalAlpha = pulse;
517
+ ctx.strokeStyle = '#ef4444';
518
+ ctx.lineWidth = 3;
519
+ drawRoundRect(ctx, barX - 2, barY - 2, barW + 4, barH + 4, 11);
520
+ ctx.stroke();
521
+ ctx.globalAlpha = 1;
522
+ }
523
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
524
+ drawRoundRect(ctx, barX + 2, barY + 2, Math.max(10, hpFillW - 4), 4, 4, 'rgba(255,255,255,0.35)');
525
+ ctx.fillStyle = '#f8fafc';
526
+ ctx.font = '600 12px Sans';
527
+ ctx.textAlign = 'center';
528
+ ctx.fillText(`${hpCurrent}/${hpMax}`, barX + barW / 2, barY + 31);
529
+
530
+ const badgeX = x + padding;
531
+ drawTypeBadges(ctx, types, badgeX, y + 122);
532
+ if (statuses.length) {
533
+ drawStatusBadges(ctx, statuses, badgeX, y + 150);
534
+ }
535
+ };
536
+
537
+ const resolveActionTone = (actionText) => {
538
+ const raw = String(actionText || '').trim();
539
+ const normalized = normalizeText(raw);
540
+ if (normalized.includes('vitoria') || normalized.includes('venceu') || normalized.includes('desmaiou') || normalized.includes('derrot')) {
541
+ return { icon: '🏆', color: '#fde68a', subline: 'Vitória garantida!', badge: 'FINAL', weight: 'high' };
542
+ }
543
+ if (normalized.includes('captur') || normalized.includes('poke bola') || normalized.includes('pokebola')) {
544
+ return { icon: '🎯', color: '#fbcfe8', subline: null, badge: 'CAPTURA', weight: 'medium' };
545
+ }
546
+ if (normalized.includes('dano') || normalized.includes('causou') || normalized.includes('atac')) {
547
+ return { icon: '💥', color: '#fecaca', subline: null, badge: 'IMPACTO', weight: 'medium' };
548
+ }
549
+ if (normalized.includes('curou') || normalized.includes('recuper')) {
550
+ return { icon: '✨', color: '#bbf7d0', subline: null, badge: 'SUPORTE', weight: 'medium' };
551
+ }
552
+ if (normalized.includes('apareceu') || normalized.includes('inici')) {
553
+ return { icon: '🌀', color: '#bfdbfe', subline: null, badge: 'INICIO', weight: 'low' };
554
+ }
555
+ return { icon: '⚔️', color: '#bfdbfe', subline: null, badge: 'ACAO', weight: 'low' };
556
+ };
557
+
558
+ const resolveSecondaryAction = ({ logLines = [], primaryAction = '' }) => {
559
+ const primary = normalizeText(primaryAction);
560
+ const lines = Array.isArray(logLines) ? logLines : [];
561
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
562
+ const line = trimText(lines[index], 88);
563
+ const normalized = normalizeText(line);
564
+ if (!line || !normalized) continue;
565
+ if (primary && (normalized === primary || primary.includes(normalized))) continue;
566
+ if (normalized.includes('turno') || normalized.includes('use /rpg') || normalized.includes('hp:')) continue;
567
+ return line;
568
+ }
569
+ return null;
570
+ };
571
+
572
+ const inferActiveRole = ({ actionText = '', turn = 1, leftPokemon = {}, rightPokemon = {} }) => {
573
+ const normalized = normalizeText(actionText);
574
+ const leftName = normalizeText(leftPokemon?.displayName || leftPokemon?.name || '');
575
+ const rightName = normalizeText(rightPokemon?.displayName || rightPokemon?.name || '');
576
+ const actionHint = /(usou|atac|causou|acertou|curou|recuper)/.test(normalized);
577
+
578
+ if (leftName && normalized.includes(leftName) && actionHint) return 'player';
579
+ if (rightName && normalized.includes(rightName) && actionHint) return 'enemy';
580
+ if (normalized.includes('seu ') || normalized.includes('jogador') || normalized.includes('voce')) return 'player';
581
+ if (normalized.includes('inimigo') || normalized.includes('adversario') || normalized.includes('oponente')) return 'enemy';
582
+
583
+ return Math.max(1, toInt(turn, 1)) % 2 === 1 ? 'player' : 'enemy';
584
+ };
585
+
586
+ const resolveImpactOffsets = ({ activeRole, actionText, turn = 1 }) => {
587
+ const normalized = normalizeText(actionText);
588
+ const isImpact = normalized.includes('dano') || normalized.includes('causou') || normalized.includes('atac');
589
+ if (!isImpact) return { player: { x: 0, y: 0 }, enemy: { x: 0, y: 0 } };
590
+
591
+ const shake = Math.max(2, toInt(turn, 1) % 2 === 0 ? 8 : 6);
592
+ if (activeRole === 'player') {
593
+ return { player: { x: 0, y: 0 }, enemy: { x: -shake, y: 2 } };
594
+ }
595
+ return { player: { x: shake, y: 2 }, enemy: { x: 0, y: 0 } };
596
+ };
597
+
598
+ const drawActiveTurnIndicator = (ctx, { activeRole = 'player', leftPokemon = {}, rightPokemon = {}, turn = 1 }) => {
599
+ const role = activeRole === 'enemy' ? 'enemy' : 'player';
600
+ const roleTheme = ROLE_THEMES[role] || ROLE_THEMES.player;
601
+ const isPlayer = role === 'player';
602
+ const anchor = isPlayer ? { x: 300, y: 352, labelY: 252 } : { x: 726, y: 176, labelY: 74 };
603
+ const actorName = trimText(isPlayer ? leftPokemon?.displayName || leftPokemon?.name || 'Seu Pokémon' : rightPokemon?.displayName || rightPokemon?.name || 'Inimigo', 18);
604
+ const label = `👉 ${actorName} age agora`;
605
+ ctx.font = '700 17px Sans';
606
+ const labelWidth = clamp(Math.round(ctx.measureText(label).width) + 28, 180, 320);
607
+ const chipX = clamp(anchor.x - labelWidth / 2, 24, CANVAS_SIZE - labelWidth - 24);
608
+ const chipY = anchor.labelY;
609
+ drawRoundRect(ctx, chipX, chipY, labelWidth, 32, 16, toRgba(roleTheme.accent, 0.9));
610
+ ctx.fillStyle = '#0f172a';
611
+ ctx.textAlign = 'center';
612
+ ctx.textBaseline = 'middle';
613
+ ctx.fillText(label, chipX + labelWidth / 2, chipY + 16);
614
+
615
+ ctx.globalAlpha = toInt(turn, 1) % 2 === 0 ? 0.88 : 0.72;
616
+ ctx.fillStyle = toRgba(roleTheme.accent, 0.95);
617
+ ctx.beginPath();
618
+ ctx.moveTo(anchor.x, chipY + 42);
619
+ ctx.lineTo(anchor.x - 14, chipY + 18);
620
+ ctx.lineTo(anchor.x + 14, chipY + 18);
621
+ ctx.closePath();
622
+ ctx.fill();
623
+ ctx.globalAlpha = 1;
624
+ };
625
+
626
+ const drawOverlay = (ctx, { turn = 1, modeLabel = 'Batalha', actionText = '', effectTag = null, logLines = [] }) => {
627
+ const panelX = 88;
628
+ const panelY = 770;
629
+ const panelW = CANVAS_SIZE - 176;
630
+ const panelH = 188;
631
+ drawRoundRect(ctx, panelX, panelY, panelW, panelH, 26, 'rgba(2,6,23,0.68)');
632
+ ctx.strokeStyle = 'rgba(255,255,255,0.18)';
633
+ ctx.lineWidth = 2;
634
+ drawRoundRect(ctx, panelX, panelY, panelW, panelH, 26);
635
+ ctx.stroke();
636
+
637
+ ctx.fillStyle = '#f8fafc';
638
+ ctx.font = '700 30px Sans';
639
+ ctx.textAlign = 'left';
640
+ ctx.fillText(`${modeLabel} • Turno ${Math.max(1, toInt(turn, 1))}`, panelX + 22, panelY + 42);
641
+
642
+ const action = trimText(actionText || 'Aguardando ação do jogador.', 96);
643
+ const tone = resolveActionTone(action);
644
+ const actionStartsWithIcon = /^([^\w\s]|\p{Extended_Pictographic})/u.test(action);
645
+ const decoratedAction = actionStartsWithIcon ? action : `${tone.icon} ${action}`;
646
+ drawRoundRect(ctx, panelX + 12, panelY + 16, 8, panelH - 32, 4, toRgba(String(tone.color || '#bfdbfe'), 0.95));
647
+ const eventBadgeW = 116;
648
+ drawRoundRect(ctx, panelX + panelW - eventBadgeW - 20, panelY + 16, eventBadgeW, 30, 15, toRgba(String(tone.color || '#bfdbfe'), 0.84));
649
+ ctx.fillStyle = '#0f172a';
650
+ ctx.font = '700 14px Sans';
651
+ ctx.textAlign = 'center';
652
+ ctx.textBaseline = 'middle';
653
+ ctx.fillText(String(tone.badge || 'ACAO'), panelX + panelW - eventBadgeW / 2 - 20, panelY + 31);
654
+ ctx.fillStyle = tone.color;
655
+ ctx.font = tone.weight === 'high' ? '700 25px Sans' : tone.weight === 'medium' ? '700 24px Sans' : '600 23px Sans';
656
+ ctx.textAlign = 'left';
657
+ ctx.textBaseline = 'alphabetic';
658
+ ctx.fillText(decoratedAction, panelX + 22, panelY + 92);
659
+ const secondaryAction = resolveSecondaryAction({ logLines, primaryAction: action });
660
+ if (secondaryAction) {
661
+ ctx.fillStyle = 'rgba(226,232,240,0.95)';
662
+ ctx.font = '600 18px Sans';
663
+ ctx.fillText(`• ${trimText(secondaryAction, 72)}`, panelX + 22, panelY + 122);
664
+ }
665
+ if (tone.subline) {
666
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
667
+ ctx.font = '600 18px Sans';
668
+ ctx.fillText(tone.subline, panelX + 22, panelY + (secondaryAction ? 150 : 124));
669
+ }
670
+
671
+ if (!effectTag) return;
672
+ const palette = effectTag === 'super' ? { label: 'SUPER EFETIVO', color: '#ef4444' } : effectTag === 'weak' ? { label: 'POUCO EFETIVO', color: '#f59e0b' } : effectTag === 'none' ? { label: 'SEM EFEITO', color: '#64748b' } : null;
673
+ if (!palette) return;
674
+ const badgeW = 220;
675
+ drawRoundRect(ctx, CANVAS_SIZE / 2 - badgeW / 2, 38, badgeW, 44, 22, palette.color);
676
+ ctx.fillStyle = '#0f172a';
677
+ ctx.font = '800 18px Sans';
678
+ ctx.textAlign = 'center';
679
+ ctx.textBaseline = 'middle';
680
+ ctx.fillText(palette.label, CANVAS_SIZE / 2, 60);
681
+ };
682
+
683
+ const drawTurnImpact = (ctx, { effectTag = null, turn = 1 }) => {
684
+ const intensity = effectTag === 'super' ? 0.28 : effectTag === 'weak' ? 0.16 : 0.1;
685
+ const pulse = (Math.max(1, toInt(turn, 1)) % 2 === 0 ? 1 : 0.8) * intensity;
686
+ ctx.globalAlpha = pulse;
687
+ const flash = ctx.createRadialGradient(CANVAS_SIZE / 2, CANVAS_SIZE / 2, 40, CANVAS_SIZE / 2, CANVAS_SIZE / 2, 360);
688
+ flash.addColorStop(0, effectTag === 'super' ? '#fca5a5' : '#f8fafc');
689
+ flash.addColorStop(1, 'rgba(255,255,255,0)');
690
+ ctx.fillStyle = flash;
691
+ ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
692
+ ctx.globalAlpha = 1;
693
+ };
694
+
695
+ export const inferEffectTagFromLogs = (logs = []) => {
696
+ const text = (Array.isArray(logs) ? logs : []).join(' ').toLowerCase();
697
+ if (text.includes('super efetivo')) return 'super';
698
+ if (text.includes('pouco efetivo')) return 'weak';
699
+ if (text.includes('não teve efeito') || text.includes('nao teve efeito')) return 'none';
700
+ return null;
701
+ };
702
+
703
+ export const renderBattleFrameCanvas = async ({ leftPokemon = {}, rightPokemon = {}, turn = 1, biomeLabel = '', modeLabel = 'Batalha Pokemon', actionText = '', effectTag = null, activeRole = null, logLines = [] }) => {
704
+ const resolvedActiveRole =
705
+ activeRole === 'player' || activeRole === 'enemy'
706
+ ? activeRole
707
+ : inferActiveRole({
708
+ actionText,
709
+ turn,
710
+ leftPokemon,
711
+ rightPokemon,
712
+ });
713
+ const impactOffsets = resolveImpactOffsets({
714
+ activeRole: resolvedActiveRole,
715
+ actionText,
716
+ turn,
717
+ });
718
+ const canvas = createCanvas(CANVAS_SIZE, CANVAS_SIZE);
719
+ const ctx = canvas.getContext('2d');
720
+ ctx.imageSmoothingEnabled = true;
721
+ ctx.imageSmoothingQuality = 'high';
722
+
723
+ drawBackground(ctx, biomeLabel, rightPokemon?.types);
724
+ drawArena(ctx);
725
+
726
+ await Promise.all([
727
+ drawPokemon(ctx, leftPokemon, {
728
+ centerX: 300,
729
+ centerY: 556,
730
+ maxWidth: 328,
731
+ maxHeight: 328,
732
+ facing: 'right',
733
+ isPrimary: true,
734
+ role: 'player',
735
+ isActive: resolvedActiveRole === 'player',
736
+ offsetX: impactOffsets.player.x,
737
+ offsetY: impactOffsets.player.y,
738
+ turn,
739
+ }),
740
+ drawPokemon(ctx, rightPokemon, {
741
+ centerX: 726,
742
+ centerY: 346,
743
+ maxWidth: 288,
744
+ maxHeight: 288,
745
+ facing: 'left',
746
+ isPrimary: false,
747
+ role: 'enemy',
748
+ isActive: resolvedActiveRole === 'enemy',
749
+ offsetX: impactOffsets.enemy.x,
750
+ offsetY: impactOffsets.enemy.y,
751
+ turn,
752
+ }),
753
+ ]);
754
+ drawActiveTurnIndicator(ctx, {
755
+ activeRole: resolvedActiveRole,
756
+ leftPokemon,
757
+ rightPokemon,
758
+ turn,
759
+ });
760
+
761
+ drawStatusPanel(ctx, leftPokemon, {
762
+ x: 44,
763
+ y: 458,
764
+ width: 420,
765
+ height: 168,
766
+ align: 'left',
767
+ role: 'player',
768
+ turn,
769
+ isActive: resolvedActiveRole === 'player',
770
+ });
771
+ drawStatusPanel(ctx, rightPokemon, {
772
+ x: CANVAS_SIZE - 464,
773
+ y: 110,
774
+ width: 420,
775
+ height: 168,
776
+ align: 'right',
777
+ role: 'enemy',
778
+ turn,
779
+ isActive: resolvedActiveRole === 'enemy',
780
+ });
781
+
782
+ drawTurnImpact(ctx, { effectTag, turn });
783
+ drawOverlay(ctx, { turn, modeLabel, actionText, effectTag, logLines });
784
+
785
+ return canvas.toBuffer('image/png', { compressionLevel: 4 });
786
+ };