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',
+ `
+
+ `,
+ '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;