diff --git a/ai_animation/src/debug/debugMenu.ts b/ai_animation/src/debug/debugMenu.ts index a83c0af..8201979 100644 --- a/ai_animation/src/debug/debugMenu.ts +++ b/ai_animation/src/debug/debugMenu.ts @@ -3,6 +3,9 @@ * Handles the collapsible debug menu and organization of debug tools */ +import { updateNextMomentDisplay, initNextMomentTool } from "./nextMoment"; +import { initDebugProvinceHighlighting } from "./provinceHighlight"; + export class DebugMenu { private toggleBtn: HTMLButtonElement; private panel: HTMLElement; @@ -19,6 +22,9 @@ export class DebugMenu { } this.initEventListeners(); + + // Start with the menu open + this.toggle() } private initEventListeners(): void { @@ -39,14 +45,6 @@ export class DebugMenu { } }); - // Close when clicking outside the menu - document.addEventListener('click', (e) => { - const target = e.target as Node; - const debugMenu = document.getElementById('debug-menu'); - if (this.isExpanded && debugMenu && !debugMenu.contains(target)) { - this.collapse(); - } - }); } /** @@ -56,6 +54,7 @@ export class DebugMenu { const debugMenu = document.getElementById('debug-menu'); if (debugMenu) { debugMenu.style.display = 'block'; + this.initTools() } } @@ -136,7 +135,7 @@ export class DebugMenu { // If no specific position, insert before "Future Tools" section or at the end const futureToolsSection = Array.from(debugContent.querySelectorAll('.debug-section h4')) .find(h4 => h4.textContent === 'Future Tools')?.parentElement; - + if (futureToolsSection) { debugContent.insertBefore(section, futureToolsSection); } else { @@ -170,4 +169,15 @@ export class DebugMenu { public get expanded(): boolean { return this.isExpanded; } -} \ No newline at end of file + + private initTools(): void { + initNextMomentTool(this); + initDebugProvinceHighlighting() + } + + public updateTools(): void { + updateNextMomentDisplay() + } +} + +export let debugMenuInstance = new DebugMenu(); diff --git a/ai_animation/src/debug/nextMoment.ts b/ai_animation/src/debug/nextMoment.ts new file mode 100644 index 0000000..ab7c272 --- /dev/null +++ b/ai_animation/src/debug/nextMoment.ts @@ -0,0 +1,104 @@ +/** + * Next Moment Debug Tool + * Shows the next moment that should occur based on phase name parsing + */ + +import { gameState } from '../gameState'; +import { getNextPhaseName, parsePhase } from '../types/moments'; + +/** + * Initializes the next moment debug tool + * @param debugMenu - Debug menu instance to add the tool to + */ +export function initNextMomentTool(debugMenu: any) { + // Add next moment display tool + debugMenu.addDebugTool( + 'Next Moment', + ` +
+
Current Phase: --
+
Next Phase: --
+
Next Moment: --
+ +
+ `, + 'Future Tools' + ); + + // Initialize the next moment display + updateNextMomentDisplay(); + + // Add refresh button functionality + const refreshBtn = document.getElementById('debug-refresh-moment'); + if (refreshBtn) { + refreshBtn.addEventListener('click', updateNextMomentDisplay); + } +} + +/** + * Updates the next moment display in the debug menu + */ +export function updateNextMomentDisplay() { + const currentPhaseElement = document.getElementById('debug-current-phase'); + const nextPhaseElement = document.getElementById('debug-next-phase'); + const nextMomentElement = document.getElementById('debug-next-moment'); + + if (!currentPhaseElement || !nextPhaseElement || !nextMomentElement) return; + + if (!gameState.gameData || !gameState.gameData.phases || gameState.phaseIndex < 0) { + currentPhaseElement.textContent = 'No game loaded'; + nextPhaseElement.textContent = '--'; + nextMomentElement.textContent = '--'; + return; + } + + const currentPhase = gameState.gameData.phases[gameState.phaseIndex]; + const currentPhaseName = currentPhase.name; + + currentPhaseElement.textContent = currentPhaseName; + + // Get next phase name using our parser + const nextPhaseName = getNextPhaseName(currentPhaseName); + nextPhaseElement.textContent = nextPhaseName || 'Unable to parse'; + + // Find next moment across all phases + if (gameState.momentsData) { + const nextMoment = findNextMoment(currentPhaseName); + if (nextMoment) { + nextMomentElement.innerHTML = `${nextMoment.category}
Phase: ${nextMoment.phase}
Score: ${nextMoment.interest_score}`; + nextMomentElement.style.color = nextMoment.interest_score >= 9 ? '#ff6b6b' : '#4dabf7'; + } else { + nextMomentElement.textContent = 'No future moments found'; + nextMomentElement.style.color = '#888'; + } + } else { + nextMomentElement.textContent = 'Moments data not loaded'; + nextMomentElement.style.color = '#888'; + } +} + +/** + * Finds the next moment chronologically after the current phase + * @param currentPhaseName - Current phase name + * @returns Next moment or null if none found + */ +function findNextMoment(currentPhaseName: string) { + if (!gameState.momentsData?.moments) return null; + + const currentParsed = parsePhase(currentPhaseName); + if (!currentParsed) return null; + + // Get all moments that come after the current phase + const futureMoments = gameState.momentsData.moments + .map(moment => ({ + ...moment, + parsedPhase: parsePhase(moment.phase) + })) + .filter(moment => + moment.parsedPhase && + moment.parsedPhase.order > currentParsed.order + ) + .sort((a, b) => a.parsedPhase!.order - b.parsedPhase!.order); + + return futureMoments.length > 0 ? futureMoments[0] : null; +} \ No newline at end of file diff --git a/ai_animation/src/debug/provinceHighlight.ts b/ai_animation/src/debug/provinceHighlight.ts index 3dfa5b3..0964bf3 100644 --- a/ai_animation/src/debug/provinceHighlight.ts +++ b/ai_animation/src/debug/provinceHighlight.ts @@ -1,4 +1,5 @@ import { gameState } from "../gameState"; +import { provinceInput, highlightProvinceBtn } from "../domElements"; import { ProvinceENUM } from "../types/map"; import { MeshBasicMaterial } from "three"; @@ -23,7 +24,7 @@ export function highlightProvince(provinceName: string): void { // Normalize the province name to uppercase const normalizedName = provinceName.toUpperCase().trim(); - + // Check if it's a valid province if (!Object.values(ProvinceENUM).includes(normalizedName as ProvinceENUM)) { console.warn(`Province "${normalizedName}" not found. Valid provinces are:`, Object.values(ProvinceENUM)); @@ -50,7 +51,7 @@ export function highlightProvince(provinceName: string): void { }; console.log(`Highlighting province: ${normalizedName}`); - + // Start the animation loop animateFlash(); } @@ -72,26 +73,26 @@ function animateFlash(): void { // Calculate flash intensity using sine wave for smooth pulsing const flashIntensity = Math.sin(elapsed * 0.01) * 0.5 + 0.5; // 0 to 1 - + // Interpolate between original color and bright yellow const material = currentFlashAnimation.mesh.material as MeshBasicMaterial; const originalColor = currentFlashAnimation.originalColor; - + // Extract RGB components from original color const originalR = (originalColor >> 16) & 255; const originalG = (originalColor >> 8) & 255; const originalB = originalColor & 255; - + // Flash to a bright yellow (255, 255, 0) const flashR = 255; const flashG = 255; const flashB = 0; - + // Interpolate between original and flash colors const r = Math.round(originalR + (flashR - originalR) * flashIntensity); const g = Math.round(originalG + (flashG - originalG) * flashIntensity); const b = Math.round(originalB + (flashB - originalB) * flashIntensity); - + // Set the new color const newColor = (r << 16) | (g << 8) | b; material.color.setHex(newColor); @@ -109,7 +110,7 @@ function stopCurrentFlash(): void { // Restore original color const material = currentFlashAnimation.mesh.material as MeshBasicMaterial; material.color.setHex(currentFlashAnimation.originalColor); - + currentFlashAnimation = null; } @@ -118,4 +119,43 @@ function stopCurrentFlash(): void { */ export function getAvailableProvinces(): string[] { return Object.values(ProvinceENUM); -} \ No newline at end of file +} + + +// Initialize debug province highlighting functionality +export function initDebugProvinceHighlighting() { + + highlightProvinceBtn.addEventListener('click', () => { + const provinceName = provinceInput.value.trim(); + if (provinceName) { + highlightProvince(provinceName); + } else { + console.warn('Please enter a province name'); + } + }); + + // Allow highlighting on Enter key press + provinceInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + const provinceName = provinceInput.value.trim(); + if (provinceName) { + highlightProvince(provinceName); + } + } + }); + + // Add input validation and autocomplete suggestions + provinceInput.addEventListener('input', () => { + const input = provinceInput.value.toUpperCase().trim(); + const availableProvinces = getAvailableProvinces(); + + // Basic validation - turn input red if it doesn't match any province + if (input && !availableProvinces.some(p => p.startsWith(input))) { + provinceInput.style.borderColor = '#ff4444'; + provinceInput.style.backgroundColor = '#ffe6e6'; + } else { + provinceInput.style.borderColor = '#4f3b16'; + provinceInput.style.backgroundColor = '#faf0d8'; + } + }); +} diff --git a/ai_animation/src/main.ts b/ai_animation/src/main.ts index 8f0a61e..fc4b38b 100644 --- a/ai_animation/src/main.ts +++ b/ai_animation/src/main.ts @@ -12,18 +12,13 @@ import { Tween, Group, Easing } from "@tweenjs/tween.js"; import { initRotatingDisplay, updateRotatingDisplay } from "./components/rotatingDisplay"; import { closeTwoPowerConversation, showTwoPowerConversation } from "./components/twoPowerConversation"; import { PowerENUM } from "./types/map"; -import { debugMenu, provinceInput, highlightProvinceBtn } from "./domElements"; -import { highlightProvince, getAvailableProvinces } from "./debug/provinceHighlight"; -import { DebugMenu } from "./debug/debugMenu"; +import { debugMenuInstance } from "./debug/debugMenu"; //TODO: Create a function that finds a suitable unit location within a given polygon, for placing units better // Currently the location for label, unit, and SC are all the same manually picked location -const isDebugMode = config.isDebugMode; const isStreamingMode = import.meta.env.VITE_STREAMING_MODE -let debugMenuInstance: DebugMenu | null = null; - // --- INITIALIZE SCENE --- function initScene() { gameState.createThreeScene() @@ -49,13 +44,12 @@ function initScene() { gameState.cameraPanAnim = createCameraPan() // Load default game file if in debug mode - if (isDebugMode || isStreamingMode) { + if (config.isDebugMode || isStreamingMode) { gameState.loadGameFile(0); } // Initialize debug menu if in debug mode - if (isDebugMode) { - debugMenuInstance = new DebugMenu(); + if (config.isDebugMode) { debugMenuInstance.show(); } if (isStreamingMode) { @@ -299,49 +293,6 @@ speedSelector.addEventListener('change', e => { } }); -// Initialize debug province highlighting functionality -function initDebugProvinceHighlighting() { - if (!isDebugMode) return; - - highlightProvinceBtn.addEventListener('click', () => { - const provinceName = provinceInput.value.trim(); - if (provinceName) { - highlightProvince(provinceName); - } else { - console.warn('Please enter a province name'); - } - }); - - // Allow highlighting on Enter key press - provinceInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - const provinceName = provinceInput.value.trim(); - if (provinceName) { - highlightProvince(provinceName); - } - } - }); - - // Add input validation and autocomplete suggestions - provinceInput.addEventListener('input', () => { - const input = provinceInput.value.toUpperCase().trim(); - const availableProvinces = getAvailableProvinces(); - - // Basic validation - turn input red if it doesn't match any province - if (input && !availableProvinces.some(p => p.startsWith(input))) { - provinceInput.style.borderColor = '#ff4444'; - provinceInput.style.backgroundColor = '#ffe6e6'; - } else { - provinceInput.style.borderColor = '#4f3b16'; - provinceInput.style.backgroundColor = '#faf0d8'; - } - }); -} - -// Initialize debug functionality after the scene is loaded -if (isDebugMode) { - initDebugProvinceHighlighting(); -} // --- BOOTSTRAP ON PAGE LOAD --- window.addEventListener('load', initScene); diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index 94cfb41..f860e89 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -1,17 +1,22 @@ import { gameState } from "./gameState"; import { logger } from "./logger"; -import { updatePhaseDisplay } from "./domElements"; +import { debugMenu, updatePhaseDisplay } from "./domElements"; import { initUnits } from "./units/create"; import { updateSupplyCenterOwnership, updateLeaderboard, updateMapOwnership as _updateMapOwnership } from "./map/state"; import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows"; import { createAnimationsForNextPhase } from "./units/animate"; import { speakSummary } from "./speech"; import { config } from "./config"; +import { updateNextMomentDisplay } from "./debug/nextMoment"; +import { debugMenuInstance } from "./debug/debugMenu"; // FIXME: Going to previous phases is borked. Units do not animate properly, map doesn't update. function _setPhase(phaseIndex: number) { + if (config.isDebugMode) { + debugMenuInstance.updateTools() + } const gameLength = gameState.gameData.phases.length // Validate that the phaseIndex is within the bounds of the game length. if (phaseIndex >= gameLength || phaseIndex < 0) { diff --git a/ai_animation/src/types/moments.ts b/ai_animation/src/types/moments.ts index 78b7065..1d12b03 100644 --- a/ai_animation/src/types/moments.ts +++ b/ai_animation/src/types/moments.ts @@ -1,6 +1,21 @@ import { z } from 'zod'; import { PowerENUMSchema } from './map'; +/** + * Schema for parsing Diplomacy phase names (e.g., "W1901R") + */ +export const PhaseNameSchema = z.string().regex(/^[SFW]\d{4}[MRA]$/, "Phase name must match format [Season][Year][Phase]"); + +/** + * Schema for parsed phase components + */ +export const ParsedPhaseSchema = z.object({ + season: z.enum(['S', 'F', 'W']), + year: z.number().int().min(1901), + phase: z.enum(['M', 'R', 'A']), + order: z.number().int() +}); + /** * Schema for moment categories used in analysis */ @@ -67,7 +82,80 @@ export const MomentsDataSchema = z.object({ moments: z.array(MomentSchema) }); +/** + * Parses a phase name like "W1901R" into its components + */ +export function parsePhase(phaseName: string): z.infer | null { + const parseResult = PhaseNameSchema.safeParse(phaseName); + if (!parseResult.success) { + return null; + } + + const match = phaseName.match(/^([SFW])(\d{4})([MRA])$/); + if (!match) return null; + + const [, season, yearStr, phase] = match; + const year = parseInt(yearStr, 10); + + const order = calculatePhaseOrder(season as 'S' | 'F' | 'W', year, phase as 'M' | 'R' | 'A'); + + return ParsedPhaseSchema.parse({ + season: season as 'S' | 'F' | 'W', + year, + phase: phase as 'M' | 'R' | 'A', + order + }); +} + +/** + * Calculates chronological order number for a phase + */ +function calculatePhaseOrder(season: 'S' | 'F' | 'W', year: number, phase: 'M' | 'R' | 'A'): number { + const yearMultiplier = (year - 1901) * 9; + + let seasonOffset = 0; + switch (season) { + case 'S': seasonOffset = 0; break; + case 'F': seasonOffset = 3; break; + case 'W': seasonOffset = 6; break; + } + + let phaseOffset = 0; + switch (phase) { + case 'M': phaseOffset = 0; break; + case 'R': phaseOffset = 1; break; + case 'A': phaseOffset = 2; break; + } + + return yearMultiplier + seasonOffset + phaseOffset; +} + +/** + * Generates the next phase name in chronological order + */ +export function getNextPhaseName(currentPhaseName: string): string | null { + const parsed = parsePhase(currentPhaseName); + if (!parsed) return null; + + let { season, year, phase } = parsed; + + switch (phase) { + case 'M': phase = 'R'; break; + case 'R': phase = 'A'; break; + case 'A': + switch (season) { + case 'S': season = 'F'; phase = 'M'; break; + case 'F': season = 'W'; phase = 'M'; break; + case 'W': season = 'S'; phase = 'M'; year++; break; + } + break; + } + + return `${season}${year}${phase}`; +} + // Type exports +export type ParsedPhase = z.infer; export type MomentCategory = z.infer; export type MomentsMetadata = z.infer; export type DiaryContext = z.infer;