Spaces:
Paused
Paused
Mohammad Shahid commited on
Commit ·
d87d4e5
1
Parent(s): 501a7cc
added self repair and removed player ai
Browse files- app/actions/ai.actions.ts +215 -385
- app/actions/db.actions.ts +100 -13
- app/battle/[battleId]/page.tsx +29 -7
- components/BattleArena.tsx +462 -426
- components/HeroRoster.tsx +1 -1
- lib/game/Hero.ts +71 -57
- lib/validators.ts +51 -1
- model/battleSetup.model.ts +27 -9
- model/hero.model.ts +20 -1
- repair.worker.ts +173 -0
- start.sh +80 -14
- tsconfig.worker.json +2 -1
- types/index.ts +1 -1
- worker.ts +2 -2
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
|
| 13 |
import { Readable } from "stream";
|
| 14 |
-
import
|
|
|
|
|
|
|
| 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 |
-
//
|
| 27 |
-
const MALE_VOICES = [
|
| 28 |
-
|
| 29 |
-
|
| 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 |
-
//
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
try {
|
| 83 |
-
const ai = new GoogleGenAI({ apiKey });
|
| 84 |
-
|
| 85 |
-
|
| 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 |
-
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
-
//
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
-
//
|
| 111 |
if (!metadata.title || !metadata.description || !metadata.tags) {
|
| 112 |
-
|
|
|
|
| 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
|
| 120 |
-
// Return
|
| 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 |
-
|
| 131 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
| 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 |
-
|
| 243 |
-
|
| 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("
|
| 309 |
-
return null;
|
| 310 |
}
|
| 311 |
};
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 360 |
export async function generateImageWithGemini(prompt: string): Promise<Buffer | null> {
|
| 361 |
-
|
| 362 |
-
if (!
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 391 |
-
|
| 392 |
-
buffer
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
};
|
| 413 |
|
| 414 |
-
|
| 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 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 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 |
-
|
| 456 |
-
|
| 457 |
-
textToSpeak
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 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 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 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 |
-
|
|
|
|
| 537 |
} catch (error) {
|
| 538 |
-
console.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 |
-
|
| 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
|
| 40 |
if (!battle) return null;
|
| 41 |
-
return JSON.parse(
|
| 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 {
|
|
|
|
| 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 |
-
|
| 28 |
-
|
| 29 |
// console.log(battleSetup.config.heros);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
// Pass the
|
| 32 |
-
|
| 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 = ({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 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 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 873 |
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
|
| 889 |
-
|
| 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 |
-
|
| 897 |
-
|
| 898 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
)
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 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 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 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 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 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 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 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 |
-
|
| 992 |
-
|
| 993 |
-
|
|
|
|
| 994 |
|
| 995 |
-
|
| 996 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 997 |
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
|
|
|
| 1098 |
attachTo: isAttachedToHero ? (parentObject as Hero) : undefined,
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
);
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 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 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 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 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1223 |
)
|
| 1224 |
-
|
| 1225 |
-
|
| 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 |
-
|
| 1241 |
-
|
| 1242 |
-
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
// --- Battle Initialization ---
|
| 1248 |
let logger: HighlightLogger;
|
| 1249 |
const heroDataMap = new Map<number, HeroData>();
|
| 1250 |
|
| 1251 |
-
|
| 1252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1253 |
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
|
|
|
| 1273 |
|
| 1274 |
-
|
|
|
|
|
|
|
| 1275 |
const runtimeId = Date.now() + i;
|
| 1276 |
const hero = new Hero(
|
| 1277 |
heroData,
|
| 1278 |
-
|
| 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 |
-
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
} else {
|
| 1297 |
// 'CUSTOM_BATTLE'
|
| 1298 |
-
|
|
|
|
| 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 |
-
?
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
//
|
| 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 |
-
} //
|
| 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 |
-
//
|
| 1045 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1052 |
this.x += this.dx * finalSpeed;
|
| 1053 |
this.y += this.dy * finalSpeed;
|
| 1054 |
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
this.
|
| 1058 |
-
|
| 1059 |
-
} else if (this.x < gameBounds.x) {
|
| 1060 |
this.dx *= -1;
|
| 1061 |
-
this.x =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
}
|
| 1063 |
-
if (
|
| 1064 |
-
this.
|
| 1065 |
-
this.y
|
| 1066 |
-
|
| 1067 |
this.dy *= -1;
|
| 1068 |
-
this.y =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
}
|
| 1070 |
|
| 1071 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
if (this.basicAttack.type === "MELEE") {
|
| 1073 |
if (this.isPrimedForMelee) {
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 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 |
-
|
| 1089 |
-
if (this.basicAttackCooldownTimer > 0) {
|
| 1090 |
-
this.basicAttackCooldownTimer--;
|
| 1091 |
-
} else {
|
| 1092 |
-
this.isPrimedForMelee = true;
|
| 1093 |
-
}
|
| 1094 |
}
|
| 1095 |
} else {
|
| 1096 |
-
//
|
| 1097 |
-
if (this.basicAttackCooldownTimer
|
| 1098 |
-
this.
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 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 {
|
|
|
|
| 2 |
|
| 3 |
-
// Define an interface for our document to ensure type safety
|
| 4 |
-
|
| 5 |
-
|
| 6 |
createdAt: Date;
|
| 7 |
}
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
const BattleSetupSchema = new Schema({
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
});
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
export default
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
export HOST=0.0.0.0
|
| 22 |
export PORT=7860
|
| 23 |
|
| 24 |
-
# Start the Next.js server in the background
|
| 25 |
-
|
| 26 |
npm run start &
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
# Wait for the server to be ready
|
| 29 |
-
echo "Waiting for Next.js server to
|
| 30 |
for i in {1..30}; do
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
break
|
| 34 |
fi
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
echo "Attempt $i: Server not ready yet, waiting..."
|
| 36 |
sleep 2
|
| 37 |
done
|
| 38 |
|
| 39 |
-
# Start the
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 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",
|