diff --git a/.vscode/launch.json b/.vscode/launch.json index d7f6a59..ae81577 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,14 +4,14 @@ { "type": "firefox", "request": "launch", - "name": "Firefox Debug", + "name": "Firefox Debug 9223", "url": "http://localhost:5173", - "webRoot": "${workspaceFolder}/ai_animation", + "webRoot": "${workspaceFolder}/ai_animation/", "sourceMapPathOverrides": { - "webpack:///./src/*": "${webRoot}/*" + "http://localhost:5173/*": "${webRoot}/*" }, "runtimeArgs": [ - "--remote-debugging-port=9222" + "--remote-debugging-port=9223" ], "sourceMaps": true } diff --git a/ai_animation/README.md b/ai_animation/README.md new file mode 100644 index 0000000..acdc328 --- /dev/null +++ b/ai_animation/README.md @@ -0,0 +1,127 @@ +# AI Diplomacy Animation + +A Three.js-based visualization of Diplomacy game states showing animated conversations between AI players and unit movements. + +## Turn Animation System + +The application uses a sophisticated turn-based animation system that coordinates multiple types of animations through game phases. + +### Architecture Overview + +The turn animation system is built around several key components that work together to create smooth, coordinated transitions: + +1. **Main Game Loop** (`src/main.ts`): Continuous animation loop that monitors all animation states +2. **Game State Management** (`src/gameState.ts`): Central state coordination with boolean locks +3. **Phase Management** (`src/phase.ts`): Handles phase transitions and orchestration +4. **Unit Animation System** (`src/units/animate.ts`): Creates and manages unit movement tweens + +### How Turn Animations Advance + +The turn advancement follows a carefully orchestrated sequence: + +#### 1. Playback Initiation +When the user clicks Play, `togglePlayback()` is triggered, which: +- Sets `gameState.isPlaying = true` +- Hides the standings board +- Starts the camera pan animation +- Begins message display for the current phase + +#### 2. Message Animation Phase +If the current phase has messages: +- `updateChatWindows()` displays messages word-by-word +- Each message appears with typing animation +- `gameState.messagesPlaying` tracks this state + +#### 3. Unit Animation Phase +Once messages complete (or if there are no messages): +- `displayPhaseWithAnimation()` is called +- `createAnimationsForNextPhase()` analyzes the previous phase's orders +- Movement tweens are created for each unit based on order results +- Animations are added to `gameState.unitAnimations` array + +#### 4. Animation Monitoring +The main `animate()` loop continuously: +- Updates all active unit animations +- Filters out completed animations +- Detects when `gameState.unitAnimations.length === 0` + +#### 5. Phase Transition +When all animations complete: +- `advanceToNextPhase()` is scheduled with a configurable delay +- If the phase has a summary, text-to-speech is triggered +- After speech completes, `moveToNextPhase()` increments the phase index +- The cycle repeats for the next phase + +### State Coordination + +The system uses several boolean flags to prevent race conditions and ensure proper sequencing: + +- `messagesPlaying`: Prevents unit animations from starting during message display +- `isAnimating`: Tracks unit animation state +- `isSpeaking`: Prevents phase advancement during text-to-speech +- `isPlaying`: Overall playback state that gates all automatic progression +- `nextPhaseScheduled`: Prevents multiple phase transitions from being scheduled + +### Animation Flow Diagram + +```mermaid +flowchart TD + A[User Clicks Play] --> B[togglePlayback] + B --> C{Phase has messages?} + + C -->|Yes| D[updateChatWindows - Show messages word by word] + C -->|No| E[displayPhaseWithAnimation] + + D --> F[Messages complete] + F --> E + + E --> G[createAnimationsForNextPhase] + G --> H[Create unit movement tweens based on previous phase orders] + H --> I[Add animations to gameState.unitAnimations array] + + I --> J[Main animate loop monitors animations] + J --> K{All animations complete?} + + K -->|No| J + K -->|Yes| L[Schedule advanceToNextPhase with delay] + + L --> M{Phase has summary?} + M -->|Yes| N[speakSummary - Text-to-speech] + M -->|No| O[moveToNextPhase] + + N --> P[Speech complete] + P --> O + + O --> Q[Increment gameState.phaseIndex] + Q --> R[displayPhaseWithAnimation for next phase] + R --> E + + style A fill:#e1f5fe + style J fill:#fff3e0 + style K fill:#f3e5f5 + style O fill:#e8f5e8 +``` + +### Key Design Decisions + +**Centralized State Management**: All animation states are tracked in the `gameState` object, making it easy to coordinate between different animation types and prevent conflicts. + +**Asynchronous Coordination**: Rather than blocking operations, the system uses promises and callbacks to coordinate between message animations, unit movements, and speech synthesis. + +**Graceful Degradation**: If text-to-speech fails or isn't available, the system continues with the next phase automatically. + +**Animation Filtering**: The main loop actively filters completed animations from the tracking array, ensuring memory doesn't grow unbounded during long games. + +**Configurable Timing**: Phase delays and animation durations are configurable through the `config` object, allowing easy adjustment of pacing. + +This architecture ensures smooth, coordinated animations while maintaining clear separation of concerns between different animation systems. + +## Development + +- `npm run dev` - Start the development server +- `npm run build` - Build for production +- `npm run lint` - Run TypeScript linting + +## Game Data + +Game data is loaded from JSON files in the `public/games/` directory. The expected format includes phases with messages, orders, and state information for each turn of the Diplomacy game. \ No newline at end of file diff --git a/ai_animation/src/components/twoPowerConversation.ts b/ai_animation/src/components/twoPowerConversation.ts new file mode 100644 index 0000000..652c55c --- /dev/null +++ b/ai_animation/src/components/twoPowerConversation.ts @@ -0,0 +1,334 @@ +import { gameState } from '../gameState'; +import { config } from '../config'; +import * as THREE from 'three'; + +interface ConversationMessage { + sender: string; + recipient: string; + message: string; + time_sent?: string; + [key: string]: any; +} + +interface TwoPowerDialogueOptions { + power1: string; + power2: string; + messages?: ConversationMessage[]; + title?: string; + onClose?: () => void; +} + +let dialogueOverlay: HTMLElement | null = null; + +/** + * Shows a dialogue box displaying conversation between two powers + * @param options Configuration for the dialogue display + */ +export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void { + const { power1, power2, messages, title, onClose } = options; + + // Close any existing dialogue + closeTwoPowerConversation(); + + // Get messages to display - either provided or filtered from current phase + const conversationMessages = messages || getMessagesBetweenPowers(power1, power2); + + if (conversationMessages.length === 0) { + console.warn(`No messages found between ${power1} and ${power2}`); + return; + } + + // Create overlay + dialogueOverlay = createDialogueOverlay(); + + // Create dialogue container + const dialogueContainer = createDialogueContainer(power1, power2, title); + + // Create conversation area + const conversationArea = createConversationArea(); + dialogueContainer.appendChild(conversationArea); + + // Add close button + const closeButton = createCloseButton(); + dialogueContainer.appendChild(closeButton); + + // Add to overlay + dialogueOverlay.appendChild(dialogueContainer); + document.body.appendChild(dialogueOverlay); + + // Set up event listeners + setupEventListeners(onClose); + + // Animate messages + animateMessages(conversationArea, conversationMessages, power1, power2); +} + +/** + * Closes the two-power conversation dialogue + */ +export function closeTwoPowerConversation(): void { + if (dialogueOverlay) { + dialogueOverlay.classList.add('fade-out'); + setTimeout(() => { + if (dialogueOverlay?.parentNode) { + dialogueOverlay.parentNode.removeChild(dialogueOverlay); + } + dialogueOverlay = null; + }, 300); + } +} + +/** + * Gets messages between two specific powers from current phase + */ +function getMessagesBetweenPowers(power1: string, power2: string): ConversationMessage[] { + const currentPhase = gameState.gameData?.phases[gameState.phaseIndex]; + if (!currentPhase?.messages) return []; + + return currentPhase.messages.filter((msg: any) => { + const sender = msg.sender?.toUpperCase(); + const recipient = msg.recipient?.toUpperCase(); + const p1 = power1.toUpperCase(); + const p2 = power2.toUpperCase(); + + return (sender === p1 && recipient === p2) || + (sender === p2 && recipient === p1); + }).sort((a: any, b: any) => { + // Sort by time_sent if available, otherwise maintain original order + if (a.time_sent && b.time_sent) { + return a.time_sent.localeCompare(b.time_sent); + } + return 0; + }); +} + +/** + * Creates the main overlay element + */ +function createDialogueOverlay(): HTMLElement { + const overlay = document.createElement('div'); + overlay.className = 'dialogue-overlay'; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + opacity: 0; + transition: opacity 0.3s ease; + `; + + // Trigger fade in + setTimeout(() => overlay.style.opacity = '1', 10); + + return overlay; +} + +/** + * Creates the main dialogue container + */ +function createDialogueContainer(power1: string, power2: string, title?: string): HTMLElement { + const container = document.createElement('div'); + container.className = 'dialogue-container'; + container.style.cssText = ` + background: radial-gradient(ellipse at center, #f7ecd1 0%, #dbc08c 100%); + border: 3px solid #4f3b16; + border-radius: 8px; + box-shadow: 0 0 15px rgba(0,0,0,0.5); + width: 80%; + max-width: 800px; + height: 80%; + max-height: 600px; + position: relative; + padding: 20px; + display: flex; + flex-direction: column; + `; + + // Add title + const titleElement = document.createElement('h2'); + titleElement.textContent = title || `Conversation: ${power1} & ${power2}`; + titleElement.style.cssText = ` + margin: 0 0 20px 0; + text-align: center; + color: #4f3b16; + font-family: 'Times New Roman', serif; + font-size: 24px; + font-weight: bold; + `; + container.appendChild(titleElement); + + return container; +} + +/** + * Creates the conversation display area + */ +function createConversationArea(): HTMLElement { + const area = document.createElement('div'); + area.className = 'conversation-area'; + area.style.cssText = ` + flex: 1; + overflow-y: auto; + padding: 10px; + border: 2px solid #8b7355; + border-radius: 5px; + background: rgba(255, 255, 255, 0.3); + display: flex; + flex-direction: column; + gap: 15px; + `; + + return area; +} + +/** + * Creates a close button + */ +function createCloseButton(): HTMLElement { + const button = document.createElement('button'); + button.textContent = ''; + button.className = 'close-button'; + button.style.cssText = ` + position: absolute; + top: 10px; + right: 15px; + background: none; + border: none; + font-size: 30px; + color: #4f3b16; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + `; + + button.addEventListener('mouseenter', () => { + button.style.color = '#8b0000'; + button.style.transform = 'scale(1.1)'; + }); + + button.addEventListener('mouseleave', () => { + button.style.color = '#4f3b16'; + button.style.transform = 'scale(1)'; + }); + + return button; +} + +/** + * Sets up event listeners for the dialogue + */ +function setupEventListeners(onClose?: () => void): void { + if (!dialogueOverlay) return; + + const closeButton = dialogueOverlay.querySelector('.close-button'); + const handleClose = () => { + closeTwoPowerConversation(); + onClose?.(); + }; + + // Close button click + closeButton?.addEventListener('click', handleClose); + + // Escape key + const handleKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose(); + document.removeEventListener('keydown', handleKeydown); + } + }; + document.addEventListener('keydown', handleKeydown); + + // Click outside to close + dialogueOverlay.addEventListener('click', (e) => { + if (e.target === dialogueOverlay) { + handleClose(); + } + }); +} + +/** + * Animates the display of messages in the conversation + */ +async function animateMessages( + container: HTMLElement, + messages: ConversationMessage[], + power1: string, + power2: string +): Promise { + for (const message of messages) { + const messageElement = createMessageElement(message, power1, power2); + container.appendChild(messageElement); + + // Animate message appearance + messageElement.style.opacity = '0'; + messageElement.style.transform = 'translateY(20px)'; + + await new Promise(resolve => { + setTimeout(() => { + messageElement.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + messageElement.style.opacity = '1'; + messageElement.style.transform = 'translateY(0)'; + + // Scroll to bottom + container.scrollTop = container.scrollHeight; + + setTimeout(resolve, 300 + (1000 / config.playbackSpeed)); + }, 100); + }); + } +} + +/** + * Creates a message element for display + */ +function createMessageElement(message: ConversationMessage, power1: string, power2: string): HTMLElement { + const messageDiv = document.createElement('div'); + const isFromPower1 = message.sender.toUpperCase() === power1.toUpperCase(); + + messageDiv.className = `message ${isFromPower1 ? 'power1' : 'power2'}`; + messageDiv.style.cssText = ` + display: flex; + flex-direction: column; + align-items: ${isFromPower1 ? 'flex-start' : 'flex-end'}; + margin: 5px 0; + `; + + // Sender label + const senderLabel = document.createElement('div'); + senderLabel.textContent = message.sender; + senderLabel.className = `power-${message.sender.toLowerCase()}`; + senderLabel.style.cssText = ` + font-size: 12px; + font-weight: bold; + margin-bottom: 5px; + color: #4f3b16; + `; + + // Message bubble + const messageBubble = document.createElement('div'); + messageBubble.textContent = message.message; + messageBubble.style.cssText = ` + background: ${isFromPower1 ? '#e6f3ff' : '#fff3e6'}; + border: 2px solid ${isFromPower1 ? '#4a90e2' : '#e67e22'}; + border-radius: 15px; + padding: 10px 15px; + max-width: 70%; + word-wrap: break-word; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + `; + + messageDiv.appendChild(senderLabel); + messageDiv.appendChild(messageBubble); + + return messageDiv; +} \ No newline at end of file diff --git a/ai_animation/src/config.ts b/ai_animation/src/config.ts index 9e5e38c..39fc119 100644 --- a/ai_animation/src/config.ts +++ b/ai_animation/src/config.ts @@ -6,7 +6,7 @@ export const config = { playbackSpeed: 500, // Whether to enable debug mode (faster animations, more console logging) - isDebugMode: true, + isDebugMode: import.meta.env.VITE_DEBUG_MODE || false, // Duration of unit movement animation in ms animationDuration: 1500, diff --git a/ai_animation/src/gameState.ts b/ai_animation/src/gameState.ts index a4f2a74..aa99944 100644 --- a/ai_animation/src/gameState.ts +++ b/ai_animation/src/gameState.ts @@ -11,6 +11,7 @@ import { OrbitControls } from "three/examples/jsm/Addons.js"; import { displayInitialPhase } from "./phase"; import { Tween, Group as TweenGroup } from "@tweenjs/tween.js"; import { hideStandingsBoard, } from "./domElements/standingsBoard"; +import { MomentsDataSchema, MomentsMetadataSchema } from "./types/moments"; //FIXME: This whole file is a mess. Need to organize and format @@ -33,6 +34,7 @@ class GameState { boardState: CoordinateData gameId: number gameData: GameSchemaType + momentsData: MomentSchemaType phaseIndex: number boardName: string currentPower: PowerENUM @@ -130,6 +132,8 @@ class GameState { // Display the initial phase displayInitialPhase() + + this.loadMomentsFile() resolve() } else { logger.log("Error: No phases found in game data"); @@ -205,15 +209,17 @@ class GameState { */ loadGameFile = (gameId: number) => { - // Clear any data that was already on the board, including messages, units, animations, etc. - //clearGameData(); + if (gameId === null || gameId < 0) { + throw Error(`Attempted to load game with invalid ID ${gameId}`) + } // Path to the default game file - const gameFilePath = `./default_game${gameId}.json`; + const gameFilePath = `./games/${gameId}/game.json`; fetch(gameFilePath) .then(response => { if (!response.ok) { + alert(`Couldn't load gameFile, received reponse code ${response.status}`) throw new Error(`Failed to load default game file: ${response.status}`); } @@ -226,12 +232,15 @@ class GameState { return response.text(); }) .then(data => { + // FIXME: This occurs because the server seems to resolve any URL to the homepage. This is the case for Vite's Dev Server. // Check for HTML content as a fallback if (data.trim().startsWith(' { @@ -249,6 +258,52 @@ class GameState { console.error(`Error loading game ${gameFilePath}: ${error.message}`); }); } + + /* + * Load the moments.json file for the given gameID. This includes all the "important" moments for a given game that should be highlighted + * + */ + loadMomentsFile = () => { + // Path to the default game file + const momentsFilePath = `./games/${this.gameId}/moments.json`; + + return new Promise((resolve, reject) => { + fetch(momentsFilePath) + .then(response => { + if (!response.ok) { + alert(`Couldn't load moments file, received reponse code ${response.status}`) + throw new Error(`Failed to load moments file: ${response.status}`); + } + + // FIXME: This occurs because the server seems to resolve any URL to the homepage. This is the case for Vite's Dev Server. + // Check content type to avoid HTML errors + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('text/html')) { + alert("Unable to load moments file") + throw new Error('Received HTML instead of JSON. Check the file path.'); + } + + return response.text(); + }) + .then(data => { + // Check for HTML content as a fallback + if (data.trim().startsWith(' { + this.momentsData = MomentsDataSchema.parse(data) + resolve(data) + }).catch((error) => { + throw error + }) + }) + } + initScene = () => { if (mapView === null) { throw Error("Cannot find mapView element, unable to continue.") diff --git a/ai_animation/src/main.ts b/ai_animation/src/main.ts index d2ddb52..50ac468 100644 --- a/ai_animation/src/main.ts +++ b/ai_animation/src/main.ts @@ -1,8 +1,7 @@ import * as THREE from "three"; import "./style.css" import { initMap } from "./map/create"; -import { createAnimationsForNextPhase as createAnimationsForNextPhase } from "./units/animate"; -import { gameState, loadGameFile } from "./gameState"; +import { gameState } from "./gameState"; import { logger } from "./logger"; import { loadBtn, prevBtn, nextBtn, speedSelector, fileInput, playBtn, mapView, loadGameBtnFunction } from "./domElements"; import { updateChatWindows } from "./domElements/chatWindows"; @@ -15,10 +14,7 @@ import { initRotatingDisplay, updateRotatingDisplay } from "./components/rotatin //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 -// -// TODO: When loading an invalide file, show an error. -//const isDebugMode = process.env.NODE_ENV === 'development' || localStorage.getItem('debug') === 'true'; const isDebugMode = config.isDebugMode; const isStreamingMode = import.meta.env.VITE_STREAMING_MODE diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index 431642c..f93a35a 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -21,7 +21,8 @@ export function displayPhase(skipMessages = false) { if (index >= gameState.gameData.phases.length) { displayFinalPhase() logger.log("Displayed final phase, moving to next game.") - loadGamefile(gameState.gameId + 1) + gameState.loadNextGame() + return; } if (!gameState.gameData || !gameState.gameData.phases || index < 0) { @@ -171,7 +172,59 @@ export function advanceToNextPhase() { } function displayFinalPhase() { - // Stub for doing anything on the final phase of a game. + if (!gameState.gameData || !gameState.gameData.phases || gameState.gameData.phases.length === 0) { + return; + } + + // Get the final phase to determine the winner + const finalPhase = gameState.gameData.phases[gameState.gameData.phases.length - 1]; + + if (!finalPhase.state?.centers) { + logger.log("No supply center data available to determine winner"); + return; + } + + // Find the power with the most supply centers + let winner = ''; + let maxCenters = 0; + + for (const [power, centers] of Object.entries(finalPhase.state.centers)) { + const centerCount = Array.isArray(centers) ? centers.length : 0; + if (centerCount > maxCenters) { + maxCenters = centerCount; + winner = power; + } + } + + // Display victory message + if (winner && maxCenters > 0) { + const victoryMessage = `🏆 GAME OVER - ${winner} WINS with ${maxCenters} supply centers! 🏆`; + + // Add victory message to news banner with dramatic styling + addToNewsBanner(victoryMessage); + + // Log the victory + logger.log(`Victory! ${winner} wins the game with ${maxCenters} supply centers.`); + + // Display final standings in console + const standings = Object.entries(finalPhase.state.centers) + .map(([power, centers]) => ({ + power, + centers: Array.isArray(centers) ? centers.length : 0 + })) + .sort((a, b) => b.centers - a.centers); + + console.log("Final Standings:"); + standings.forEach((entry, index) => { + const medal = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : " "; + console.log(`${medal} ${entry.power}: ${entry.centers} centers`); + }); + + // Show victory in info panel + logger.updateInfoPanel(`🏆 ${winner} VICTORIOUS! 🏆\n\nFinal Score: ${maxCenters} supply centers\n\nCheck console for full standings.`); + } else { + logger.log("Could not determine game winner"); + } } /** @@ -189,15 +242,7 @@ function moveToNextPhase() { gameState.messagesPlaying = false; // Advance the phase index - if (gameState.gameData && gameState.phaseIndex >= gameState.gameData.phases.length - 1) { - logger.log("Reached end of game, Moving to next in 5 seconds"); - setTimeout(() => { - gameState.loadGameFile(gameState.gameId + 1), 5000 - }) - - } else { - gameState.phaseIndex++; - } + gameState.phaseIndex++; if (config.isDebugMode && gameState.gameData) { console.log(`Moving to phase ${gameState.gameData.phases[gameState.phaseIndex].name}`); } diff --git a/ai_animation/src/types/gameState.ts b/ai_animation/src/types/gameState.ts index aa9e892..cd58788 100644 --- a/ai_animation/src/types/gameState.ts +++ b/ai_animation/src/types/gameState.ts @@ -5,10 +5,10 @@ import { ProvinceENUMSchema } from './map'; // Define the possible relationship statuses const RelationshipStatusSchema = z.enum([ - "Enemy", - "Unfriendly", - "Neutral", - "Friendly", + "Enemy", + "Unfriendly", + "Neutral", + "Friendly", "Ally" ]); @@ -32,7 +32,7 @@ const PhaseSchema = z.object({ summary: z.string().optional(), // Add agent_relationships based on the provided lmvsgame.json structure agent_relationships: z.record( - PowerENUMSchema, + PowerENUMSchema, z.record(PowerENUMSchema, RelationshipStatusSchema) ).optional(), }); diff --git a/ai_animation/src/types/map.ts b/ai_animation/src/types/map.ts index cbb8ae5..db54994 100644 --- a/ai_animation/src/types/map.ts +++ b/ai_animation/src/types/map.ts @@ -9,6 +9,7 @@ export enum ProvTypeENUM { } export enum PowerENUM { + EUROPE = "EUROPE", // TODO: Used in the moments.json file to indicate all Powers AUSTRIA = "AUSTRIA", ENGLAND = "ENGLAND", FRANCE = "FRANCE", diff --git a/ai_animation/src/types/moments.ts b/ai_animation/src/types/moments.ts new file mode 100644 index 0000000..78b7065 --- /dev/null +++ b/ai_animation/src/types/moments.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; +import { PowerENUMSchema } from './map'; + +/** + * Schema for moment categories used in analysis + */ +export const MomentCategorySchema = z.enum([ + 'BETRAYAL', + 'PROMISE_ADJUSTMENT', + 'COLLABORATION', + 'PLAYING_BOTH_SIDES', + 'BRILLIANT_STRATEGY', + 'STRATEGIC_BLUNDER', + 'STRATEGIC_BURST (UNFORESEEN_OUTCOME)', +]); + + +/** + * Schema for metadata about the moments analysis + */ +export const MomentsMetadataSchema = z.object({ + timestamp: z.string(), + generated_at: z.string(), + source_folder: z.string(), + analysis_model: z.string(), + total_moments: z.number(), + moment_categories: z.object({ + betrayals: z.number(), + collaborations: z.number(), + playing_both_sides: z.number(), + brilliant_strategies: z.number(), + strategic_blunders: z.number() + }), + score_distribution: z.object({ + scores_9_10: z.number(), + scores_7_8: z.number(), + scores_4_6: z.number(), + scores_1_3: z.number() + }) +}); + +/** + * Schema for diary context entries for each power + */ +export const DiaryContextSchema = z.record(PowerENUMSchema, z.string()); + +/** + * Schema for an individual moment in the game + */ +export const MomentSchema = z.object({ + phase: z.string(), + category: MomentCategorySchema, + powers_involved: z.array(PowerENUMSchema), + promise_agreement: z.string(), + actual_action: z.string(), + impact: z.string(), + interest_score: z.number().min(0).max(10), + diary_context: DiaryContextSchema +}); + +/** + * Schema for the complete moments.json file + */ +export const MomentsDataSchema = z.object({ + metadata: MomentsMetadataSchema, + power_models: z.record(PowerENUMSchema, z.string()), + moments: z.array(MomentSchema) +}); + +// Type exports +export type MomentCategory = z.infer; +export type MomentsMetadata = z.infer; +export type DiaryContext = z.infer; +export type Moment = z.infer; +export type MomentsDataSchemaType = z.infer; diff --git a/ai_animation/src/types/unitOrders.ts b/ai_animation/src/types/unitOrders.ts index 0a2b938..4fce7e1 100644 --- a/ai_animation/src/types/unitOrders.ts +++ b/ai_animation/src/types/unitOrders.ts @@ -1,23 +1,29 @@ import { z } from "zod"; export const OrderFromString = z.string().transform((orderStr) => { + // Helper function to clean province names by removing coast specifications + const cleanProvince = (province: string): string => { + if (!province) return province; + return province.split('/')[0]; + }; + // Split the order into tokens by whitespace. const tokens = orderStr.trim().split(/\s+/); // The first token is the unit type (A or F) const unitType = tokens[0]; // The second token is the origin province. - const origin = tokens[1]; + const origin = cleanProvince(tokens[1]); // Check if this order is a support order. if (tokens.includes("S")) { const indexS = tokens.indexOf("S"); // The tokens immediately after "S" define the supported unit. const supportedUnitType = tokens[indexS + 1]; - const supportedOrigin = tokens[indexS + 2]; + const supportedOrigin = cleanProvince(tokens[indexS + 2]); let supportedDestination = null; // If there is a hyphen following, then a destination is specified. if (tokens.length > indexS + 3 && tokens[indexS + 3] === "-") { - supportedDestination = tokens[indexS + 4]; + supportedDestination = cleanProvince(tokens[indexS + 4]); } return { type: "support", @@ -58,15 +64,15 @@ export const OrderFromString = z.string().transform((orderStr) => { return { type: "retreat", unit: { type: unitType, origin }, - destination: tokens.at(-1), + destination: cleanProvince(tokens.at(-1) || ''), raw: orderStr } } else if (tokens.includes("C")) { return { type: "convoy", - unit: { type: unitType, origin: tokens.at(-3) }, - destination: tokens.at(-1), + unit: { type: unitType, origin: cleanProvince(tokens.at(-3) || '') }, + destination: cleanProvince(tokens.at(-1) || ''), raw: orderStr } } @@ -74,7 +80,7 @@ export const OrderFromString = z.string().transform((orderStr) => { else if (tokens.includes("-")) { const dashIndex = tokens.indexOf("-"); // The token immediately after "-" is the destination. - const destination = tokens[dashIndex + 1]; + const destination = cleanProvince(tokens[dashIndex + 1]); return { type: "move", unit: { type: unitType, origin },