mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-19 12:58:09 +00:00
WIP: Starting the ingestion of moments.json file
This commit is contained in:
parent
06cf18c7bf
commit
9314a411f9
11 changed files with 675 additions and 36 deletions
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
127
ai_animation/README.md
Normal file
127
ai_animation/README.md
Normal file
|
|
@ -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.
|
||||
334
ai_animation/src/components/twoPowerConversation.ts
Normal file
334
ai_animation/src/components/twoPowerConversation.ts
Normal file
|
|
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('<!DOCTYPE') || data.trim().startsWith('<html')) {
|
||||
alert("Unable to load game file")
|
||||
throw new Error('Received HTML instead of JSON. Check the file path.');
|
||||
}
|
||||
|
||||
console.log("Loaded game file, attempting to parse...");
|
||||
this.gameId = gameId
|
||||
return this.loadGameData(data);
|
||||
})
|
||||
.then(() => {
|
||||
|
|
@ -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('<!DOCTYPE') || data.trim().startsWith('<html')) {
|
||||
throw new Error('Received HTML instead of JSON. Check the file path.');
|
||||
}
|
||||
|
||||
console.log("Loaded moments file, attempting to parse...");
|
||||
|
||||
return JSON.parse(data)
|
||||
})
|
||||
.then((data) => {
|
||||
this.momentsData = MomentsDataSchema.parse(data)
|
||||
resolve(data)
|
||||
}).catch((error) => {
|
||||
throw error
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
initScene = () => {
|
||||
if (mapView === null) {
|
||||
throw Error("Cannot find mapView element, unable to continue.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
75
ai_animation/src/types/moments.ts
Normal file
75
ai_animation/src/types/moments.ts
Normal file
|
|
@ -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<typeof MomentCategorySchema>;
|
||||
export type MomentsMetadata = z.infer<typeof MomentsMetadataSchema>;
|
||||
export type DiaryContext = z.infer<typeof DiaryContextSchema>;
|
||||
export type Moment = z.infer<typeof MomentSchema>;
|
||||
export type MomentsDataSchemaType = z.infer<typeof MomentsDataSchema>;
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue