Mohammad Shahid commited on
Commit
d87d4e5
·
1 Parent(s): 501a7cc

added self repair and removed player ai

Browse files
app/actions/ai.actions.ts CHANGED
@@ -1,19 +1,30 @@
1
  "use server";
2
 
3
- import {
4
- expertYouTubePrompt,
5
- heroCoderPrompt,
6
- heroCreativePrompt,
7
- } from "@/lib/AiPrompts";
8
- import { revalidatePath } from "next/cache";
9
- import { GoogleGenAI, Modality, Type } from "@google/genai";
10
- import { BattleReport, HeroData } from "@/types"; // Assuming HeroData type will be updated in "@/types"
11
  import { v2 as cloudinary } from "cloudinary";
12
- import wav from "wav";
13
  import { Readable } from "stream";
14
- import { extractAndCleanCode, sanitizePowerLogicCode } from "@/lib/utils";
 
 
15
  import { connectToDatabase } from "@/lib/database";
 
16
  import Hero from "@/model/hero.model";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  // --- Cloudinary Configuration ---
19
  cloudinary.config({
@@ -23,71 +34,39 @@ cloudinary.config({
23
  secure: true,
24
  });
25
 
26
- // Male voice options based on common naming conventions and user's list
27
- const MALE_VOICES = [
28
- "Puck",
29
- "Charon",
30
- "Fenrir",
31
- "Orus",
32
- "Enceladus",
33
- "Iapetus",
34
- "Umbriel",
35
- "Algieba",
36
- "Algenib",
37
- "Rasalgethi",
38
-
39
- "Alnilam",
40
- "Schedar",
41
- "Achird",
42
- "Pulcherrima",
43
- "Zubenelgenubi",
44
- "Sadaltager",
45
-
46
- "Sadachbia",
47
- ];
48
-
49
- // Female voice options based on common naming conventions and user's list
50
- const FEMALE_VOICES = [
51
- "Zephyr",
52
- "Kore",
53
- "Leda",
54
- "Aoede",
55
- "Callirrhoe",
56
- "Autonoe",
57
- "Despina",
58
- "Erinome",
59
- "Achernar",
60
- "Gacrux",
61
- "Laomedeia",
62
- "Sulafat",
63
- "Vindemiatrix",
64
- ];
65
-
66
- // Helper to get a random voice of a specific gender
67
  function getRandomVoice(gender: "Male" | "Female"): string {
 
68
  const voices = gender === "Male" ? MALE_VOICES : FEMALE_VOICES;
 
 
 
 
69
  return voices[Math.floor(Math.random() * voices.length)];
70
  }
71
 
72
- // We can place this type definition in a shared file or at the top of the AI utility file.
73
-
74
- export const generateYouTubeMetadata = async (report: BattleReport) => {
75
- console.log("Generating YouTube metadata with expert AI prompt...");
76
-
77
- const apiKey = process.env.GEMINI_API_KEY;
78
- if (!apiKey) {
79
- throw new Error("GEMINI_API_KEY is not configured.");
 
 
 
 
 
80
  }
81
 
82
  try {
83
- const ai = new GoogleGenAI({ apiKey });
84
-
85
- // This is the specific battle data we're sending for this request
86
- const userPromptForThisBattle = `Here is the BattleReport for the video:\n${JSON.stringify(
87
- report,
88
- null,
89
- 2
90
- )}`;
91
  const response = await ai.models.generateContent({
92
  model: "gemini-2.5-flash",
93
  contents: userPromptForThisBattle,
@@ -97,195 +76,101 @@ export const generateYouTubeMetadata = async (report: BattleReport) => {
97
  },
98
  });
99
 
100
- console.log(response.text);
101
-
102
  const aiResponseText = response.text;
103
  if (!aiResponseText) {
104
- throw new Error("AI response is empty.");
 
105
  }
106
 
107
- // The AI response is a JSON string, so we parse it into an object
108
- const metadata = JSON.parse(aiResponseText);
 
 
 
 
 
 
109
 
110
- // You could add validation here to ensure it has title, description, tags
111
  if (!metadata.title || !metadata.description || !metadata.tags) {
112
- throw new Error("AI returned malformed JSON.");
 
113
  }
114
 
115
- // revalidatePath("/"); // This might not be necessary depending on your app flow
116
-
117
  return metadata;
118
  } catch (error) {
119
- console.error("Error generating metadata with AI:", error);
120
- // Return a fallback or re-throw the error
121
- return {
122
- title: `Square Battles: ${report.winner?.name || "Draw"}`,
123
- description:
124
- "An epic battle in Square Battles! Like and subscribe for more!",
125
- tags: "Square Battles, indie game, physics game",
126
- };
127
  }
128
  };
129
 
130
- const heroDataSchema = {
131
- type: Type.OBJECT,
132
- properties: {
133
- heroName: { type: Type.STRING },
134
- heroGender: { type: Type.STRING },
135
- heroDescription: { type: Type.STRING },
136
- heroColor: { type: Type.STRING },
137
- heroHealth: { type: Type.NUMBER },
138
- heroSpeed: { type: Type.NUMBER },
139
- basicAttack: {
140
- type: Type.OBJECT,
141
- properties: {
142
- type: { type: Type.STRING }, // e.g., "RANGED", "MELEE", "CONTACT"
143
- damage: { type: Type.NUMBER },
144
- cooldown: { type: Type.NUMBER },
145
- range: { type: Type.NUMBER },
146
- knockback: { type: Type.NUMBER },
147
- },
148
- required: ["type", "damage", "cooldown", "range", "knockback"],
149
- },
150
- powerName: { type: Type.STRING },
151
- powerDescription: { type: Type.STRING },
152
- powerLogicDescription: { type: Type.STRING },
153
- introLine: { type: Type.STRING },
154
- superpowerActivationLine: { type: Type.STRING },
155
- avatarPrompt: { type: Type.STRING },
156
- iconPrompt: { type: Type.STRING },
157
- tags: { type: Type.ARRAY, items: { type: Type.STRING } },
158
- },
159
- required: [
160
- "heroName",
161
- "heroGender",
162
- "heroDescription",
163
- "heroColor",
164
- "heroHealth",
165
- "heroSpeed",
166
- "basicAttack",
167
- "powerName",
168
- "powerDescription",
169
- "powerLogicDescription",
170
- "introLine",
171
- "superpowerActivationLine",
172
- "avatarPrompt",
173
- "iconPrompt",
174
- "tags",
175
- ],
176
- };
177
-
178
- // --- NEW, COMPLETELY REWRITTEN 'heroGenerationPrompt' ---
179
-
180
- export const generateAndSaveHero = async (
181
- theme: string,
182
- path?: string
183
- ): Promise<HeroData | null> => {
184
- console.log(`Generating two hero concepts for theme: ${theme}...`);
185
-
186
- const apiKey = process.env.GEMINI_API_KEY;
187
- if (!apiKey) {
188
- console.error("GEMINI_API_KEY is not configured.");
189
- return null;
190
- }
191
-
192
  try {
193
  await connectToDatabase();
194
 
195
- // --- STEP 1: PRE-EMPTIVE CHECK ---
196
- // Fetch all existing hero names first.
197
- const existingHeroes = await Hero.find().select("heroName");
198
  const existingNames = existingHeroes.map((h) => h.heroName);
199
-
200
  let userPromptForThisTheme = `Generate a unique hero concept for the theme: "${theme}".`;
201
-
202
  if (existingNames.length > 0) {
203
- const exclusionList = existingNames.join(", ");
204
- userPromptForThisTheme += ` \n\nIMPORTANT: You MUST NOT generate a hero whose name is on this list of already existing heroes: ${exclusionList}.`;
205
- console.log(`Injecting exclusion list into prompt: ${exclusionList}`);
206
  }
207
 
208
- const ai = new GoogleGenAI({ apiKey });
209
 
210
- // FIX: Make the prompt for the second hero explicitly unique
211
  console.log("Calling Creative Director AI...");
212
-
213
  const creativeResponse = await ai.models.generateContent({
214
  model: "gemini-2.5-pro",
215
  contents: userPromptForThisTheme,
216
  config: {
217
  systemInstruction: heroCreativePrompt,
218
  responseMimeType: "application/json",
219
- responseSchema: heroDataSchema,
220
  },
221
  });
222
 
223
  const creativeText = creativeResponse.text;
224
- if (!creativeText) throw new Error(`AI response for hero was empty.`);
 
 
 
 
 
 
 
225
 
226
- const partialHeroData = JSON.parse(creativeText);
 
 
 
 
227
 
228
- // --- STEP 2: Call the Logic Coder AI ---
229
  console.log("Calling Logic Coder AI to translate description...");
230
- const finalSanitizedCode = await generatePowerLogicCode(
231
- partialHeroData.powerLogicDescription
232
- );
233
- // --- STEP 4: Generate Assets (your existing logic) ---
 
234
  console.log("Generating assets for", partialHeroData.heroName);
235
-
236
- // --- Generate and Upload Assets ---
237
  const [avatarBuffer, iconBuffer] = await Promise.all([
238
  generateImageWithGemini(partialHeroData.avatarPrompt),
239
  generateImageWithGemini(partialHeroData.iconPrompt),
240
  ]);
241
- if (!avatarBuffer || !iconBuffer)
242
- throw new Error(
243
- `Failed to generate image buffers for ${partialHeroData.heroName}.`
244
- );
245
-
246
- const publicIdBase = partialHeroData.heroName
247
- .toLowerCase()
248
- .replace(/ /g, "_");
249
  const [avatarUrl, iconUrl] = await Promise.all([
250
  uploadToCloudinary(avatarBuffer, `${publicIdBase}_avatar`, "image"),
251
  uploadToCloudinary(iconBuffer, `${publicIdBase}_icon`, "image"),
252
  ]);
253
- if (!avatarUrl || !iconUrl)
254
- throw new Error(
255
- `Failed to upload images for ${partialHeroData.heroName}.`
256
- );
257
-
258
- partialHeroData.avatarUrl = avatarUrl;
259
- partialHeroData.iconUrl = iconUrl;
260
-
261
- const selectedVoice = getRandomVoice(
262
- partialHeroData.heroGender as "Male" | "Female"
263
- );
264
- const [introLineAudioUrl, superpowerActivationLineAudioUrl] =
265
- await Promise.all([
266
- generateAudio(
267
- partialHeroData.introLine,
268
- partialHeroData.heroName,
269
- selectedVoice,
270
- "intro"
271
- ),
272
- generateAudio(
273
- partialHeroData.superpowerActivationLine,
274
- partialHeroData.heroName,
275
- selectedVoice,
276
- "superpower"
277
- ),
278
- ]);
279
-
280
- partialHeroData.introLineAudioUrl = introLineAudioUrl || undefined;
281
- partialHeroData.superpowerActivationLineAudioUrl =
282
- superpowerActivationLineAudioUrl || undefined;
283
-
284
- console.log(
285
- `Successfully generated and processed hero:`,
286
- partialHeroData.heroName
287
- );
288
 
 
 
 
 
 
 
289
  const finalHeroData: HeroData = {
290
  ...partialHeroData,
291
  powerLogic: finalSanitizedCode,
@@ -293,75 +178,76 @@ export const generateAndSaveHero = async (
293
  iconUrl,
294
  aiVoicer: selectedVoice,
295
  introLineAudioUrl: introLineAudioUrl || undefined,
296
- superpowerActivationLineAudioUrl:
297
- superpowerActivationLineAudioUrl || undefined,
298
  };
 
299
  const newHero = new Hero(finalHeroData);
300
  await newHero.save();
301
 
302
  console.log(`Successfully generated and saved hero: ${newHero.heroName}`);
303
-
304
  if (path) revalidatePath(path);
305
-
306
  return JSON.parse(JSON.stringify(newHero));
 
307
  } catch (error) {
308
- console.error("Error generating hero concepts with AI:", error);
309
- return null;
310
  }
311
  };
312
 
313
- export const generatePowerLogicCode = async (
314
- powerLogicDescription: string
315
- ): Promise<string> => {
316
- // Call Gemini to generate code
317
-
318
- try {
319
- const apiKey = process.env.GEMINI_API_KEY;
320
- if (!apiKey) {
321
- console.error("GEMINI_API_KEY is not configured.");
322
- throw new Error("GEMINI_API_KEY is not configured.");
323
- }
324
- const ai = new GoogleGenAI({ apiKey });
325
-
326
- const coderResponse = await ai.models.generateContent({
327
- model: "gemini-2.5-pro",
328
- contents: powerLogicDescription,
329
- config: {
330
- systemInstruction: heroCoderPrompt,
331
- },
332
- });
333
-
334
- const rawPowerLogicCode = coderResponse.text;
335
- console.log("Raw response from AI:", rawPowerLogicCode);
336
-
337
- if (!rawPowerLogicCode) {
338
- throw new Error("Coder AI did not return a powerLogic string.");
339
- }
340
-
341
- // Clean the AI's response to remove markdown and other artifacts.
342
- const extractedCode = extractAndCleanCode(rawPowerLogicCode);
343
- console.log("Cleaned powerLogic code:", extractedCode);
344
-
345
- if (!extractedCode) {
346
- throw new Error("After cleaning, the AI-generated code was empty.");
 
 
 
 
347
  }
348
-
349
- console.log("Successfully generated and cleaned powerLogic code.");
350
-
351
- // Sanitize and validate the code
352
- return sanitizePowerLogicCode(extractedCode);
353
- } catch (error) {
354
- console.error("Error generating or sanitizing powerLogic code:", error);
355
- throw new Error("Failed to generate valid powerLogic code from AI.");
356
  }
 
357
  };
358
 
359
- // --- Helper function to call Gemini for Image Generation ---
 
 
360
  export async function generateImageWithGemini(prompt: string): Promise<Buffer | null> {
361
- const apiKey = process.env.GEMINI_API_KEY;
362
- if (!apiKey) return null;
 
 
 
363
  try {
364
- const ai = new GoogleGenAI({ apiKey });
365
  const imagePrompt = `High-quality, detailed, vibrant image. Painterly 2D style, square format, epic lighting, strong character focus. No text/borders. PROMPT: ${prompt}`;
366
  const response = await ai.models.generateContent({
367
  model: "gemini-2.0-flash-preview-image-generation",
@@ -369,17 +255,11 @@ export async function generateImageWithGemini(prompt: string): Promise<Buffer |
369
  config: { responseModalities: [Modality.TEXT, Modality.IMAGE] },
370
  });
371
 
372
- const imagePart = response.candidates?.[0]?.content?.parts?.find(
373
- (p) => "inlineData" in p
374
- );
375
- if (
376
- imagePart &&
377
- "inlineData" in imagePart &&
378
- imagePart.inlineData &&
379
- typeof imagePart.inlineData.data === "string"
380
- ) {
381
  return Buffer.from(imagePart.inlineData.data, "base64");
382
  }
 
383
  return null;
384
  } catch (error) {
385
  console.error("[Gemini Image] Error:", error);
@@ -387,110 +267,77 @@ export async function generateImageWithGemini(prompt: string): Promise<Buffer |
387
  }
388
  }
389
 
390
- // --- Helper function to upload any buffer to Cloudinary ---
391
- export const uploadToCloudinary = async (
392
- buffer: Buffer,
393
- publicId: string,
394
- resourceType: "image" | "video"
395
- ): Promise<string> => {
396
- return new Promise((resolve, reject) => {
397
- const stream = cloudinary.uploader.upload_stream(
398
- {
399
- public_id: publicId,
400
- folder: "square-battles-heroes",
401
- resource_type: resourceType,
402
- overwrite: true,
403
- format: resourceType === "video" ? "wav" : undefined,
404
- },
405
- (error, result) => {
406
- if (result) resolve(result.secure_url);
407
- else reject(error);
408
- }
409
- );
410
- stream.end(buffer);
411
- });
 
 
 
 
 
 
 
412
  };
413
 
414
- // Helper function to convert PCM data to WAV format
415
- const convertToWav = async (
416
- pcmData: Buffer,
417
- channels = 1,
418
- sampleRate = 24000,
419
- bitDepth = 16
420
- ): Promise<Buffer> => {
421
  return new Promise((resolve, reject) => {
 
 
 
 
422
  const chunks: Buffer[] = [];
423
-
424
- // Create a readable stream from the PCM data
425
- const readable = new Readable({
426
- read() {
427
- this.push(pcmData);
428
- this.push(null); // End the stream
429
- },
430
- });
431
-
432
- // Create WAV writer
433
- const writer = new wav.Writer({
434
- channels,
435
- sampleRate,
436
- bitDepth,
437
- });
438
-
439
- writer.on("data", (chunk) => {
440
- chunks.push(chunk);
441
- });
442
-
443
- writer.on("end", () => {
444
- const wavBuffer = Buffer.concat(chunks);
445
- resolve(wavBuffer);
446
- });
447
-
448
- writer.on("error", reject);
449
-
450
- // Pipe the PCM data through the WAV writer
451
  readable.pipe(writer);
452
  });
453
  };
454
 
455
- // --- NEW: Function to generate and upload audio ---
456
- export const generateAudio = async (
457
- textToSpeak: string,
458
- heroName: string,
459
- selectedVoice: string,
460
- lineType: "intro" | "superpower"
461
- ): Promise<string | null> => {
462
- console.log(
463
- `[Audio] Requesting for "${heroName}" (${lineType}): "${textToSpeak}"`
464
- );
465
-
466
- const apiKey = process.env.GEMINI_API_KEY;
467
- if (!apiKey) return null;
468
-
469
  try {
470
- const ai = new GoogleGenAI({ apiKey });
471
-
472
  const response = await ai.models.generateContent({
473
  model: "gemini-2.5-flash-preview-tts",
474
  contents: [{ parts: [{ text: textToSpeak }] }],
475
  config: {
476
  responseModalities: ["AUDIO"],
477
- speechConfig: {
478
- voiceConfig: { prebuiltVoiceConfig: { voiceName: selectedVoice } },
479
- },
480
  },
481
  });
482
 
483
- const data =
484
- response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
485
  if (!data) throw new Error("No audio data from AI.");
486
-
487
  const pcmBuffer = Buffer.from(data, "base64");
488
  const wavBuffer = await convertToWav(pcmBuffer);
489
-
490
- // FIX: Make public_id unique for each line type to prevent overwriting
491
  const publicId = `${heroName.toLowerCase().replace(/ /g, "_")}_${lineType}`;
492
-
493
- // Use 'video' resource type for audio files on Cloudinary
494
  return await uploadToCloudinary(wavBuffer, publicId, "video");
495
  } catch (error) {
496
  console.error(`[Audio Generation Error for ${heroName}]:`, error);
@@ -498,44 +345,27 @@ export const generateAudio = async (
498
  }
499
  };
500
 
501
- /**
502
- * Updates one or more fields of a hero in the database by hero ID.
503
- * @param _id The ID of the hero to update.
504
- * @param update An object with any fields to update.
505
- * @returns The updated hero document, or null if not found.
506
- *
507
- * Example usage:
508
- *
509
- * await updateHero("665b1e2c7f8a2a0012a3c456", {
510
- * heroName: "New Name",
511
- * heroHealth: 120,
512
- * tags: ["tank", "fire", "legendary"]
513
- * });
514
- *
515
- * // You can update as many or as few fields as you want.
516
- * // Only the fields you include in the `update` object will be changed.
517
- */
518
- export const updateHero = async (
519
- _id: string,
520
- update: Partial<HeroData>
521
- ): Promise<HeroData | null> => {
522
  try {
523
  await connectToDatabase();
524
-
525
- // Prevent empty update
526
- if (Object.keys(update).length === 0) {
527
- throw new Error("You must provide at least one field to update.");
528
- }
529
-
530
  const updatedHero = await Hero.findByIdAndUpdate(
531
  _id,
532
  { $set: update },
533
- { new: true }
534
- );
 
 
535
 
536
- return updatedHero ? JSON.parse(JSON.stringify(updatedHero)) : null;
 
537
  } catch (error) {
538
- console.error("Error updating hero:", error);
539
  return null;
540
  }
541
  };
 
 
1
  "use server";
2
 
3
+ import { GoogleGenAI, Modality } from "@google/genai";
 
 
 
 
 
 
 
4
  import { v2 as cloudinary } from "cloudinary";
5
+ import { revalidatePath } from "next/cache";
6
  import { Readable } from "stream";
7
+ import wav from "wav";
8
+
9
+ import { expertYouTubePrompt, heroCoderPrompt, heroCreativePrompt } from "@/lib/AiPrompts";
10
  import { connectToDatabase } from "@/lib/database";
11
+ import { extractAndCleanCode, sanitizePowerLogicCode } from "@/lib/utils";
12
  import Hero from "@/model/hero.model";
13
+ import { BattleReport, HeroData } from "@/types";
14
+ import { heroDataSchema } from "@/lib/validators";
15
+
16
+ // --- Defensive Environmental Variable Checks ---
17
+ // Ensure all required environment variables are present at startup.
18
+ if (
19
+ !process.env.GEMINI_API_KEY ||
20
+ !process.env.CLOUDINARY_CLOUD_NAME ||
21
+ !process.env.CLOUDINARY_API_KEY ||
22
+ !process.env.CLOUDINARY_API_SECRET
23
+ ) {
24
+ throw new Error(
25
+ "FATAL: Missing critical environment variables for AI or Cloudinary. Application cannot start."
26
+ );
27
+ }
28
 
29
  // --- Cloudinary Configuration ---
30
  cloudinary.config({
 
34
  secure: true,
35
  });
36
 
37
+ // --- Voice Options (unchanged) ---
38
+ const MALE_VOICES = ["Puck", "Charon", "Fenrir", "Orus", "Enceladus", "Iapetus", "Umbriel", "Algieba", "Algenib", "Rasalgethi", "Alnilam", "Schedar", "Achird", "Pulcherrima", "Zubenelgenubi", "Sadaltager", "Sadachbia"];
39
+ const FEMALE_VOICES = ["Zephyr", "Kore", "Leda", "Aoede", "Callirrhoe", "Autonoe", "Despina", "Erinome", "Achernar", "Gacrux", "Laomedeia", "Sulafat", "Vindemiatrix"];
40
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  function getRandomVoice(gender: "Male" | "Female"): string {
42
+ // DEFENSIVE: Handle potential invalid gender input gracefully.
43
  const voices = gender === "Male" ? MALE_VOICES : FEMALE_VOICES;
44
+ if (!voices || voices.length === 0) {
45
+ console.error(`[getRandomVoice] No voices available for gender: ${gender}.`);
46
+ return MALE_VOICES[0]; // Return a guaranteed safe default.
47
+ }
48
  return voices[Math.floor(Math.random() * voices.length)];
49
  }
50
 
51
+ // --- generateYouTubeMetadata (Refactored for Resilience) ---
52
+ export const generateYouTubeMetadata = async (report: BattleReport | null) => {
53
+ // DEFENSIVE: Create a safe fallback metadata object.
54
+ const fallbackMetadata = {
55
+ title: `Square Battles: ${report?.winner?.name || "An Epic Draw"}`,
56
+ description: "An intense battle in Square Battles! Who will emerge victorious? Like and subscribe for more computer-generated chaos!",
57
+ tags: "Square Battles, indie game, physics game, simulation, auto battler",
58
+ };
59
+
60
+ // DEFENSIVE: Check for valid input.
61
+ if (!report) {
62
+ console.warn("[AI] generateYouTubeMetadata called with null report. Returning fallback.");
63
+ return fallbackMetadata;
64
  }
65
 
66
  try {
67
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY as string });
68
+ const userPromptForThisBattle = `Here is the BattleReport for the video:\n${JSON.stringify(report, null, 2)}`;
69
+
 
 
 
 
 
70
  const response = await ai.models.generateContent({
71
  model: "gemini-2.5-flash",
72
  contents: userPromptForThisBattle,
 
76
  },
77
  });
78
 
 
 
79
  const aiResponseText = response.text;
80
  if (!aiResponseText) {
81
+ console.warn("[AI] YouTube metadata response was empty. Using fallback.");
82
+ return fallbackMetadata;
83
  }
84
 
85
+ // DEFENSIVE: Wrap JSON.parse in a try-catch to prevent crashes from malformed AI responses.
86
+ let metadata;
87
+ try {
88
+ metadata = JSON.parse(aiResponseText);
89
+ } catch (parseError) {
90
+ console.error("[AI] Failed to parse YouTube metadata JSON from AI. Using fallback.", parseError);
91
+ return fallbackMetadata;
92
+ }
93
 
94
+ // DEFENSIVE: Validate the parsed object's structure.
95
  if (!metadata.title || !metadata.description || !metadata.tags) {
96
+ console.warn("[AI] YouTube metadata from AI was missing required keys. Using fallback.");
97
+ return fallbackMetadata;
98
  }
99
 
 
 
100
  return metadata;
101
  } catch (error) {
102
+ console.error("[AI] Error generating YouTube metadata:", error);
103
+ return fallbackMetadata; // Return the safe fallback on any error.
 
 
 
 
 
 
104
  }
105
  };
106
 
107
+ // --- generateAndSaveHero (Refactored for Resilience) ---
108
+ export const generateAndSaveHero = async (theme: string, path?: string): Promise<HeroData | null> => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  try {
110
  await connectToDatabase();
111
 
112
+ const existingHeroes = await Hero.find().select("heroName").lean();
 
 
113
  const existingNames = existingHeroes.map((h) => h.heroName);
 
114
  let userPromptForThisTheme = `Generate a unique hero concept for the theme: "${theme}".`;
 
115
  if (existingNames.length > 0) {
116
+ userPromptForThisTheme += ` \n\nIMPORTANT: You MUST NOT generate a hero whose name is on this list of already existing heroes: ${existingNames.join(", ")}.`;
 
 
117
  }
118
 
119
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY as string });
120
 
 
121
  console.log("Calling Creative Director AI...");
 
122
  const creativeResponse = await ai.models.generateContent({
123
  model: "gemini-2.5-pro",
124
  contents: userPromptForThisTheme,
125
  config: {
126
  systemInstruction: heroCreativePrompt,
127
  responseMimeType: "application/json",
128
+ responseSchema: heroDataSchema
129
  },
130
  });
131
 
132
  const creativeText = creativeResponse.text;
133
+ if (!creativeText) throw new Error("AI creative response was empty.");
134
+
135
+ let partialHeroData;
136
+ try {
137
+ partialHeroData = JSON.parse(creativeText);
138
+ } catch{
139
+ throw new Error("AI returned malformed JSON for hero data.");
140
+ }
141
 
142
+ // DEFENSIVE: Check if the generated name already exists (a race condition or AI mistake)
143
+ if (existingNames.includes(partialHeroData.heroName)) {
144
+ console.warn(`[AI] Generated a duplicate hero name: ${partialHeroData.heroName}. Aborting.`);
145
+ return null; // Gracefully fail
146
+ }
147
 
 
148
  console.log("Calling Logic Coder AI to translate description...");
149
+ const finalSanitizedCode = await generatePowerLogicCode(partialHeroData.powerLogicDescription);
150
+ if (!finalSanitizedCode) {
151
+ throw new Error("Failed to generate valid power logic code after multiple attempts.");
152
+ }
153
+
154
  console.log("Generating assets for", partialHeroData.heroName);
 
 
155
  const [avatarBuffer, iconBuffer] = await Promise.all([
156
  generateImageWithGemini(partialHeroData.avatarPrompt),
157
  generateImageWithGemini(partialHeroData.iconPrompt),
158
  ]);
159
+ if (!avatarBuffer || !iconBuffer) throw new Error("Failed to generate one or more image buffers.");
160
+
161
+ const publicIdBase = partialHeroData.heroName.toLowerCase().replace(/ /g, "_");
 
 
 
 
 
162
  const [avatarUrl, iconUrl] = await Promise.all([
163
  uploadToCloudinary(avatarBuffer, `${publicIdBase}_avatar`, "image"),
164
  uploadToCloudinary(iconBuffer, `${publicIdBase}_icon`, "image"),
165
  ]);
166
+ if (!avatarUrl || !iconUrl) throw new Error("Failed to upload one or more images to Cloudinary.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
+ const selectedVoice = getRandomVoice(partialHeroData.heroGender as "Male" | "Female");
169
+ const [introLineAudioUrl, superpowerActivationLineAudioUrl] = await Promise.all([
170
+ generateAudio(partialHeroData.introLine, partialHeroData.heroName, selectedVoice, "intro"),
171
+ generateAudio(partialHeroData.superpowerActivationLine, partialHeroData.heroName, selectedVoice, "superpower"),
172
+ ]);
173
+
174
  const finalHeroData: HeroData = {
175
  ...partialHeroData,
176
  powerLogic: finalSanitizedCode,
 
178
  iconUrl,
179
  aiVoicer: selectedVoice,
180
  introLineAudioUrl: introLineAudioUrl || undefined,
181
+ superpowerActivationLineAudioUrl: superpowerActivationLineAudioUrl || undefined,
 
182
  };
183
+
184
  const newHero = new Hero(finalHeroData);
185
  await newHero.save();
186
 
187
  console.log(`Successfully generated and saved hero: ${newHero.heroName}`);
 
188
  if (path) revalidatePath(path);
 
189
  return JSON.parse(JSON.stringify(newHero));
190
+
191
  } catch (error) {
192
+ console.error("⛔️ Top-Level Error in generateAndSaveHero:", error);
193
+ return null; // Gracefully return null on any failure.
194
  }
195
  };
196
 
197
+ // --- generatePowerLogicCode (Refactored with Self-Healing Retry Loop) ---
198
+ export const generatePowerLogicCode = async (powerLogicDescription: string): Promise<string | null> => {
199
+ const MAX_ATTEMPTS = 3;
200
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
201
+ try {
202
+ console.log(`[AI Logic] Attempt ${attempt}/${MAX_ATTEMPTS} for: "${powerLogicDescription.substring(0, 50)}..."`);
203
+
204
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
205
+ const coderResponse = await ai.models.generateContent({
206
+ model: "gemini-2.5-pro",
207
+ contents: powerLogicDescription,
208
+ config: { systemInstruction: heroCoderPrompt },
209
+ });
210
+
211
+ const rawPowerLogicCode = coderResponse.text;
212
+ if (!rawPowerLogicCode) {
213
+ throw new Error("Coder AI returned an empty response.");
214
+ }
215
+
216
+ const extractedCode = extractAndCleanCode(rawPowerLogicCode);
217
+ if (!extractedCode) {
218
+ throw new Error("After cleaning, the AI-generated code was empty.");
219
+ }
220
+
221
+ // The sanitization function will throw if the code is syntactically invalid.
222
+ const sanitizedCode = sanitizePowerLogicCode(extractedCode);
223
+
224
+ console.log("[AI Logic] Successfully generated and validated code.");
225
+ return sanitizedCode; // Success! Exit the loop and return the code.
226
+
227
+ } catch (error) {
228
+ console.warn(`[AI Logic] Attempt ${attempt} failed.`, error);
229
+ if (attempt === MAX_ATTEMPTS) {
230
+ console.error("[AI Logic] All attempts to generate valid code failed.");
231
+ return null; // Return null after all retries have failed.
232
+ }
233
+ // Optional: wait a moment before retrying
234
+ await new Promise(res => setTimeout(res, 1000));
235
  }
 
 
 
 
 
 
 
 
236
  }
237
+ return null; // Should be unreachable, but acts as a final fallback.
238
  };
239
 
240
+
241
+ // --- Helper Functions (Refactored for Resilience) ---
242
+
243
  export async function generateImageWithGemini(prompt: string): Promise<Buffer | null> {
244
+ // DEFENSIVE: check prompt
245
+ if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') {
246
+ console.error("[Gemini Image] Called with invalid prompt.");
247
+ return null;
248
+ }
249
  try {
250
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
251
  const imagePrompt = `High-quality, detailed, vibrant image. Painterly 2D style, square format, epic lighting, strong character focus. No text/borders. PROMPT: ${prompt}`;
252
  const response = await ai.models.generateContent({
253
  model: "gemini-2.0-flash-preview-image-generation",
 
255
  config: { responseModalities: [Modality.TEXT, Modality.IMAGE] },
256
  });
257
 
258
+ const imagePart = response.candidates?.[0]?.content?.parts?.find((p) => "inlineData" in p);
259
+ if (imagePart && "inlineData" in imagePart && imagePart.inlineData?.data) {
 
 
 
 
 
 
 
260
  return Buffer.from(imagePart.inlineData.data, "base64");
261
  }
262
+ console.warn("[Gemini Image] No image data found in AI response.");
263
  return null;
264
  } catch (error) {
265
  console.error("[Gemini Image] Error:", error);
 
267
  }
268
  }
269
 
270
+ export const uploadToCloudinary = async (buffer: Buffer, publicId: string, resourceType: "image" | "video"): Promise<string | null> => {
271
+ // DEFENSIVE: Check inputs
272
+ if (!buffer || buffer.length === 0 || !publicId) {
273
+ console.error("[Cloudinary] Invalid buffer or publicId provided for upload.");
274
+ return null;
275
+ }
276
+ try {
277
+ return new Promise((resolve, reject) => {
278
+ const stream = cloudinary.uploader.upload_stream(
279
+ {
280
+ public_id: publicId,
281
+ folder: "square-battles-heroes",
282
+ resource_type: resourceType,
283
+ overwrite: true,
284
+ format: resourceType === "video" ? "wav" : undefined,
285
+ },
286
+ (error, result) => {
287
+ if (error) return reject(error);
288
+ if (result) return resolve(result.secure_url);
289
+ // DEFENSIVE: Handle case where there's no error but no result
290
+ reject(new Error("Cloudinary upload returned no result and no error."));
291
+ }
292
+ );
293
+ stream.end(buffer);
294
+ });
295
+ } catch (error) {
296
+ console.error(`[Cloudinary] Failed to upload ${publicId}:`, error);
297
+ return null;
298
+ }
299
  };
300
 
301
+ const convertToWav = (pcmData: Buffer): Promise<Buffer> => {
 
 
 
 
 
 
302
  return new Promise((resolve, reject) => {
303
+ // DEFENSIVE: Check input buffer
304
+ if (!pcmData || pcmData.length === 0) {
305
+ return reject(new Error("PCM data buffer is empty or invalid."));
306
+ }
307
  const chunks: Buffer[] = [];
308
+ const readable = new Readable({ read() { this.push(pcmData); this.push(null); } });
309
+ const writer = new wav.Writer({ channels: 1, sampleRate: 24000, bitDepth: 16 });
310
+ writer.on("data", (chunk) => chunks.push(chunk));
311
+ writer.on("end", () => resolve(Buffer.concat(chunks)));
312
+ writer.on("error", reject); // This will propagate errors to the Promise's catch block.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  readable.pipe(writer);
314
  });
315
  };
316
 
317
+ export const generateAudio = async (textToSpeak: string, heroName: string, selectedVoice: string, lineType: "intro" | "superpower"): Promise<string | null> => {
318
+ // DEFENSIVE: Check inputs
319
+ if (!textToSpeak || !heroName || !selectedVoice) {
320
+ console.error("[Audio Gen] Called with missing parameters.");
321
+ return null;
322
+ }
 
 
 
 
 
 
 
 
323
  try {
324
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
 
325
  const response = await ai.models.generateContent({
326
  model: "gemini-2.5-flash-preview-tts",
327
  contents: [{ parts: [{ text: textToSpeak }] }],
328
  config: {
329
  responseModalities: ["AUDIO"],
330
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: selectedVoice } } },
 
 
331
  },
332
  });
333
 
334
+ const data = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
 
335
  if (!data) throw new Error("No audio data from AI.");
336
+
337
  const pcmBuffer = Buffer.from(data, "base64");
338
  const wavBuffer = await convertToWav(pcmBuffer);
 
 
339
  const publicId = `${heroName.toLowerCase().replace(/ /g, "_")}_${lineType}`;
340
+
 
341
  return await uploadToCloudinary(wavBuffer, publicId, "video");
342
  } catch (error) {
343
  console.error(`[Audio Generation Error for ${heroName}]:`, error);
 
345
  }
346
  };
347
 
348
+ export const updateHero = async (_id: string, update: Partial<HeroData>): Promise<HeroData | null> => {
349
+ // DEFENSIVE: Check inputs
350
+ if (!_id || !update || Object.keys(update).length === 0) {
351
+ console.error("[updateHero] Invalid ID or empty update object provided.");
352
+ return null;
353
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  try {
355
  await connectToDatabase();
 
 
 
 
 
 
356
  const updatedHero = await Hero.findByIdAndUpdate(
357
  _id,
358
  { $set: update },
359
+ { new: true, runValidators: true } // runValidators ensures schema rules are checked
360
+ ).lean(); // Use .lean() for a plain JS object, slightly faster.
361
+
362
+ if (!updatedHero) return null;
363
 
364
+ // The result from .lean() is a plain object, no need for JSON parsing tricks.
365
+ return updatedHero as unknown as HeroData;
366
  } catch (error) {
367
+ console.error(`[updateHero] Error updating hero ${_id}:`, error);
368
  return null;
369
  }
370
  };
371
+
app/actions/db.actions.ts CHANGED
@@ -1,5 +1,4 @@
1
  "use server"
2
- import BattleSetup from "@/model/battleSetup.model";
3
  import fs from 'fs/promises';
4
  import path from 'path';
5
  import { connectToDatabase } from "@/lib/database";
@@ -14,31 +13,36 @@ import {
14
  generateAudio,
15
  uploadToCloudinary, // Assuming you have these helpers
16
  } from "./ai.actions"; // Assuming your AI actions are here
 
17
 
18
  export async function saveBattleSetup(setup: BattleSetupType): Promise<string | null> {
 
 
 
 
 
 
 
 
 
 
 
19
  try {
20
  await connectToDatabase();
21
-
22
- const newBattle = await BattleSetup.create({
23
- setupData: JSON.stringify(setup)
24
- });
25
-
26
- // Return the unique MongoDB document ID as a string
27
- return newBattle._id.toString();
28
-
29
  } catch (error) {
30
- console.error("Failed to save battle setup to DB:", error);
31
  return null;
32
  }
33
  }
34
 
35
-
36
  export async function getBattleSetup(id: string): Promise<BattleSetupType | null> {
37
  try {
38
  await connectToDatabase();
39
- const battle = await BattleSetup.findById(id);
40
  if (!battle) return null;
41
- return JSON.parse(battle.setupData) as BattleSetupType;
42
  } catch (error) {
43
  console.error("Failed to fetch battle setup:", error);
44
  return null;
@@ -46,6 +50,42 @@ export async function getBattleSetup(id: string): Promise<BattleSetupType | null
46
  }
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  export async function getAllHeroes(): Promise<HeroData[]> {
50
  try {
51
  await connectToDatabase();
@@ -183,4 +223,51 @@ export async function saveBattleRecording(
183
  const message = error instanceof Error ? error.message : "Unknown error saving recording.";
184
  return { success: false, message };
185
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  }
 
1
  "use server"
 
2
  import fs from 'fs/promises';
3
  import path from 'path';
4
  import { connectToDatabase } from "@/lib/database";
 
13
  generateAudio,
14
  uploadToCloudinary, // Assuming you have these helpers
15
  } from "./ai.actions"; // Assuming your AI actions are here
16
+ import BattleSetupModel from "@/model/battleSetup.model";
17
 
18
  export async function saveBattleSetup(setup: BattleSetupType): Promise<string | null> {
19
+ // DEFENSIVE: Validate the structure before saving.
20
+ if (!setup || !setup.type || !setup.config) {
21
+ console.error("[saveBattleSetup] Attempted to save invalid or null setup object.");
22
+ return null;
23
+ }
24
+ // DEFENSIVE: Specifically check the new heroIds structure.
25
+ if (setup.type === "HERO_BATTLE" && (!Array.isArray(setup.config.heroIds) || setup.config.heroIds.length < 2)) {
26
+ console.error("[saveBattleSetup] Hero battle setup must contain at least two hero IDs.");
27
+ return null;
28
+ }
29
+
30
  try {
31
  await connectToDatabase();
32
+ const newBattle = await BattleSetupModel.create(setup);
33
+ return (newBattle._id as { toString: () => string }).toString();
 
 
 
 
 
 
34
  } catch (error) {
35
+ console.error("⛔️ Failed to save battle setup to DB:", error);
36
  return null;
37
  }
38
  }
39
 
 
40
  export async function getBattleSetup(id: string): Promise<BattleSetupType | null> {
41
  try {
42
  await connectToDatabase();
43
+ const battle = await BattleSetupModel.findById(id);
44
  if (!battle) return null;
45
+ return JSON.parse(JSON.stringify(battle)) as BattleSetupType;
46
  } catch (error) {
47
  console.error("Failed to fetch battle setup:", error);
48
  return null;
 
50
  }
51
 
52
 
53
+ // --- NEW ACTION: getHeroesByIds ---
54
+ // This action efficiently fetches multiple hero documents in a single database call.
55
+ export async function getHeroesByIds(ids: string[]): Promise<HeroData[]> {
56
+ // DEFENSIVE: Check for valid, non-empty array of IDs.
57
+ if (!Array.isArray(ids) || ids.length === 0) {
58
+ return [];
59
+ }
60
+
61
+ try {
62
+ await connectToDatabase();
63
+ // Use the $in operator to find all documents where _id is in the provided array.
64
+ // .lean() is critical for performance here.
65
+ const heroes = await Hero.find({ '_id': { $in: ids } }).lean();
66
+
67
+ // IMPORTANT: The database does not guarantee the order of the returned documents.
68
+ // We must re-order them to match the original `ids` array to ensure consistent
69
+ // player placement in the arena (e.g., Player 1 vs Player 2).
70
+ const heroMap = new Map(heroes.map(h => [h._id && h._id.toString(), h]));
71
+ const orderedHeroes = ids
72
+ .map(id => heroMap.get(id))
73
+ .filter(Boolean)
74
+ .map(h => JSON.parse(JSON.stringify(h)) as HeroData);
75
+
76
+ // DEFENSIVE: Log if some heroes were not found (e.g., deleted after setup creation)
77
+ if (orderedHeroes.length !== ids.length) {
78
+ console.warn(`[getHeroesByIds] Mismatch: Requested ${ids.length} heroes, but only found ${orderedHeroes.length}. Some may have been deleted.`);
79
+ }
80
+
81
+ return orderedHeroes;
82
+
83
+ } catch (error) {
84
+ console.error("⛔️ Failed to fetch heroes by IDs:", error);
85
+ return []; // Return an empty array on any failure.
86
+ }
87
+ }
88
+
89
  export async function getAllHeroes(): Promise<HeroData[]> {
90
  try {
91
  await connectToDatabase();
 
223
  const message = error instanceof Error ? error.message : "Unknown error saving recording.";
224
  return { success: false, message };
225
  }
226
+ }
227
+
228
+
229
+
230
+ export async function reportHeroRuntimeError(
231
+ heroId: string,
232
+ errorMessage: string,
233
+ faultyCode: string
234
+ ): Promise<{ success: boolean }> {
235
+ // DEFENSIVE: Validate inputs
236
+ if (!heroId || !errorMessage || !faultyCode) {
237
+ console.error("[reportHeroRuntimeError] Missing required arguments.");
238
+ return { success: false };
239
+ }
240
+
241
+ try {
242
+ await connectToDatabase();
243
+
244
+ // Find the hero and update it with the error details.
245
+ // We use $inc to atomically increment the errorCount.
246
+ const result = await Hero.updateOne(
247
+ { _id: heroId },
248
+ {
249
+ $inc: { errorCount: 1 },
250
+ $set: {
251
+ lastError: {
252
+ message: errorMessage,
253
+ faultyCode: faultyCode,
254
+ reportedAt: new Date(),
255
+ },
256
+ },
257
+ }
258
+ );
259
+
260
+ // DEFENSIVE: Check if the update operation actually found and modified a document.
261
+ if (result.modifiedCount === 0) {
262
+ console.warn(`[reportHeroRuntimeError] Hero with ID ${heroId} not found or no changes made.`);
263
+ return { success: false };
264
+ }
265
+
266
+ console.log(`[reportHeroRuntimeError] Successfully logged error for hero ${heroId}.`);
267
+ return { success: true };
268
+
269
+ } catch (error) {
270
+ console.error(`⛔️ Failed to report runtime error for hero ${heroId}:`, error);
271
+ return { success: false };
272
+ }
273
  }
app/battle/[battleId]/page.tsx CHANGED
@@ -1,7 +1,8 @@
1
- import { getBattleSetup } from "@/app/actions/db.actions";
2
  import { auth } from "@/auth";
3
  import BattleArena from "@/components/BattleArena"; // The component itself
4
- import { redirect } from "next/navigation";
 
5
 
6
  // The page becomes a server component that fetches data and passes it down
7
 
@@ -23,13 +24,34 @@ const BattlePage = async ({ params, searchParams }: { params: Params, searchPar
23
  }
24
  }
25
 
 
 
26
  if (!battleSetup) {
27
- return <div>Battle not found or has expired.</div>;
28
- }
29
  // console.log(battleSetup.config.heros);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- // Pass the fetched object as a prop to your client component
32
- return <BattleArena battleSetup={battleSetup} />;
33
- };
 
34
 
35
  export default BattlePage;
 
1
+ import { getBattleSetup, getHeroesByIds } from "@/app/actions/db.actions";
2
  import { auth } from "@/auth";
3
  import BattleArena from "@/components/BattleArena"; // The component itself
4
+ import { HeroData } from "@/types";
5
+ import { notFound, redirect } from "next/navigation";
6
 
7
  // The page becomes a server component that fetches data and passes it down
8
 
 
24
  }
25
  }
26
 
27
+
28
+ // DEFENSIVE: If no setup is found for the ID, render a 404 page.
29
  if (!battleSetup) {
30
+ notFound();
31
+ }
32
  // console.log(battleSetup.config.heros);
33
+ let initialHeroes: HeroData[] = [];
34
+ // 2. If it's a hero battle, fetch the full hero data using the new action.
35
+ if (battleSetup.type === 'HERO_BATTLE') {
36
+ initialHeroes = await getHeroesByIds(battleSetup.config.heroIds);
37
+
38
+ // DEFENSIVE: Handle the critical case where the heroes for the battle couldn't be found.
39
+ // This could happen if they were deleted between setup creation and the battle start.
40
+ if (initialHeroes.length !== battleSetup.config.heroIds.length) {
41
+ return (
42
+ <div className="flex h-screen w-full items-center justify-center bg-gray-900 text-white">
43
+ <div className="text-center">
44
+ <h1 className="text-2xl font-bold text-red-500">Battle Data Error</h1>
45
+ <p className="text-gray-400 mt-2">Could not load all required heroes for this battle. They may have been deleted.</p>
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+ }
51
 
52
+ // 3. Pass all the prepared data as props to the client component.
53
+ // The client component no longer fetches its own data.
54
+ return <BattleArena battleSetup={battleSetup} initialHeroes={initialHeroes} />
55
+ }
56
 
57
  export default BattlePage;
components/BattleArena.tsx CHANGED
@@ -70,9 +70,15 @@ import { Badge } from "./ui/badge";
70
  import { Square } from "@/lib/game/Square";
71
  import { Camera } from "@/lib/game/Camera";
72
  import { drawBroadcastUI } from "@/lib/BroadcastUI";
 
73
 
74
  // BATTLE ARENA COMPONENT
75
- const BattleArena = ({ battleSetup }: { battleSetup: BattleSetup }) => {
 
 
 
 
 
76
  const canvasRef = useRef<HTMLCanvasElement>(null);
77
  const searchParams = useSearchParams();
78
  const router = useRouter();
@@ -101,7 +107,7 @@ const BattleArena = ({ battleSetup }: { battleSetup: BattleSetup }) => {
101
  const sources: AssetSource[] = [];
102
 
103
  if (fullSetup.type === "HERO_BATTLE") {
104
- fullSetup.config.heroes.forEach((hero, i) => {
105
  if (hero.avatarUrl)
106
  sources.push({
107
  id: `hero_${i}_avatar`,
@@ -129,7 +135,7 @@ const BattleArena = ({ battleSetup }: { battleSetup: BattleSetup }) => {
129
  });
130
 
131
  return sources;
132
- }, [battleSetup]);
133
 
134
  const { loadedImages, isLoading } = useAssetLoader(assetSources); // Assuming this is defined correctly
135
 
@@ -419,7 +425,7 @@ const BattleArena = ({ battleSetup }: { battleSetup: BattleSetup }) => {
419
  ];
420
 
421
  if (battleSetup.type === "HERO_BATTLE") {
422
- battleSetup.config.heroes.forEach((heroData) => {
423
  if (heroData.introLineAudioUrl) {
424
  audioLoadPromises.push(
425
  audioManager.loadSound(
@@ -835,467 +841,497 @@ const BattleArena = ({ battleSetup }: { battleSetup: BattleSetup }) => {
835
  };
836
 
837
  // --- OMNIPOTENT SANDBOX API IMPLEMENTATION ---
838
- const sandboxExecutor = (
839
- logic: string, // This 'logic' is the AST string from the DB
840
- caster: Hero,
841
- additionalContext: object = {}
842
- ) => {
843
- // We create the full API object once.
844
- const sandboxAPI = createSandboxAPI(gameState, caster);
845
- const fullContext = { ...sandboxAPI, ...additionalContext };
846
-
847
- try {
848
- // --- NEW SIMPLIFIED PIPELINE ---
849
-
850
- // The 'logic' from the DB is an arrow function string like:
851
- // `async ({ getSelf, dealDamage }) => { ... }`
852
-
853
- // We can use the Function constructor to safely evaluate this string
854
- // into a real function within the current scope.
855
- // It's safer than a direct `eval()` because it doesn't leak local variables
856
- // unless you explicitly pass them.
857
- const abilityFn = new Function("return " + logic)();
858
-
859
- // Now, call the function with the sandbox API as its argument.
860
- abilityFn(fullContext);
861
- } catch (e) {
862
- console.error("Sandbox Execution Error:", e, "Logic Code:", logic);
863
- }
864
- };
865
-
866
- // in BattleArena.tsx
867
 
868
- const createSandboxAPI = (
869
- gameCtx: typeof gameState,
870
- currentHero: Hero | null
871
- ): GameAPI => {
872
- const self = currentHero;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
 
874
- const getOpponents = () => {
875
- if (!self) return [];
876
- return gameCtx.fighters.filter(
877
- (f) => f.health > 0 && f.id !== self.id
878
- ) as Hero[];
879
- };
880
- const getAllies = () => {
881
- if (!self) return [];
882
- // An ally is any fighter (excluding self) whose teamId IS the same as our own.
883
- return gameCtx.fighters.filter(
884
- (f) =>
885
- f instanceof Hero && f.id !== self.id && f.teamId === self.teamId
886
- ) as Hero[];
 
 
 
 
 
 
 
 
 
 
 
 
 
887
  };
 
 
 
 
 
 
 
 
 
 
 
 
888
 
889
- const getDistance = (
890
- p1: { x: number; y: number },
891
- p2: { x: number; y: number }
892
- ) => {
893
- return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
894
- };
895
 
896
- const getHeroById = (id: number): Hero | null => {
897
- return (gameCtx.fighters.find((f) => f.id === id) as Hero) || null;
898
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
899
 
900
- return {
901
- // --- Core Reference & Targeting ---
902
- getSelf: () => self,
903
- getOpponents,
904
- getAllies,
905
- getHeroById,
906
- findNearestOpponent: () => {
907
- if (!self) return null;
908
- const opponents = getOpponents();
909
- if (opponents.length === 0) return null;
910
- return opponents.sort(
911
- (a, b) => getDistance(self, a) - getDistance(self, b)
912
- )[0];
913
- },
914
- getMapBoundaries: () => {
915
- // Access gameBounds from the passed gameCtx
916
- return {
917
- top: gameCtx.gameBounds.y,
918
- bottom: gameCtx.gameBounds.y + gameCtx.gameBounds.height,
919
- left: gameCtx.gameBounds.x,
920
- right: gameCtx.gameBounds.x + gameCtx.gameBounds.width,
921
- };
922
- },
923
- findFarthestOpponent: () => {
924
- if (!self) return null;
925
- const opponents = getOpponents();
926
- if (opponents.length === 0) return null;
927
- return opponents.sort(
928
- (a, b) => getDistance(self, b) - getDistance(self, a)
929
- )[0];
930
- },
931
- findLowestHealthOpponent: () => {
932
- if (!self) return null;
933
- return getOpponents().sort((a, b) => a.health - b.health)[0] || null;
934
- },
935
 
936
- // --- Core Actions ---
937
- dealDamage: (
938
- target,
939
- amount,
940
- sourceType: "basic" | "superpower" | "other" = "superpower"
941
- ) => {
942
- // Defaulting to 'superpower' is a smart choice because the sandbox API
943
- // is primarily used by superpower logic. This avoids breaking existing abilities.
944
- console.log(target);
945
-
946
- target.takeDamage(amount, sourceType);
947
- },
948
 
949
- heal: (target, amount) => target.heal(amount),
950
- applyForce: (target, angle, magnitude) => {
951
- target.dx += Math.cos(angle) * magnitude * 0.1;
952
- target.dy += Math.sin(angle) * magnitude * 0.1;
953
- },
954
- modifyHero: (target, modifications, duration) => {
955
- if (modifications.stunDuration)
956
- target.stunTimer = Math.max(
957
- target.stunTimer,
958
- modifications.stunDuration
959
- );
960
- if (modifications.isGhost)
961
- target.ghostTimer = Math.max(target.ghostTimer, duration);
962
- if (modifications.speedFactor)
963
- target.speedBoostTimer = Math.max(target.speedBoostTimer, duration);
964
- },
965
- teleport: (target, x, y) => {
966
- target.x = x;
967
- target.y = y;
968
- },
969
 
970
- spawnClone: (caster: Hero, duration: number): number | null => {
971
- if (!caster) return null;
972
-
973
- const newId = gameCtx.nextObjectId++;
974
-
975
- // Create a new Hero instance using the caster's own data
976
- const clone = new Hero(
977
- caster.superpower, // This holds the original HeroData
978
- newId,
979
- caster.x, // Spawn at the caster's position
980
- caster.y,
981
- //@ts-expect-error: Hero constructor expects a sandboxExecutor, but type system does not recognize this property on fighters array elements.
982
- gameCtx.fighters.find((f) => f.id === caster.id)!.sandboxExecutor, // Use the same executor
983
- {
984
- isSummon: true,
985
- ownerId: caster.id,
986
- lifetime: duration, // Duration in frames
987
- teamId: caster.teamId, // <<< NEW: The clone inherits the caster's teamId
988
- }
989
- );
990
 
991
- // Copy over the visual assets
992
- clone.avatarImage = caster.avatarImage;
993
- clone.iconImage = caster.iconImage;
 
994
 
995
- // Add the new clone to the main fighters array!
996
- gameCtx.fighters.push(clone);
 
 
 
 
 
997
 
998
- return newId; // Return the ID so the ability can track it
999
- },
1000
- spawnBasicProjectile: (from, to, damage, knockback) => {
1001
- if (!self || !currentHero) return;
1002
- const startX = from.x + from.width / 2;
1003
- const startY = from.y + from.height / 2;
1004
- const endX = to.x + to.width / 2;
1005
- const endY = to.y + to.height / 2;
1006
- const angle = Math.atan2(endY - startY, endX - startX);
1007
-
1008
- const projectile = new Projectile({
1009
- id: gameCtx.nextObjectId++,
1010
- owner: self,
1011
- startX,
1012
- startY,
1013
- velocityX: Math.cos(angle) * 15, // Standard speed
1014
- velocityY: Math.sin(angle) * 15,
1015
- size: 15,
1016
- lifetime: 120, // 2 seconds
1017
- // Default onHit for basic attacks
1018
- onHit: (context) => {
1019
- const { target, applyForce, dealDamage } = context;
1020
- dealDamage(target, damage, "basic");
1021
- if (knockback) {
1022
- const angle = context.getAngle(context.projectile, target);
1023
- applyForce(target, angle, knockback);
1024
- }
1025
- },
1026
- // Default drawLogic using hero's color
1027
- drawLogic: (p, ctx) => {
1028
- ctx.fillStyle = p.owner.color;
1029
- ctx.shadowColor = p.owner.color;
1030
- ctx.shadowBlur = 15;
1031
- ctx.beginPath();
1032
- ctx.arc(
1033
- p.x + p.size / 2,
1034
- p.y + p.size / 2,
1035
- p.size / 2,
1036
- 0,
1037
- Math.PI * 2
1038
- );
1039
- ctx.fill();
1040
- },
1041
- });
1042
- gameCtx.projectiles.push(projectile);
1043
- },
1044
- // --- Object Spawning ---
1045
- spawnProjectile: (options: ProjectileAIOptions) => {
1046
- if (!self) return;
1047
- const startX = options.from.x + options.from.width / 2;
1048
- const startY = options.from.y + options.from.height / 2;
1049
- const endX = options.to.x + options.to.width / 2;
1050
- const endY = options.to.y + options.to.height / 2;
1051
-
1052
- getDistance({ x: startX, y: startY }, { x: endX, y: endY });
1053
- const angle = Math.atan2(endY - startY, endX - startX);
1054
- // Normalize the AI's "speed" value into something reasonable.
1055
- // Let's assume a "speed" of 20 is standard.
1056
- // A higher AI speed means a faster projectile.
1057
- const normalizedSpeed = options.speed / 100; // AI's 2200 becomes 22. This is a reasonable velocity.
1058
-
1059
- const constructorOptions: ProjectileConstructorOptions = {
1060
- id: gameCtx.nextObjectId++,
1061
- owner: self,
1062
- startX,
1063
- startY,
1064
- velocityX: Math.cos(angle) * normalizedSpeed,
1065
- velocityY: Math.sin(angle) * normalizedSpeed,
1066
- size: options.size,
1067
- lifetime: options.lifetime,
1068
- onHit: options.onHit,
1069
- drawLogic: options.drawLogic,
1070
- };
1071
- gameCtx.projectiles.push(new Projectile(constructorOptions));
1072
- },
1073
- spawnZone: (options: ZoneAIOptions) => {
1074
- if (!self) return;
1075
- const parentObject = options.parent;
1076
- const isAttachedToHero =
1077
- "id" in parentObject && typeof parentObject.id === "number";
1078
-
1079
- const constructorOptions: ZoneConstructorOptions = {
1080
- id: gameCtx.nextObjectId++,
1081
- owner: self,
1082
- parent: parentObject,
1083
- shape: "circle",
1084
- x:
1085
- parentObject.x +
1086
- (isAttachedToHero ? (parentObject as Hero).width / 2 : 0),
1087
- y:
1088
- parentObject.y +
1089
- (isAttachedToHero ? (parentObject as Hero).height / 2 : 0),
1090
- radius: options.radius,
1091
- width: options.radius * 2,
1092
- height: options.radius * 2,
1093
- lifetime: options.duration,
1094
- maxDuration: options.duration, // Added property
1095
- progress: 0, // Added property, initialize as needed
1096
- onStay: options.onStay,
1097
- drawLogic: options.drawLogic,
 
1098
  attachTo: isAttachedToHero ? (parentObject as Hero) : undefined,
1099
- };
1100
- gameCtx.zones.push(new Zone(constructorOptions));
1101
- },
1102
-
1103
- // --- Visuals & Effects ---
1104
- spawnParticle: (
1105
- casterId,
1106
- startX,
1107
- startY,
1108
- lifetime,
1109
- updateFn,
1110
- drawFn,
1111
- drawLayer,
1112
- initialState
1113
- ) => {
1114
- gameCtx.effectManager.spawnParticle(
1115
- casterId,
1116
- startX,
1117
- startY,
1118
- lifetime,
1119
- updateFn,
1120
- drawFn,
1121
- drawLayer,
1122
- initialState
1123
- );
1124
- },
1125
- drawPath: (from, to, duration, drawFn, drawLayer) => {
1126
- gameCtx.effectManager.drawPath(from, to, duration, drawFn, drawLayer);
1127
- },
1128
- drawText: (text, x, y, options = {}) => {
1129
- const lifetime = options.duration || 60;
1130
- gameCtx.effectManager.spawnParticle(
1131
- self ? self.id : null,
1132
- x,
1133
- y,
1134
- lifetime,
1135
- (p) => {
1136
- p.y -= 0.5;
1137
- },
1138
- (p, ctx) => {
1139
- ctx.font = options.font || "bold 24px sans-serif";
1140
- ctx.fillStyle = options.color || "white";
1141
- ctx.textAlign = options.align || "center";
1142
- ctx.globalAlpha = p.progress;
1143
- if (options.shadowColor) {
1144
- ctx.shadowColor = options.shadowColor;
1145
- ctx.shadowBlur = options.shadowBlur || 5;
1146
- }
1147
- ctx.fillText(text, p.x, p.y);
1148
- },
1149
- options.drawLayer || 4
1150
- );
1151
  },
1152
- drawShape: (shape, x, y, options = {}) => {
1153
- const lifetime = options.duration || 60;
1154
- gameCtx.effectManager.spawnParticle(
1155
- self ? self.id : null,
1156
- x,
1157
- y,
1158
- lifetime,
1159
- () => {},
1160
- (p, ctx) => {
1161
- ctx.fillStyle = options.color || "white";
1162
- ctx.globalAlpha = (options.alpha ?? 1) * p.progress;
1163
- if (options.shadowColor) {
1164
- ctx.shadowColor = options.shadowColor;
1165
- ctx.shadowBlur = options.shadowBlur || 10;
1166
- }
1167
- if (shape === "circle") {
1168
- ctx.beginPath();
1169
- ctx.arc(p.x, p.y, options.radius || 50, 0, 2 * Math.PI);
1170
- ctx.fill();
1171
- } else {
1172
- const width = options.width || 100;
1173
- const height = options.height || 100;
1174
- ctx.fillRect(p.x - width / 2, p.y - height / 2, width, height);
1175
- }
1176
- },
1177
- options.drawLayer || 1
1178
  );
 
1179
  },
1180
-
1181
- // --- Cinematics & Time ---
1182
- setGameSpeed: (factor, duration) => {
1183
- gameCtx.gameSpeedModifier = { factor, duration };
1184
- },
1185
- scheduleAction: (delayFrames, callback) => {
1186
- if (!self) return;
1187
- gameCtx.scheduledActions.push({
1188
- delayFrames,
1189
- action: callback,
1190
- context: {},
1191
- casterId: self.id,
1192
- });
1193
- },
1194
- panCameraTo: (x, y) => gameCtx.camera.panTo(x, y),
1195
- zoomCamera: (level) => gameCtx.camera.setZoom(level),
1196
- focusCameraOn: (target) => gameCtx.camera.focusOn(target),
1197
- resetCamera: () => gameCtx.camera.reset(),
1198
- shakeCamera: (magnitude, duration) =>
1199
- gameCtx.cameraShake.shake(magnitude, duration),
1200
-
1201
- // --- Utilities & Hero State ---
1202
- playSound: (soundName) => audioManager.playSound(soundName),
1203
- getCanvasDimensions: () => ({
1204
- width: canvas.width,
1205
- height: canvas.height,
1206
- }),
1207
- getRandomNumber: (min, max) => Math.random() * (max - min) + min,
1208
- getAngle: (p1, p2) => Math.atan2(p2.y - p1.y, p2.x - p1.x),
1209
- getDistance,
1210
- raycast: (startX, startY, angle, length, width): Hero[] => {
1211
- if (!self) return [];
1212
- const hits: Hero[] = [];
1213
- const stepX = Math.cos(angle) * 10;
1214
- const stepY = Math.sin(angle) * 10;
1215
- for (let i = 0; i < length / 10; i++) {
1216
- const checkX = startX + i * stepX;
1217
- const checkY = startY + i * stepY;
1218
- for (const fighter of gameCtx.fighters) {
1219
- if (
1220
- fighter.id === self.id ||
1221
- !(fighter instanceof Hero) ||
1222
- hits.includes(fighter)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1223
  )
1224
- continue;
1225
- if (
1226
- checkCollision(
1227
- {
1228
- x: checkX - width / 2,
1229
- y: checkY - width / 2,
1230
- width: width,
1231
- height: width,
1232
- },
1233
- fighter
1234
- )
1235
- ) {
1236
- hits.push(fighter);
1237
- }
1238
  }
1239
  }
1240
- return hits;
1241
- },
1242
- setHeroData: (target, key, value) => target.setData(key, value),
1243
- getHeroData: (target, key) => target.getData(key),
1244
- };
1245
- };
1246
-
1247
  // --- Battle Initialization ---
1248
  let logger: HighlightLogger;
1249
  const heroDataMap = new Map<number, HeroData>();
1250
 
1251
- if (fullSetup.type === "HERO_BATTLE") {
1252
- const config = fullSetup.config;
 
 
 
 
 
 
1253
 
1254
- const spawnPoints = [
1255
- { x: gameBounds.x + 100, y: gameBounds.y + 100 },
1256
- {
1257
- x: gameBounds.x + gameBounds.width - 200,
1258
- y: gameBounds.y + gameBounds.height - 200,
1259
- },
1260
- { x: gameBounds.x + 100, y: gameBounds.y + gameBounds.height - 200 },
1261
- { x: gameBounds.x + gameBounds.width - 200, y: gameBounds.y + 100 },
1262
- { x: gameBounds.x + gameBounds.width / 2, y: gameBounds.y + 100 },
1263
- { x: gameBounds.x + 100, y: gameBounds.y + gameBounds.height / 2 },
1264
- {
1265
- x: gameBounds.x + gameBounds.width - 200,
1266
- y: gameBounds.y + gameBounds.height / 2,
1267
- },
1268
- {
1269
- x: gameBounds.x + gameBounds.width / 2,
1270
- y: gameBounds.y + gameBounds.height - 200,
1271
- },
1272
- ];
 
1273
 
1274
- gameState.fighters = config.heroes.map((heroData, i) => {
 
 
1275
  const runtimeId = Date.now() + i;
1276
  const hero = new Hero(
1277
  heroData,
1278
- Date.now() + i,
1279
  spawnPoints[i].x,
1280
  spawnPoints[i].y,
1281
  sandboxExecutor,
1282
  {}
1283
  );
1284
- console.log(hero);
1285
 
1286
  hero.avatarImage = loadedImages[`hero_${i}_avatar`];
1287
  hero.iconImage = loadedImages[`hero_${i}_icon`];
1288
-
1289
  heroDataMap.set(runtimeId, heroData);
1290
-
1291
  return hero;
1292
  });
1293
- logger = new HighlightLogger(
1294
- gameState.fighters.map((f) => ({ id: f.id, name: f.name }))
1295
- );
1296
- } else {
1297
  // 'CUSTOM_BATTLE'
1298
- const config = fullSetup.config;
 
1299
  const spawnPoints = [
1300
  { x: gameBounds.x + 50, y: gameBounds.y + 50 },
1301
  { x: gameBounds.x + gameBounds.width - 150, y: gameBounds.y + 50 },
@@ -1957,7 +1993,7 @@ const BattleArena = ({ battleSetup }: { battleSetup: BattleSetup }) => {
1957
  // Determine the list of players based on the battle type
1958
  players:
1959
  fullSetup.type === "HERO_BATTLE"
1960
- ? fullSetup.config.heroes.map((heroData) => {
1961
  // For heroes, find their randomly assigned color from the game instance
1962
  const fighterInstance = gameState.fighters.find(
1963
  (f) => f.name === heroData.heroName
 
70
  import { Square } from "@/lib/game/Square";
71
  import { Camera } from "@/lib/game/Camera";
72
  import { drawBroadcastUI } from "@/lib/BroadcastUI";
73
+ import { reportHeroRuntimeError } from "@/app/actions/db.actions";
74
 
75
  // BATTLE ARENA COMPONENT
76
+ const BattleArena = ({ battleSetup,
77
+ initialHeroes, // Receive the pre-fetched hero data
78
+ }: {
79
+ battleSetup: BattleSetup;
80
+ initialHeroes: HeroData[];
81
+ }) => {
82
  const canvasRef = useRef<HTMLCanvasElement>(null);
83
  const searchParams = useSearchParams();
84
  const router = useRouter();
 
107
  const sources: AssetSource[] = [];
108
 
109
  if (fullSetup.type === "HERO_BATTLE") {
110
+ initialHeroes.forEach((hero, i) => {
111
  if (hero.avatarUrl)
112
  sources.push({
113
  id: `hero_${i}_avatar`,
 
135
  });
136
 
137
  return sources;
138
+ }, [battleSetup,initialHeroes]);
139
 
140
  const { loadedImages, isLoading } = useAssetLoader(assetSources); // Assuming this is defined correctly
141
 
 
425
  ];
426
 
427
  if (battleSetup.type === "HERO_BATTLE") {
428
+ initialHeroes.forEach((heroData) => {
429
  if (heroData.introLineAudioUrl) {
430
  audioLoadPromises.push(
431
  audioManager.loadSound(
 
841
  };
842
 
843
  // --- OMNIPOTENT SANDBOX API IMPLEMENTATION ---
844
+ // --- REFACTORED & BULLETPROOF SANDBOX EXECUTOR ---
845
+ const sandboxExecutor = (
846
+ logic: string,
847
+ caster: Hero,
848
+ additionalContext: object = {}
849
+ ) => {
850
+ // DEFENSIVE: Do not execute if caster or logic is invalid.
851
+ if (!caster || !logic || typeof logic !== "string") {
852
+ console.error("[Sandbox] Executor called with invalid caster or logic.");
853
+ return;
854
+ }
855
+
856
+ // Create the full API object for the AI code to use.
857
+ const sandboxAPI = createSandboxAPI(gameState, caster);
858
+ const fullContext = { ...sandboxAPI, ...additionalContext };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
 
860
+ try {
861
+ // Safely construct the function from the AI's code string.
862
+ const abilityFn = new Function("return " + logic)();
863
+
864
+ // Attempt to execute the AI's superpower function.
865
+ // This is the most dangerous line of code in the component.
866
+ abilityFn(fullContext);
867
+
868
+ } catch (e: unknown) {
869
+ // --- CRASH PREVENTION & ERROR REPORTING ---
870
+ const error = e instanceof Error ? e : new Error(String(e));
871
+
872
+ // 1. Log the error to the browser console for immediate debugging.
873
+ console.error("⛔️ SANDBOX EXECUTION CRASH CAUGHT ⛔️");
874
+ console.error(`Hero: ${caster.name} (ID: ${caster.id})`);
875
+ console.error(`Error: ${error.message}`);
876
+ console.error("Faulty Code:", logic);
877
+
878
+ // 2. Display a non-intrusive toast notification to the user.
879
+ toast.error(`"${caster.name}" superpower failed!`, {
880
+ description: "A runtime error occurred in the hero's logic.",
881
+ });
882
 
883
+ // 3. Trigger a visual "fizzle" effect in-game to provide feedback.
884
+ // This makes the failure feel like part of the game, not a bug.
885
+ const fizzleEffect = () => {
886
+ const centerX = caster.x + caster.width / 2;
887
+ const centerY = caster.y + caster.height / 2;
888
+ // Use the sandbox API to create the effect.
889
+ sandboxAPI.playSound('powerup_fail'); // A sad trombone or dud sound.
890
+ // Smoke puff particle effect
891
+ for (let i = 0; i < 20; i++) {
892
+ sandboxAPI.spawnParticle(
893
+ caster.id,
894
+ centerX, centerY,
895
+ 30 + Math.random() * 20,
896
+ (p) => { // updateFn
897
+ p.x += (Math.random() - 0.5) * 2;
898
+ p.y += (Math.random() - 0.5) * 2 - 0.5; // slow rise
899
+ },
900
+ (p, ctx) => { // drawFn
901
+ ctx.fillStyle = `rgba(100, 100, 100, ${p.progress * 0.5})`;
902
+ ctx.beginPath();
903
+ ctx.arc(p.x, p.y, p.progress * 20, 0, Math.PI * 2);
904
+ ctx.fill();
905
+ },
906
+ 2 // Draw layer
907
+ );
908
+ }
909
  };
910
+ fizzleEffect();
911
+
912
+ // 4. Asynchronously report the error to the database.
913
+ // We do not `await` this. It's a fire-and-forget operation.
914
+ // The battle should not pause while we report an error.
915
+ reportHeroRuntimeError(caster.superpower._id!, error.message, logic)
916
+ .catch(reportError => {
917
+ // DEFENSIVE: Catch potential errors in the reporting process itself.
918
+ console.error("CRITICAL: Failed to report hero error to the server.", reportError);
919
+ });
920
+ }
921
+ };
922
 
923
+ // in BattleArena.tsx
 
 
 
 
 
924
 
925
+ const createSandboxAPI = (
926
+ gameCtx: typeof gameState,
927
+ currentHero: Hero | null
928
+ ): GameAPI => {
929
+ const self = currentHero;
930
+
931
+ // --- SHIELD WRAPPER ---
932
+ // This is the core of our defense. It's a higher-order function
933
+ // that takes an AI-provided callback and returns a new, "shielded" version.
934
+ const shieldCallback = <T extends (...args: never[]) => unknown>(
935
+ aiCallback: T | undefined,
936
+ callbackName: string // For logging purposes
937
+ ): T | undefined => {
938
+ // If the AI didn't provide a callback, do nothing.
939
+ if (!aiCallback || typeof aiCallback !== 'function') {
940
+ return undefined;
941
+ }
942
 
943
+ // Return a new function that wraps the original AI code in a try-catch.
944
+ return ((...args: Parameters<T>): ReturnType<T> | void => {
945
+ try {
946
+ // Attempt to execute the original AI-provided code.
947
+ return aiCallback(...args) as void | ReturnType<T>;
948
+ } catch (e: unknown) {
949
+ // If a crash happens inside the callback, we catch it here.
950
+ const error = e instanceof Error ? e : new Error(String(e));
951
+
952
+ // Prevent log spam: only report the error once per hero's superpower activation.
953
+ if (self && !self.getData('hasReportedError')) {
954
+ console.error(`⛔️ RUNTIME CRASH IN SANDBOX CALLBACK: ${callbackName} ⛔️`);
955
+ console.error(`Hero: ${self.name} (ID: ${self.id})`);
956
+ console.error(`Error: ${error.message}`);
957
+ console.error("Originating Power Logic:", self.superpower.powerLogic);
958
+
959
+ toast.error(`"${self.name}" superpower failed mid-effect!`, {
960
+ description: `Error in ${callbackName}: ${error.message.substring(0, 50)}...`,
961
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
962
 
963
+ // Report the error to the database for the asynchronous repair pipeline.
964
+ // Note the use of `self.superpower._id!` which is the permanent database ID.
965
+ reportHeroRuntimeError(self.superpower._id!, error.message, self.superpower.powerLogic)
966
+ .catch(reportError => console.error("CRITICAL: Failed to report hero error to server.", reportError));
 
 
 
 
 
 
 
 
967
 
968
+ // Set a flag on the hero's runtime instance to prevent this error report from firing again.
969
+ self.setData('hasReportedError', true);
970
+ }
971
+ // We return `void` to stop execution flow after a crash.
972
+ }
973
+ }) as T;
974
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
975
 
976
+ // --- Helper Functions (for clarity and re-use) ---
977
+ const getOpponents = () => {
978
+ if (!self) return [];
979
+ return gameCtx.fighters.filter(f => f.health > 0 && f.id !== self.id && f instanceof Hero) as Hero[];
980
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
 
982
+ const getAllies = () => {
983
+ if (!self) return [];
984
+ return gameCtx.fighters.filter(f => f instanceof Hero && f.id !== self.id && f.teamId === self.teamId) as Hero[];
985
+ };
986
 
987
+ const getHeroById = (id: number): Hero | null => {
988
+ return (gameCtx.fighters.find((f) => f.id === id) as Hero) || null;
989
+ };
990
+
991
+ const getDistance = (p1: { x: number; y: number }, p2: { x: number; y: number }) => {
992
+ return Math.hypot(p2.x - p1.x, p2.y - p1.y);
993
+ };
994
 
995
+ // --- THE FULL, SHIELDED GAME API ---
996
+ return {
997
+ // --- Core Reference & Targeting (No Callbacks - Intrinsically Safe) ---
998
+ getSelf: () => self,
999
+ getOpponents,
1000
+ getAllies,
1001
+ getHeroById,
1002
+ findNearestOpponent: () => {
1003
+ if (!self) return null;
1004
+ const opponents = getOpponents();
1005
+ if (opponents.length === 0) return null;
1006
+ return opponents.sort((a, b) => getDistance(self, a) - getDistance(self, b))[0];
1007
+ },
1008
+ findFarthestOpponent: () => {
1009
+ if (!self) return null;
1010
+ const opponents = getOpponents();
1011
+ if (opponents.length === 0) return null;
1012
+ return opponents.sort((a, b) => getDistance(self, b) - getDistance(self, a))[0];
1013
+ },
1014
+ findLowestHealthOpponent: () => {
1015
+ if (!self) return null;
1016
+ const opponents = getOpponents();
1017
+ return opponents.sort((a, b) => a.health - b.health)[0] || null;
1018
+ },
1019
+ getMapBoundaries: () => ({
1020
+ top: gameCtx.gameBounds.y,
1021
+ bottom: gameCtx.gameBounds.y + gameCtx.gameBounds.height,
1022
+ left: gameCtx.gameBounds.x,
1023
+ right: gameCtx.gameBounds.x + gameCtx.gameBounds.width,
1024
+ }),
1025
+
1026
+ // --- Core Actions (No Callbacks - Intrinsically Safe) ---
1027
+ dealDamage: (target, amount, sourceType: "basic" | "superpower" | "other" = "superpower") => {
1028
+ if(target) target.takeDamage(amount, sourceType);
1029
+ },
1030
+ heal: (target, amount) => {
1031
+ if(target) target.heal(amount);
1032
+ },
1033
+ applyForce: (target, angle, magnitude) => {
1034
+ if(target) {
1035
+ target.dx += Math.cos(angle) * magnitude * 0.1;
1036
+ target.dy += Math.sin(angle) * magnitude * 0.1;
1037
+ }
1038
+ },
1039
+ modifyHero: (target, modifications, duration) => {
1040
+ if(!target) return;
1041
+ if (modifications.stunDuration) target.stunTimer = Math.max(target.stunTimer, modifications.stunDuration);
1042
+ if (modifications.isGhost) target.ghostTimer = Math.max(target.ghostTimer, duration);
1043
+ if (modifications.speedFactor) target.speedBoostTimer = Math.max(target.speedBoostTimer, duration);
1044
+ },
1045
+ teleport: (target, x, y) => {
1046
+ if(target) {
1047
+ target.x = x;
1048
+ target.y = y;
1049
+ }
1050
+ },
1051
+
1052
+ // --- Object Spawning (Callbacks MUST be shielded) ---
1053
+ spawnProjectile: (options: ProjectileAIOptions) => {
1054
+ if (!self || !options) return;
1055
+ const startX = options.from.x + options.from.width / 2;
1056
+ const startY = options.from.y + options.from.height / 2;
1057
+ const endX = options.to.x + options.to.width / 2;
1058
+ const endY = options.to.y + options.to.height / 2;
1059
+ const angle = Math.atan2(endY - startY, endX - startX);
1060
+ const normalizedSpeed = (options.speed || 1500) / 100;
1061
+
1062
+ const constructorOptions: ProjectileConstructorOptions = {
1063
+ id: gameCtx.nextObjectId++, owner: self, startX, startY,
1064
+ velocityX: Math.cos(angle) * normalizedSpeed,
1065
+ velocityY: Math.sin(angle) * normalizedSpeed,
1066
+ size: options.size, lifetime: options.lifetime,
1067
+ onHit: shieldCallback(options.onHit, 'projectile.onHit')!,
1068
+ drawLogic: shieldCallback(options.drawLogic, 'projectile.drawLogic'),
1069
+ };
1070
+ gameCtx.projectiles.push(new Projectile(constructorOptions));
1071
+ },
1072
+ spawnClone: (caster: Hero, duration: number): number | null => {
1073
+ if (!caster) return null;
1074
+ const newId = gameCtx.nextObjectId++;
1075
+ const clone = new Hero(caster.superpower, newId, caster.x, caster.y, (logic, hero) => sandboxExecutor(logic, hero), {
1076
+ isSummon: true, ownerId: caster.id, lifetime: duration, teamId: caster.teamId,
1077
+ });
1078
+ clone.avatarImage = caster.avatarImage;
1079
+ clone.iconImage = caster.iconImage;
1080
+ gameCtx.fighters.push(clone);
1081
+ return newId;
1082
+ },
1083
+ spawnZone: (options: ZoneAIOptions) => {
1084
+ if (!self || !options) return;
1085
+ const parentObject = options.parent;
1086
+ const isAttachedToHero = "id" in parentObject && typeof parentObject.id === "number";
1087
+
1088
+ const constructorOptions: ZoneConstructorOptions = {
1089
+ id: gameCtx.nextObjectId++, owner: self, parent: parentObject, shape: 'circle',
1090
+ x: parentObject.x + (isAttachedToHero ? (parentObject as Hero).width / 2 : 0),
1091
+ y: parentObject.y + (isAttachedToHero ? (parentObject as Hero).height / 2 : 0),
1092
+ radius: options.radius, width: options.radius * 2, height: options.radius * 2,
1093
+ lifetime: options.duration, maxDuration: options.duration, progress: 1.0,
1094
+ onStay: shieldCallback(options.onStay, 'zone.onStay'),
1095
+ drawLogic: shieldCallback(options.drawLogic, 'zone.drawLogic'),
1096
  attachTo: isAttachedToHero ? (parentObject as Hero) : undefined,
1097
+ };
1098
+ gameCtx.zones.push(new Zone(constructorOptions));
1099
+ },
1100
+ spawnBasicProjectile: (from, to, damage, knockback) => {
1101
+ // This function's onHit logic is hardcoded by us, not the AI, so it doesn't need a shield.
1102
+ if (!self || !from || !to) return;
1103
+ const startX = from.x + from.width / 2;
1104
+ const startY = from.y + from.height / 2;
1105
+ const endX = to.x + to.width / 2;
1106
+ const endY = to.y + to.height / 2;
1107
+ const angle = Math.atan2(endY - startY, endX - startX);
1108
+
1109
+ const projectile = new Projectile({
1110
+ id: gameCtx.nextObjectId++,
1111
+ owner: self,
1112
+ startX,
1113
+ startY,
1114
+ velocityX: Math.cos(angle) * 15, // Standard speed
1115
+ velocityY: Math.sin(angle) * 15,
1116
+ size: 15,
1117
+ lifetime: 120, // 2 seconds
1118
+ // Default onHit for basic attacks
1119
+ onHit: (context) => {
1120
+ const { target, applyForce, dealDamage } = context;
1121
+ dealDamage(target, damage, "basic");
1122
+ if (knockback) {
1123
+ const angle = context.getAngle(context.projectile, target);
1124
+ applyForce(target, angle, knockback);
1125
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1126
  },
1127
+ // Default drawLogic using hero's color
1128
+ drawLogic: (p, ctx) => {
1129
+ ctx.fillStyle = p.owner.color;
1130
+ ctx.shadowColor = p.owner.color;
1131
+ ctx.shadowBlur = 15;
1132
+ ctx.beginPath();
1133
+ ctx.arc(
1134
+ p.x + p.size / 2,
1135
+ p.y + p.size / 2,
1136
+ p.size / 2,
1137
+ 0,
1138
+ Math.PI * 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1139
  );
1140
+ ctx.fill();
1141
  },
1142
+ });
1143
+ gameCtx.projectiles.push(projectile);
1144
+ },
1145
+
1146
+ // --- Visuals & Effects (Callbacks MUST be shielded where applicable) ---
1147
+ spawnParticle: (casterId, startX, startY, lifetime, updateFn, drawFn, drawLayer, initialState) => {
1148
+ gameCtx.effectManager.spawnParticle(
1149
+ casterId, startX, startY, lifetime,
1150
+ shieldCallback(updateFn, 'particle.updateFn')!,
1151
+ shieldCallback(drawFn, 'particle.drawFn')!,
1152
+ drawLayer, initialState
1153
+ );
1154
+ },
1155
+ drawPath: (from, to, duration, drawFn, drawLayer) => {
1156
+ if (drawFn) {
1157
+ gameCtx.effectManager.drawPath(from, to, duration, shieldCallback(drawFn, 'path.drawFn')!, drawLayer);
1158
+ }
1159
+ },
1160
+ drawText: (text, x, y, options = {}) => {
1161
+ // This function's callbacks are hardcoded by us, so it is intrinsically safe.
1162
+ const lifetime = options.duration || 60;
1163
+ gameCtx.effectManager.spawnParticle(
1164
+ self ? self.id : null,
1165
+ x,
1166
+ y,
1167
+ lifetime,
1168
+ (p) => {
1169
+ p.y -= 0.5;
1170
+ },
1171
+ (p, ctx) => {
1172
+ ctx.font = options.font || "bold 24px sans-serif";
1173
+ ctx.fillStyle = options.color || "white";
1174
+ ctx.textAlign = options.align || "center";
1175
+ ctx.globalAlpha = p.progress;
1176
+ if (options.shadowColor) {
1177
+ ctx.shadowColor = options.shadowColor;
1178
+ ctx.shadowBlur = options.shadowBlur || 5;
1179
+ }
1180
+ ctx.fillText(text, p.x, p.y);
1181
+ },
1182
+ options.drawLayer || 4
1183
+ );
1184
+ },
1185
+ drawShape: (shape, x, y, options = {}) => {
1186
+ // This function's callbacks are hardcoded by us, so it is intrinsically safe.
1187
+ const lifetime = options.duration || 60;
1188
+ gameCtx.effectManager.spawnParticle(
1189
+ self ? self.id : null,
1190
+ x,
1191
+ y,
1192
+ lifetime,
1193
+ () => {},
1194
+ (p, ctx) => {
1195
+ ctx.fillStyle = options.color || "white";
1196
+ ctx.globalAlpha = (options.alpha ?? 1) * p.progress;
1197
+ if (options.shadowColor) {
1198
+ ctx.shadowColor = options.shadowColor;
1199
+ ctx.shadowBlur = options.shadowBlur || 10;
1200
+ }
1201
+ if (shape === "circle") {
1202
+ ctx.beginPath();
1203
+ ctx.arc(p.x, p.y, options.radius || 50, 0, 2 * Math.PI);
1204
+ ctx.fill();
1205
+ } else {
1206
+ const width = options.width || 100;
1207
+ const height = options.height || 100;
1208
+ ctx.fillRect(p.x - width / 2, p.y - height / 2, width, height);
1209
+ }
1210
+ },
1211
+ options.drawLayer || 1
1212
+ );
1213
+ },
1214
+
1215
+ // --- Cinematics & Time ---
1216
+ setGameSpeed: (factor, duration) => {
1217
+ gameCtx.gameSpeedModifier = { factor, duration };
1218
+ },
1219
+ scheduleAction: (delayFrames, callback) => {
1220
+ if (!self || !callback) return;
1221
+ // This is a critical one to shield.
1222
+ const shieldedAction = shieldCallback(callback, 'scheduleAction');
1223
+ if (shieldedAction) {
1224
+ gameCtx.scheduledActions.push({
1225
+ delayFrames, action: shieldedAction, casterId: self.id, context: {}
1226
+ });
1227
+ }
1228
+ },
1229
+ panCameraTo: (x, y) => gameCtx.camera.panTo(x, y),
1230
+ zoomCamera: (level) => gameCtx.camera.setZoom(level),
1231
+ focusCameraOn: (target) => gameCtx.camera.focusOn(target),
1232
+ resetCamera: () => gameCtx.camera.reset(),
1233
+ shakeCamera: (magnitude, duration) => gameCtx.cameraShake.shake(magnitude, duration),
1234
+
1235
+ // --- Utilities & Hero State (No Callbacks - Intrinsically Safe) ---
1236
+ playSound: (soundName) => audioManager.playSound(soundName),
1237
+ getCanvasDimensions: () => ({ width: canvasRef.current?.width || 0, height: canvasRef.current?.height || 0 }),
1238
+ getRandomNumber: (min, max) => Math.random() * (max - min) + min,
1239
+ getAngle: (p1, p2) => Math.atan2(p2.y - p1.y, p2.x - p1.x),
1240
+ getDistance,
1241
+ raycast: (startX, startY, angle, length, width): Hero[] => {
1242
+ if (!self) return [];
1243
+ const hits: Hero[] = [];
1244
+ const stepX = Math.cos(angle) * 10;
1245
+ const stepY = Math.sin(angle) * 10;
1246
+ for (let i = 0; i < length / 10; i++) {
1247
+ const checkX = startX + i * stepX;
1248
+ const checkY = startY + i * stepY;
1249
+ for (const fighter of gameCtx.fighters) {
1250
+ if (
1251
+ fighter.id === self.id ||
1252
+ !(fighter instanceof Hero) ||
1253
+ hits.includes(fighter)
1254
+ )
1255
+ continue;
1256
+ if (
1257
+ checkCollision(
1258
+ {
1259
+ x: checkX - width / 2,
1260
+ y: checkY - width / 2,
1261
+ width: width,
1262
+ height: width,
1263
+ },
1264
+ fighter
1265
  )
1266
+ ) {
1267
+ hits.push(fighter as Hero);
 
 
 
 
 
 
 
 
 
 
 
 
1268
  }
1269
  }
1270
+ }
1271
+ return hits;
1272
+ },
1273
+ setHeroData: (target, key, value) => target.setData(key, value),
1274
+ getHeroData: (target, key) => target.getData(key),
1275
+ };
1276
+ };
1277
  // --- Battle Initialization ---
1278
  let logger: HighlightLogger;
1279
  const heroDataMap = new Map<number, HeroData>();
1280
 
1281
+ const generateSpawnPoints = (count: number, bounds: GameBounds): { x: number, y: number }[] => {
1282
+ const points = [];
1283
+ const padding = 150; // Keep heroes away from edges initially
1284
+
1285
+ // Handle case for 1 or 0 heroes gracefully
1286
+ if (count <= 1) {
1287
+ return [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }];
1288
+ }
1289
 
1290
+ const angleStep = (Math.PI * 2) / count;
1291
+ const centerX = bounds.x + bounds.width / 2;
1292
+ const centerY = bounds.y + bounds.height / 2;
1293
+ const radiusX = (bounds.width / 2) - padding;
1294
+ const radiusY = (bounds.height / 2) - padding;
1295
+
1296
+ for (let i = 0; i < count; i++) {
1297
+ const angle = i * angleStep;
1298
+ const x = centerX + Math.cos(angle) * radiusX;
1299
+ const y = centerY + Math.sin(angle) * radiusY;
1300
+ points.push({ x, y });
1301
+ }
1302
+ return points;
1303
+ };
1304
+ if (battleSetup.type === "HERO_BATTLE") {
1305
+ // DEFENSIVE: Handle case where heroes are missing, even if the page component checked.
1306
+ if (initialHeroes.length === 0) {
1307
+ toast.error("Critical Error", { description: "Cannot start battle, hero data is missing." });
1308
+ return; // Abort game loop.
1309
+ }
1310
 
1311
+ const spawnPoints = generateSpawnPoints(initialHeroes.length, gameBounds);
1312
+
1313
+ gameState.fighters = initialHeroes.map((heroData, i) => {
1314
  const runtimeId = Date.now() + i;
1315
  const hero = new Hero(
1316
  heroData,
1317
+ runtimeId,
1318
  spawnPoints[i].x,
1319
  spawnPoints[i].y,
1320
  sandboxExecutor,
1321
  {}
1322
  );
 
1323
 
1324
  hero.avatarImage = loadedImages[`hero_${i}_avatar`];
1325
  hero.iconImage = loadedImages[`hero_${i}_icon`];
 
1326
  heroDataMap.set(runtimeId, heroData);
 
1327
  return hero;
1328
  });
1329
+
1330
+ logger = new HighlightLogger(gameState.fighters.map((f) => ({ id: f.id, name: f.name })));
1331
+ } else {
 
1332
  // 'CUSTOM_BATTLE'
1333
+ // Narrow config type to CustomBattleConfig
1334
+ const config = fullSetup.config as import("@/types").CustomBattleConfig;
1335
  const spawnPoints = [
1336
  { x: gameBounds.x + 50, y: gameBounds.y + 50 },
1337
  { x: gameBounds.x + gameBounds.width - 150, y: gameBounds.y + 50 },
 
1993
  // Determine the list of players based on the battle type
1994
  players:
1995
  fullSetup.type === "HERO_BATTLE"
1996
+ ? initialHeroes.map((heroData) => {
1997
  // For heroes, find their randomly assigned color from the game instance
1998
  const fighterInstance = gameState.fighters.find(
1999
  (f) => f.name === heroData.heroName
components/HeroRoster.tsx CHANGED
@@ -78,7 +78,7 @@ const HeroRoster = ({ initialHeroes }: HeroRosterProps) => {
78
  const battleSetup: BattleSetup = {
79
  type: 'HERO_BATTLE',
80
  config: {
81
- heroes: [selectedHeroes[0], selectedHeroes[1]],
82
  // --- USE STATE FOR SETTINGS ---
83
  gameSpeed: gameSpeed,
84
  activePowerUps: activePowerUps,
 
78
  const battleSetup: BattleSetup = {
79
  type: 'HERO_BATTLE',
80
  config: {
81
+ heroIds: [selectedHeroes[0]._id, selectedHeroes[1]._id].filter((id): id is string => typeof id === 'string'),
82
  // --- USE STATE FOR SETTINGS ---
83
  gameSpeed: gameSpeed,
84
  activePowerUps: activePowerUps,
lib/game/Hero.ts CHANGED
@@ -70,6 +70,7 @@ export class Hero {
70
  isWinner: boolean = false;
71
  victoryScale: number = 1;
72
  initialSize: number;
 
73
 
74
  constructor(
75
  heroData: HeroData,
@@ -823,7 +824,7 @@ export class Hero {
823
  const range = this.basicAttack.range || 50; // Use defined range or a sensible default.
824
  // Create a safety buffer. Be MORE cautious against contact heroes.
825
  const safetyBuffer =
826
- //@ts-expect-error no error
827
  this.target.basicAttack.type === "CONTACT" ? 40 : 15;
828
  const optimalDistance = range - safetyBuffer;
829
 
@@ -834,7 +835,6 @@ export class Hero {
834
  // Otherwise, we are in the sweet spot. Stop moving forward.
835
  isMoving = false;
836
  }
837
-
838
  } else if (this.basicAttack.type === "RANGED") {
839
  const safeDistance =
840
  typeof this.basicAttack.range === "number"
@@ -1015,23 +1015,14 @@ export class Hero {
1015
  allOpponents: (Square | Hero)[],
1016
  powerUps: PowerUp[],
1017
  api: GameAPI
1018
- ) {
1019
- // Filter to only get hero opponents
1020
- // Filter to only get hero opponents for the AI
1021
- const heroOpponents = allOpponents.filter(
1022
- (o) =>
1023
- o instanceof Hero &&
1024
- o.id !== this.id &&
1025
- o.teamId !== this.id &&
1026
- (o as Hero).ghostTimer <= 0
1027
- ) as Hero[];
1028
- // Standard state updates
1029
  if (this.speakingTimer > 0) this.speakingTimer--;
1030
  else this.isSpeaking = false;
1031
  if (this.stunTimer > 0) {
1032
  this.stunTimer--;
1033
- return;
1034
- } // Stunned heroes can't do anything
1035
  if (this.speedBoostTimer > 0) this.speedBoostTimer--;
1036
  if (this.ghostTimer > 0) this.ghostTimer--;
1037
  if (this.invinsibleTimer > 0) this.invinsibleTimer--;
@@ -1041,69 +1032,92 @@ export class Hero {
1041
  if (Math.abs(this.rotationAnimation) > 0.01)
1042
  this.rotationAnimation = lerp(this.rotationAnimation, 0, 0.15);
1043
 
1044
- // 1. AI Logic: Decide and Move
1045
- this._makeDecision(heroOpponents, powerUps, api);
1046
- this._handleMovement(powerUps, api);
1047
-
1048
- // 2. Physics & State Updates
1049
- this.basicAttackCooldownTimer--;
1050
  const boost = this.speedBoostTimer > 0 ? 1.75 : 1.0;
1051
- const finalSpeed = this.speed * boost * gameSpeed;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1052
  this.x += this.dx * finalSpeed;
1053
  this.y += this.dy * finalSpeed;
1054
 
1055
- // 4. Wall Collision
1056
- if (this.x + this.width > gameBounds.x + gameBounds.width) {
1057
- this.dx *= -1;
1058
- this.x = gameBounds.x + gameBounds.width - this.width;
1059
- } else if (this.x < gameBounds.x) {
1060
  this.dx *= -1;
1061
- this.x = gameBounds.x;
 
 
 
 
1062
  }
1063
- if (this.y + this.height > gameBounds.y + gameBounds.height) {
1064
- this.dy *= -1;
1065
- this.y = gameBounds.y + gameBounds.height - this.height;
1066
- } else if (this.y < gameBounds.y) {
1067
  this.dy *= -1;
1068
- this.y = gameBounds.y;
 
 
 
 
1069
  }
1070
 
1071
- // Handle Melee Attack Logic Separately
 
 
 
 
 
 
 
 
 
 
 
1072
  if (this.basicAttack.type === "MELEE") {
1073
  if (this.isPrimedForMelee) {
1074
- // Attack is ready! Look for a target in range.
1075
- const targetInRange =
1076
- typeof this.basicAttack.range === "number"
1077
- ? heroOpponents.find(
1078
- (o) => api.getDistance(this, o) <= this.basicAttack.range!
1079
- )
1080
- : undefined;
1081
-
1082
  if (targetInRange) {
1083
  this._executeMeleeAttack(targetInRange, api);
1084
  this.isPrimedForMelee = false;
1085
  this.basicAttackCooldownTimer = this.basicAttack.cooldown * 60;
1086
  }
1087
- } else {
1088
- // Cooldown logic
1089
- if (this.basicAttackCooldownTimer > 0) {
1090
- this.basicAttackCooldownTimer--;
1091
- } else {
1092
- this.isPrimedForMelee = true;
1093
- }
1094
  }
1095
  } else {
1096
- // Handle Ranged & Contact Attack Logic (your old system)
1097
- if (this.basicAttackCooldownTimer > 0) {
1098
- this.basicAttackCooldownTimer--;
1099
- } else {
1100
- if (this.basicAttack.type === "RANGED") {
1101
- if (this.target instanceof Hero) {
1102
- this._executeRangedAttack(this.target, api);
1103
- }
 
1104
  }
1105
  }
1106
  }
 
 
1107
  }
1108
  draw(ctx: CanvasRenderingContext2D) {
1109
  const centerX = this.x + this.width / 2;
 
70
  isWinner: boolean = false;
71
  victoryScale: number = 1;
72
  initialSize: number;
73
+ maxSpeed: number = 6;
74
 
75
  constructor(
76
  heroData: HeroData,
 
824
  const range = this.basicAttack.range || 50; // Use defined range or a sensible default.
825
  // Create a safety buffer. Be MORE cautious against contact heroes.
826
  const safetyBuffer =
827
+ //@ts-expect-error no error
828
  this.target.basicAttack.type === "CONTACT" ? 40 : 15;
829
  const optimalDistance = range - safetyBuffer;
830
 
 
835
  // Otherwise, we are in the sweet spot. Stop moving forward.
836
  isMoving = false;
837
  }
 
838
  } else if (this.basicAttack.type === "RANGED") {
839
  const safeDistance =
840
  typeof this.basicAttack.range === "number"
 
1015
  allOpponents: (Square | Hero)[],
1016
  powerUps: PowerUp[],
1017
  api: GameAPI
1018
+ ): boolean {
1019
+ // --- V2's Advanced State Timers ---
 
 
 
 
 
 
 
 
 
1020
  if (this.speakingTimer > 0) this.speakingTimer--;
1021
  else this.isSpeaking = false;
1022
  if (this.stunTimer > 0) {
1023
  this.stunTimer--;
1024
+ return false;
1025
+ } // Return early like V1
1026
  if (this.speedBoostTimer > 0) this.speedBoostTimer--;
1027
  if (this.ghostTimer > 0) this.ghostTimer--;
1028
  if (this.invinsibleTimer > 0) this.invinsibleTimer--;
 
1032
  if (Math.abs(this.rotationAnimation) > 0.01)
1033
  this.rotationAnimation = lerp(this.rotationAnimation, 0, 0.15);
1034
 
1035
+ // --- V1's CORE MOVEMENT ENGINE (The "will-less" part) ---
1036
+ let wallHit = false;
 
 
 
 
1037
  const boost = this.speedBoostTimer > 0 ? 1.75 : 1.0;
1038
+ // Use the hero's 'speed' stat from V2, but apply it to V1's movement style
1039
+ const finalSpeed = (this.speed / 2) * boost * gameSpeed; // V2 speed stat is a bit high, scale it down
1040
+
1041
+ // --- NEW: Speed Limiting (Clamping) Logic ---
1042
+ // Calculate the current speed using the Pythagorean theorem
1043
+ const currentSpeed = Math.hypot(this.dx, this.dy);
1044
+
1045
+ // If the current speed exceeds our defined maxSpeed...
1046
+ if (currentSpeed > this.maxSpeed) {
1047
+ // ...calculate the scaling factor to bring it back to the max speed.
1048
+ const factor = this.maxSpeed / currentSpeed;
1049
+ // Apply the factor to both dx and dy to reduce speed without changing direction.
1050
+ this.dx *= factor;
1051
+ this.dy *= factor;
1052
+ }
1053
+ // ====================================================================
1054
+
1055
  this.x += this.dx * finalSpeed;
1056
  this.y += this.dy * finalSpeed;
1057
 
1058
+ if (
1059
+ this.x + this.width > gameBounds.x + gameBounds.width ||
1060
+ this.x < gameBounds.x
1061
+ ) {
 
1062
  this.dx *= -1;
1063
+ this.x = Math.max(
1064
+ gameBounds.x,
1065
+ Math.min(this.x, gameBounds.x + gameBounds.width - this.width)
1066
+ );
1067
+ wallHit = true;
1068
  }
1069
+ if (
1070
+ this.y + this.height > gameBounds.y + gameBounds.height ||
1071
+ this.y < gameBounds.y
1072
+ ) {
1073
  this.dy *= -1;
1074
+ this.y = Math.max(
1075
+ gameBounds.y,
1076
+ Math.min(this.y, gameBounds.y + gameBounds.height - this.height)
1077
+ );
1078
+ wallHit = true;
1079
  }
1080
 
1081
+ // --- V2's ATTACK LOGIC (triggered by proximity, not AI decisions) ---
1082
+ this.basicAttackCooldownTimer--;
1083
+
1084
+ // Filter for valid hero opponents
1085
+ const heroOpponents = allOpponents.filter(
1086
+ (o) =>
1087
+ o instanceof Hero &&
1088
+ o.id !== this.id &&
1089
+ o.teamId !== this.id &&
1090
+ o.ghostTimer <= 0
1091
+ ) as Hero[];
1092
+
1093
  if (this.basicAttack.type === "MELEE") {
1094
  if (this.isPrimedForMelee) {
1095
+ const targetInRange = heroOpponents.find(
1096
+ (o) => api.getDistance(this, o) <= (this.basicAttack.range || 0)
1097
+ );
 
 
 
 
 
1098
  if (targetInRange) {
1099
  this._executeMeleeAttack(targetInRange, api);
1100
  this.isPrimedForMelee = false;
1101
  this.basicAttackCooldownTimer = this.basicAttack.cooldown * 60;
1102
  }
1103
+ } else if (this.basicAttackCooldownTimer <= 0) {
1104
+ this.isPrimedForMelee = true;
 
 
 
 
 
1105
  }
1106
  } else {
1107
+ // RANGED and CONTACT
1108
+ if (this.basicAttackCooldownTimer <= 0) {
1109
+ const target = this._findNearestOpponent(heroOpponents, api);
1110
+ if (
1111
+ target &&
1112
+ this.basicAttack.type === "RANGED" &&
1113
+ api.getDistance(this, target) <= (this.basicAttack.range || 1000)
1114
+ ) {
1115
+ this._executeRangedAttack(target, api);
1116
  }
1117
  }
1118
  }
1119
+
1120
+ return wallHit; // Keep V1's return value
1121
  }
1122
  draw(ctx: CanvasRenderingContext2D) {
1123
  const centerX = this.x + this.width / 2;
lib/validators.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { z } from "zod";
2
 
3
  export const heroFormSchema = z.object({
@@ -27,4 +28,53 @@ export const heroFormSchema = z.object({
27
  introLineAudioUrl: z.string().url().optional().nullable(),
28
  superpowerActivationLineAudioUrl: z.string().url().optional().nullable(),
29
  tags: z.array(z.string()).optional(),
30
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Type } from "@google/genai";
2
  import { z } from "zod";
3
 
4
  export const heroFormSchema = z.object({
 
28
  introLineAudioUrl: z.string().url().optional().nullable(),
29
  superpowerActivationLineAudioUrl: z.string().url().optional().nullable(),
30
  tags: z.array(z.string()).optional(),
31
+ });
32
+
33
+
34
+ export const heroDataSchema = {
35
+ type: Type.OBJECT,
36
+ properties: {
37
+ heroName: { type: Type.STRING },
38
+ heroGender: { type: Type.STRING },
39
+ heroDescription: { type: Type.STRING },
40
+ heroColor: { type: Type.STRING },
41
+ heroHealth: { type: Type.NUMBER },
42
+ heroSpeed: { type: Type.NUMBER },
43
+ basicAttack: {
44
+ type: Type.OBJECT,
45
+ properties: {
46
+ type: { type: Type.STRING }, // e.g., "RANGED", "MELEE", "CONTACT"
47
+ damage: { type: Type.NUMBER },
48
+ cooldown: { type: Type.NUMBER },
49
+ range: { type: Type.NUMBER },
50
+ knockback: { type: Type.NUMBER },
51
+ },
52
+ required: ["type", "damage", "cooldown", "range", "knockback"],
53
+ },
54
+ powerName: { type: Type.STRING },
55
+ powerDescription: { type: Type.STRING },
56
+ powerLogicDescription: { type: Type.STRING },
57
+ introLine: { type: Type.STRING },
58
+ superpowerActivationLine: { type: Type.STRING },
59
+ avatarPrompt: { type: Type.STRING },
60
+ iconPrompt: { type: Type.STRING },
61
+ tags: { type: Type.ARRAY, items: { type: Type.STRING } },
62
+ },
63
+ required: [
64
+ "heroName",
65
+ "heroGender",
66
+ "heroDescription",
67
+ "heroColor",
68
+ "heroHealth",
69
+ "heroSpeed",
70
+ "basicAttack",
71
+ "powerName",
72
+ "powerDescription",
73
+ "powerLogicDescription",
74
+ "introLine",
75
+ "superpowerActivationLine",
76
+ "avatarPrompt",
77
+ "iconPrompt",
78
+ "tags",
79
+ ],
80
+ };
model/battleSetup.model.ts CHANGED
@@ -1,17 +1,35 @@
1
- import { Schema, Document, models, model } from "mongoose";
 
2
 
3
- // Define an interface for our document to ensure type safety
4
- export interface IBattleSetup extends Document {
5
- setupData: string; // We'll store the stringified JSON object
6
  createdAt: Date;
7
  }
8
 
 
 
 
9
  const BattleSetupSchema = new Schema({
10
- // Storing the large setup object as a single stringified JSON field is efficient
11
- setupData: { type: String, required: true },
12
- createdAt: { type: Date, default: Date.now, expires: '48h' } // Automatically delete docs after 1 hour
 
 
 
 
 
 
 
 
 
 
 
 
13
  });
14
 
15
- const BattleSetup = models.BattleSetup || model<IBattleSetup>("BattleSetup", BattleSetupSchema);
 
 
16
 
17
- export default BattleSetup;
 
1
+ import { HeroBattleConfig } from "@/types";
2
+ import { Schema, Document, models, model, Model } from "mongoose";
3
 
4
+ // Define an interface for our document to ensure type safety.
5
+ // This matches the BattleSetup type from types/index.ts
6
+ export interface IBattleSetup extends HeroBattleConfig, Document {
7
  createdAt: Date;
8
  }
9
 
10
+ // REFACTOR: Instead of a single JSON string, we define the schema's structure.
11
+ // This leverages Mongoose's validation, prevents JSON parsing errors,
12
+ // and makes the data more robust and queryable.
13
  const BattleSetupSchema = new Schema({
14
+ type: {
15
+ type: String,
16
+ required: true,
17
+ enum: ["HERO_BATTLE", "CUSTOM_BATTLE"],
18
+ },
19
+ // We use Schema.Types.Mixed for the config object because its structure
20
+ // depends on the `type` field. Mongoose will store the object directly.
21
+ config: {
22
+ type: Schema.Types.Mixed,
23
+ required: true,
24
+ },
25
+ createdAt: {
26
+ type: Date,
27
+ default: Date.now,
28
+ },
29
  });
30
 
31
+ // Use a type-safe model definition.
32
+ const BattleSetupModel: Model<IBattleSetup> =
33
+ models.BattleSetup || model<IBattleSetup>("BattleSetup", BattleSetupSchema);
34
 
35
+ export default BattleSetupModel;
model/hero.model.ts CHANGED
@@ -4,6 +4,13 @@ import { HeroData } from "@/types"; // We will ensure HeroData is correctly defi
4
  // Define an interface for our document to ensure type safety
5
  export type IHero = HeroData & Document & {
6
  createdAt: Date;
 
 
 
 
 
 
 
7
  // We can add more metadata here later, like createdBy, wins, losses, etc.
8
  };
9
 
@@ -28,7 +35,7 @@ const HeroSchema = new Schema({
28
  powerLogicDescription: { type: String, required: true },
29
  powerLogic: { type: String, required: true }, // The AI-generated code string
30
  introLine: { type: String, required: true },
31
- superpowerActivationLine: { type: String, required:true },
32
  aiVoicer: { type: String, required: true },
33
  avatarPrompt: { type: String, required: true },
34
  iconPrompt: { type: String, required: true },
@@ -37,6 +44,18 @@ const HeroSchema = new Schema({
37
  introLineAudioUrl: { type: String },
38
  superpowerActivationLineAudioUrl: { type: String },
39
  tags: [{ type: String }],
 
 
 
 
 
 
 
 
 
 
 
 
40
  createdAt: { type: Date, default: Date.now },
41
  });
42
 
 
4
  // Define an interface for our document to ensure type safety
5
  export type IHero = HeroData & Document & {
6
  createdAt: Date;
7
+ errorCount: number;
8
+ lastError?: {
9
+ message?: string;
10
+ faultyCode?: string;
11
+ reportedAt?: Date;
12
+ };
13
+ repairAttempts: number;
14
  // We can add more metadata here later, like createdBy, wins, losses, etc.
15
  };
16
 
 
35
  powerLogicDescription: { type: String, required: true },
36
  powerLogic: { type: String, required: true }, // The AI-generated code string
37
  introLine: { type: String, required: true },
38
+ superpowerActivationLine: { type: String, required: true },
39
  aiVoicer: { type: String, required: true },
40
  avatarPrompt: { type: String, required: true },
41
  iconPrompt: { type: String, required: true },
 
44
  introLineAudioUrl: { type: String },
45
  superpowerActivationLineAudioUrl: { type: String },
46
  tags: [{ type: String }],
47
+ errorCount: {
48
+ type: Number,
49
+ default: 0,
50
+ index: true, // Index this field for efficient querying by the repair worker.
51
+ },
52
+ lastError: {
53
+ message: { type: String },
54
+ faultyCode: { type: String },
55
+ reportedAt: { type: Date },
56
+ },
57
+ // We can add repairAttempts later if we build the "Fixer AI"
58
+ repairAttempts: { type: Number, default: 0 },
59
  createdAt: { type: Date, default: Date.now },
60
  });
61
 
repair.worker.ts ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // FILE: repair.worker.ts
2
+
3
+ import "dotenv/config";
4
+ import { GoogleGenAI } from "@google/genai";
5
+ import { connectToDatabase } from "./lib/database";
6
+ import Hero from "./model/hero.model";
7
+ import { sanitizePowerLogicCode } from "./lib/utils";
8
+ import { heroCoderPrompt } from "./lib/AiPrompts";
9
+ import { IHero } from "./model/hero.model"; // Import the Mongoose document type
10
+
11
+ // --- Configuration ---
12
+ const REPAIR_INTERVAL_MS = 5 * 60 * 1000; // Run every 5 minutes
13
+ const MAX_REPAIR_ATTEMPTS = 2; // How many times to try fixing a hero before giving up
14
+
15
+ // --- Specialized AI Prompt for Fixing Code ---
16
+ const fixerAiPrompt = `
17
+ You are an expert JavaScript Game Engine Debugger. Your mission is to fix faulty code.
18
+ You will be given the original code generation prompt, the hero's power description, the full faulty JavaScript code, and the specific runtime error it produced.
19
+
20
+ Your task is to analyze the error and the code, identify the mistake, and provide a corrected, fully functional version of the JavaScript function.
21
+ The mistake is likely a typo, using a non-existent API, or accessing a property on a null or undefined object (e.g., trying to use 'target.x' when 'target' might not exist).
22
+
23
+ RULES:
24
+ 1. Your response MUST be ONLY the corrected, complete, standalone JavaScript async arrow function: \`async ({...}) => { ... }\`.
25
+ 2. Do NOT add any explanations, comments, or markdown.
26
+ 3. Pay EXTREMELY close attention to the provided API documentation to ensure you are only using valid functions and properties.
27
+ 4. Ensure all object access is safe. For example, check \`if (target)\` before using \`target.x\`.
28
+
29
+ --- PROVIDED API DOCUMENTATION ---
30
+ ${heroCoderPrompt}
31
+ `;
32
+
33
+
34
+ /**
35
+ * Queries the database for heroes that have reported runtime errors.
36
+ */
37
+ async function findBrokenHeroes(): Promise<IHero[]> {
38
+ console.log("[Repair Worker] Checking for broken heroes...");
39
+ try {
40
+ // Find heroes that have errors and haven't exceeded repair attempts.
41
+ const heroes = await Hero.find({
42
+ errorCount: { $gt: 0 },
43
+ // repairAttempts: { $lt: MAX_REPAIR_ATTEMPTS } // We'll add this field later
44
+ }).limit(5); // Process in batches to avoid overwhelming the AI API.
45
+
46
+ if (heroes.length > 0) {
47
+ console.log(`[Repair Worker] Found ${heroes.length} broken hero(es) to repair.`);
48
+ } else {
49
+ console.log("[Repair Worker] No broken heroes found. All systems nominal.");
50
+ }
51
+ return heroes;
52
+ } catch (error) {
53
+ console.error("[Repair Worker] CRITICAL: Could not query database for broken heroes.", error);
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Uses the Fixer AI to attempt to repair a hero's faulty power logic.
60
+ * @param hero The Mongoose document of the hero to repair.
61
+ * @returns The new, sanitized code string if successful, otherwise null.
62
+ */
63
+ async function attemptRepair(hero: IHero): Promise<string | null> {
64
+ // Defensive check for required error information
65
+ if (!hero.lastError?.faultyCode || !hero.lastError?.message || !hero.powerLogicDescription) {
66
+ console.error(`[Repair Worker] Hero ${hero.heroName} is flagged but missing error details. Cannot repair.`);
67
+ return null;
68
+ }
69
+
70
+ console.log(`[Repair Worker] Attempting to repair hero: ${hero.heroName}`);
71
+
72
+ // Construct the detailed prompt for the Fixer AI
73
+ const repairPrompt = `
74
+ HERO POWER DESCRIPTION:
75
+ ${hero.powerLogicDescription}
76
+
77
+ ---
78
+ FAULTY JAVASCRIPT CODE:
79
+ \`\`\`javascript
80
+ ${hero.lastError.faultyCode}
81
+ \`\`\`
82
+
83
+ ---
84
+ RUNTIME ERROR MESSAGE:
85
+ ${hero.lastError.message}
86
+ `;
87
+
88
+ try {
89
+ const apiKey = process.env.GEMINI_API_KEY;
90
+ if (!apiKey) {
91
+ throw new Error("GEMINI_API_KEY is not configured.");
92
+ }
93
+ const ai = new GoogleGenAI({ apiKey });
94
+
95
+ const result = await ai.models.generateContent({
96
+ model: "gemini-2.5-pro", // Use the most capable model for debugging
97
+ contents: repairPrompt,
98
+ config: {
99
+ systemInstruction: fixerAiPrompt,
100
+ }
101
+ });
102
+
103
+ const fixedCode = result.text;
104
+ if (!fixedCode) {
105
+ console.warn(`[Repair Worker] Fixer AI for ${hero.heroName} returned an empty response.`);
106
+ return null;
107
+ }
108
+
109
+ // IMPORTANT: We must validate the AI's fix. It might also be syntactically invalid.
110
+ return sanitizePowerLogicCode(fixedCode);
111
+
112
+ } catch (error) {
113
+ console.error(`[Repair Worker] AI repair attempt for ${hero.heroName} failed:`, error);
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The main workflow for the repair worker.
120
+ */
121
+ async function repairWorkflow() {
122
+ await connectToDatabase();
123
+
124
+ // 1. Find all broken heroes
125
+ const brokenHeroes = await findBrokenHeroes();
126
+
127
+ for (const hero of brokenHeroes) {
128
+ let repairSucceeded = false;
129
+
130
+ // 2. Attempt to repair the hero's code (with retries)
131
+ for (let i = 0; i < MAX_REPAIR_ATTEMPTS; i++) {
132
+ console.log(`[Repair Worker] Repair attempt ${i + 1}/${MAX_REPAIR_ATTEMPTS} for ${hero.heroName}...`);
133
+ const newCode = await attemptRepair(hero);
134
+
135
+ if (newCode) {
136
+ // Repair successful! Update the hero in the database.
137
+ await Hero.updateOne(
138
+ { _id: hero._id },
139
+ {
140
+ $set: {
141
+ powerLogic: newCode,
142
+ lastError: undefined, // Clear the last error
143
+ errorCount: 0 // Reset error count
144
+ }
145
+ }
146
+ );
147
+ console.log(`[Repair Worker] ✅ SUCCESS: Hero ${hero.heroName} has been repaired!`);
148
+ repairSucceeded = true;
149
+ break; // Exit the retry loop
150
+ }
151
+ }
152
+
153
+ // 3. If all repair attempts fail, delete the hero
154
+ if (!repairSucceeded) {
155
+ console.log(`[Repair Worker] ❌ FAILED: All repair attempts for ${hero.heroName} failed. Deleting hero...`);
156
+ await Hero.deleteOne({ _id: hero._id });
157
+ console.log(`[Repair Worker] Hero ${hero.heroName} has been deleted.`);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Main execution function that runs the workflow on a set interval.
164
+ */
165
+ async function main() {
166
+ console.log("🤖 Robotic Repair Bay Worker started.");
167
+
168
+ // Run immediately on start, then run on the specified interval.
169
+ await repairWorkflow().catch(console.error);
170
+ setInterval(() => repairWorkflow().catch(console.error), REPAIR_INTERVAL_MS);
171
+ }
172
+
173
+ main();
start.sh CHANGED
@@ -1,40 +1,106 @@
1
  #!/bin/bash
2
- # Define the cache directory
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  export PUPPETEER_CACHE_DIR="/tmp/puppeteer_cache"
4
 
5
- # Find Chrome executable
6
  CHROME_EXECUTABLE_PATH=$(find /opt/puppeteer_cache -name "chrome" -type f -executable 2>/dev/null | head -1)
7
-
8
  if [ -z "$CHROME_EXECUTABLE_PATH" ] || [ ! -f "$CHROME_EXECUTABLE_PATH" ]; then
9
  CHROME_EXECUTABLE_PATH=$(find /opt/puppeteer_cache -path "*/chrome-linux*/chrome" -type f 2>/dev/null | head -1)
10
  fi
11
 
12
  if [ -z "$CHROME_EXECUTABLE_PATH" ] || [ ! -f "$CHROME_EXECUTABLE_PATH" ]; then
13
- echo "!!! CRITICAL ERROR: Chrome executable not found !!!"
14
  exit 1
15
  fi
16
-
17
  export PUPPETEER_EXECUTABLE_PATH=$CHROME_EXECUTABLE_PATH
18
  echo ">>> Puppeteer will use Chrome from: $PUPPETEER_EXECUTABLE_PATH"
19
 
20
- # CRITICAL: Bind to 0.0.0.0 so Hugging Face can detect the app is running
 
 
 
 
 
 
 
 
 
 
 
 
21
  export HOST=0.0.0.0
22
  export PORT=7860
23
 
24
- # Start the Next.js server in the background
25
- # Use npm run start with proper host binding
26
  npm run start &
 
 
 
27
 
28
- # Wait for the server to be ready with health check
29
- echo "Waiting for Next.js server to start..."
30
  for i in {1..30}; do
31
- if curl -f http://localhost:7860 >/dev/null 2>&1; then
32
- echo "Next.js server is ready!"
 
33
  break
34
  fi
 
 
 
 
 
35
  echo "Attempt $i: Server not ready yet, waiting..."
36
  sleep 2
37
  done
38
 
39
- # Start the automation worker in the foreground
40
- node dist/worker.js
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  #!/bin/bash
2
+
3
+ # This script is designed for robustness in a containerized environment like Hugging Face Spaces.
4
+ # It builds the necessary worker scripts, then starts the Next.js server, the battle
5
+ # generation worker, and the self-healing repair worker in parallel. It also includes
6
+ # a cleanup mechanism to ensure all processes are terminated correctly on shutdown.
7
+
8
+ # --- Graceful Shutdown Function and Trap ---
9
+ # This function will be called when the script receives a shutdown signal.
10
+ cleanup() {
11
+ echo ">>> Received shutdown signal. Cleaning up background processes..."
12
+ # The 'kill' command sends a termination signal to the specified Process IDs (PIDs).
13
+ # The '2>/dev/null' suppresses errors if a process has already exited.
14
+ if [ -n "$SERVER_PID" ]; then kill $SERVER_PID 2>/dev/null; fi
15
+ if [ -n "$BATTLE_WORKER_PID" ]; then kill $BATTLE_WORKER_PID 2>/dev/null; fi
16
+ if [ -n "$REPAIR_WORKER_PID" ]; then kill $REPAIR_WORKER_PID 2>/dev/null; fi
17
+ echo ">>> Cleanup complete."
18
+ }
19
+
20
+ # The 'trap' command registers the 'cleanup' function to be executed when the script
21
+ # receives specific signals (EXIT, SIGINT, SIGTERM). This is crucial for clean shutdowns.
22
+ trap cleanup EXIT SIGINT SIGTERM
23
+
24
+ # --- Environment and Puppeteer Setup ---
25
+ echo ">>> Setting up environment for Puppeteer..."
26
  export PUPPETEER_CACHE_DIR="/tmp/puppeteer_cache"
27
 
28
+ # Find Chrome executable in the cache copied by the Dockerfile.
29
  CHROME_EXECUTABLE_PATH=$(find /opt/puppeteer_cache -name "chrome" -type f -executable 2>/dev/null | head -1)
 
30
  if [ -z "$CHROME_EXECUTABLE_PATH" ] || [ ! -f "$CHROME_EXECUTABLE_PATH" ]; then
31
  CHROME_EXECUTABLE_PATH=$(find /opt/puppeteer_cache -path "*/chrome-linux*/chrome" -type f 2>/dev/null | head -1)
32
  fi
33
 
34
  if [ -z "$CHROME_EXECUTABLE_PATH" ] || [ ! -f "$CHROME_EXECUTABLE_PATH" ]; then
35
+ echo "!!! CRITICAL ERROR: Chrome executable not found in /opt/puppeteer_cache !!!"
36
  exit 1
37
  fi
 
38
  export PUPPETEER_EXECUTABLE_PATH=$CHROME_EXECUTABLE_PATH
39
  echo ">>> Puppeteer will use Chrome from: $PUPPETEER_EXECUTABLE_PATH"
40
 
41
+ # --- Build Step ---
42
+ # Build both worker scripts (worker.ts and repair.worker.ts) using the single command.
43
+ echo ">>> Building worker scripts..."
44
+ npm run build:worker
45
+ # Check if the build command was successful. If not, exit immediately.
46
+ if [ $? -ne 0 ]; then
47
+ echo "!!! CRITICAL ERROR: Worker build failed. Exiting. !!!"
48
+ exit 1
49
+ fi
50
+ echo ">>> Worker build complete."
51
+
52
+ # --- Process Execution ---
53
+ # Set host and port for the Next.js server to be accessible within the container network.
54
  export HOST=0.0.0.0
55
  export PORT=7860
56
 
57
+ # 1. Start the Next.js server in the background.
58
+ echo ">>> Starting Next.js server..."
59
  npm run start &
60
+ # Capture the Process ID (PID) of the server.
61
+ SERVER_PID=$!
62
+ echo "Next.js server started with PID: $SERVER_PID"
63
 
64
+ # 2. Wait for the Next.js server to be ready before starting dependent workers.
65
+ echo "Waiting for Next.js server to become available..."
66
  for i in {1..30}; do
67
+ # Use 'curl' to check if the server is responding to requests.
68
+ if curl -sf http://localhost:7860 >/dev/null; then
69
+ echo ">>> Next.js server is ready!"
70
  break
71
  fi
72
+ # If the loop ends without the server being ready, exit the script.
73
+ if [ $i -eq 30 ]; then
74
+ echo "!!! CRITICAL ERROR: Next.js server failed to start in time. Exiting. !!!"
75
+ exit 1
76
+ fi
77
  echo "Attempt $i: Server not ready yet, waiting..."
78
  sleep 2
79
  done
80
 
81
+ # 3. Start the battle generation worker in the background.
82
+ echo ">>> Starting battle generation worker..."
83
+ node dist/worker.js &
84
+ # Capture the PID of the battle worker.
85
+ BATTLE_WORKER_PID=$!
86
+ echo "Battle worker started with PID: $BATTLE_WORKER_PID"
87
+
88
+ # 4. Start the self-healing repair worker in the background.
89
+ echo ">>> Starting self-healing repair worker..."
90
+ node dist/repair.worker.js &
91
+ # Capture the PID of the repair worker.
92
+ REPAIR_WORKER_PID=$!
93
+ echo "Repair worker started with PID: $REPAIR_WORKER_PID"
94
+
95
+
96
+ echo ""
97
+ echo "========================================="
98
+ echo " ALL SYSTEMS ARE OPERATIONAL"
99
+ echo "========================================="
100
+ echo ""
101
+
102
+ # The 'wait' command is essential. It pauses the script here, preventing it from
103
+ # exiting while the background processes are running. It will wait until the
104
+ # specified process (the Next.js server) terminates. When it does, the script
105
+ # will proceed, and the 'trap' will trigger the cleanup function.
106
+ wait $SERVER_PID
tsconfig.worker.json CHANGED
@@ -9,7 +9,8 @@
9
  "noEmit": false
10
  },
11
  "include": [
12
- "worker.ts" // Only the entry point is needed! TS will find the rest.
 
13
  ],
14
  "exclude": ["node_modules", ".next", "components", "app/components"]
15
  }
 
9
  "noEmit": false
10
  },
11
  "include": [
12
+ "worker.ts", // Only the entry point is needed! TS will find the rest.,
13
+ "repair.worker.ts"
14
  ],
15
  "exclude": ["node_modules", ".next", "components", "app/components"]
16
  }
types/index.ts CHANGED
@@ -45,7 +45,7 @@ export type HeroData = {
45
 
46
  // The configuration for a hero-vs-hero battle
47
  export type HeroBattleConfig = {
48
- heroes: [HeroData, HeroData];
49
  gameSpeed: number;
50
  // We MUST include power-ups here to keep them in the game
51
  activePowerUps: PowerUpType[];
 
45
 
46
  // The configuration for a hero-vs-hero battle
47
  export type HeroBattleConfig = {
48
+ heroIds: string[];
49
  gameSpeed: number;
50
  // We MUST include power-ups here to keep them in the game
51
  activePowerUps: PowerUpType[];
worker.ts CHANGED
@@ -6,7 +6,7 @@ import path from "path";
6
  // We can directly import our server actions because this script runs in the same project context
7
  // import { generateAndSaveHero } from './app/actions/ai.actions';
8
  import { saveBattleSetup } from "./app/actions/db.actions";
9
- import { BattleReport, BattleSetup, HeroData } from "./types";
10
  import { generateAndSaveHero, generateYouTubeMetadata } from "./app/actions/ai.actions";
11
 
12
  // --- CONFIGURATION ---
@@ -161,7 +161,7 @@ async function main() {
161
  const battleSetup: BattleSetup = {
162
  type: "HERO_BATTLE",
163
  config: {
164
- heroes: [hero1 as HeroData, hero2 as HeroData],
165
  gameSpeed: 1.5, // Speed up automated battles
166
  activePowerUps: [
167
  "SPEED_BOOST",
 
6
  // We can directly import our server actions because this script runs in the same project context
7
  // import { generateAndSaveHero } from './app/actions/ai.actions';
8
  import { saveBattleSetup } from "./app/actions/db.actions";
9
+ import { BattleReport, BattleSetup } from "./types";
10
  import { generateAndSaveHero, generateYouTubeMetadata } from "./app/actions/ai.actions";
11
 
12
  // --- CONFIGURATION ---
 
161
  const battleSetup: BattleSetup = {
162
  type: "HERO_BATTLE",
163
  config: {
164
+ heroIds: [hero1._id as string, hero2._id as string],
165
  gameSpeed: 1.5, // Speed up automated battles
166
  activePowerUps: [
167
  "SPEED_BOOST",