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",
|
"type": "firefox",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Firefox Debug",
|
"name": "Firefox Debug 9223",
|
||||||
"url": "http://localhost:5173",
|
"url": "http://localhost:5173",
|
||||||
"webRoot": "${workspaceFolder}/ai_animation",
|
"webRoot": "${workspaceFolder}/ai_animation/",
|
||||||
"sourceMapPathOverrides": {
|
"sourceMapPathOverrides": {
|
||||||
"webpack:///./src/*": "${webRoot}/*"
|
"http://localhost:5173/*": "${webRoot}/*"
|
||||||
},
|
},
|
||||||
"runtimeArgs": [
|
"runtimeArgs": [
|
||||||
"--remote-debugging-port=9222"
|
"--remote-debugging-port=9223"
|
||||||
],
|
],
|
||||||
"sourceMaps": true
|
"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,
|
playbackSpeed: 500,
|
||||||
|
|
||||||
// Whether to enable debug mode (faster animations, more console logging)
|
// 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
|
// Duration of unit movement animation in ms
|
||||||
animationDuration: 1500,
|
animationDuration: 1500,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { OrbitControls } from "three/examples/jsm/Addons.js";
|
||||||
import { displayInitialPhase } from "./phase";
|
import { displayInitialPhase } from "./phase";
|
||||||
import { Tween, Group as TweenGroup } from "@tweenjs/tween.js";
|
import { Tween, Group as TweenGroup } from "@tweenjs/tween.js";
|
||||||
import { hideStandingsBoard, } from "./domElements/standingsBoard";
|
import { hideStandingsBoard, } from "./domElements/standingsBoard";
|
||||||
|
import { MomentsDataSchema, MomentsMetadataSchema } from "./types/moments";
|
||||||
|
|
||||||
//FIXME: This whole file is a mess. Need to organize and format
|
//FIXME: This whole file is a mess. Need to organize and format
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ class GameState {
|
||||||
boardState: CoordinateData
|
boardState: CoordinateData
|
||||||
gameId: number
|
gameId: number
|
||||||
gameData: GameSchemaType
|
gameData: GameSchemaType
|
||||||
|
momentsData: MomentSchemaType
|
||||||
phaseIndex: number
|
phaseIndex: number
|
||||||
boardName: string
|
boardName: string
|
||||||
currentPower: PowerENUM
|
currentPower: PowerENUM
|
||||||
|
|
@ -130,6 +132,8 @@ class GameState {
|
||||||
|
|
||||||
// Display the initial phase
|
// Display the initial phase
|
||||||
displayInitialPhase()
|
displayInitialPhase()
|
||||||
|
|
||||||
|
this.loadMomentsFile()
|
||||||
resolve()
|
resolve()
|
||||||
} else {
|
} else {
|
||||||
logger.log("Error: No phases found in game data");
|
logger.log("Error: No phases found in game data");
|
||||||
|
|
@ -205,15 +209,17 @@ class GameState {
|
||||||
*/
|
*/
|
||||||
loadGameFile = (gameId: number) => {
|
loadGameFile = (gameId: number) => {
|
||||||
|
|
||||||
// Clear any data that was already on the board, including messages, units, animations, etc.
|
if (gameId === null || gameId < 0) {
|
||||||
//clearGameData();
|
throw Error(`Attempted to load game with invalid ID ${gameId}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Path to the default game file
|
// Path to the default game file
|
||||||
const gameFilePath = `./default_game${gameId}.json`;
|
const gameFilePath = `./games/${gameId}/game.json`;
|
||||||
|
|
||||||
fetch(gameFilePath)
|
fetch(gameFilePath)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
alert(`Couldn't load gameFile, received reponse code ${response.status}`)
|
||||||
throw new Error(`Failed to load default game file: ${response.status}`);
|
throw new Error(`Failed to load default game file: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,12 +232,15 @@ class GameState {
|
||||||
return response.text();
|
return response.text();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.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
|
// Check for HTML content as a fallback
|
||||||
if (data.trim().startsWith('<!DOCTYPE') || data.trim().startsWith('<html')) {
|
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.');
|
throw new Error('Received HTML instead of JSON. Check the file path.');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Loaded game file, attempting to parse...");
|
console.log("Loaded game file, attempting to parse...");
|
||||||
|
this.gameId = gameId
|
||||||
return this.loadGameData(data);
|
return this.loadGameData(data);
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
@ -249,6 +258,52 @@ class GameState {
|
||||||
console.error(`Error loading game ${gameFilePath}: ${error.message}`);
|
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 = () => {
|
initScene = () => {
|
||||||
if (mapView === null) {
|
if (mapView === null) {
|
||||||
throw Error("Cannot find mapView element, unable to continue.")
|
throw Error("Cannot find mapView element, unable to continue.")
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import "./style.css"
|
import "./style.css"
|
||||||
import { initMap } from "./map/create";
|
import { initMap } from "./map/create";
|
||||||
import { createAnimationsForNextPhase as createAnimationsForNextPhase } from "./units/animate";
|
import { gameState } from "./gameState";
|
||||||
import { gameState, loadGameFile } from "./gameState";
|
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { loadBtn, prevBtn, nextBtn, speedSelector, fileInput, playBtn, mapView, loadGameBtnFunction } from "./domElements";
|
import { loadBtn, prevBtn, nextBtn, speedSelector, fileInput, playBtn, mapView, loadGameBtnFunction } from "./domElements";
|
||||||
import { updateChatWindows } from "./domElements/chatWindows";
|
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
|
//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
|
// 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 isDebugMode = config.isDebugMode;
|
||||||
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE
|
const isStreamingMode = import.meta.env.VITE_STREAMING_MODE
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ export function displayPhase(skipMessages = false) {
|
||||||
if (index >= gameState.gameData.phases.length) {
|
if (index >= gameState.gameData.phases.length) {
|
||||||
displayFinalPhase()
|
displayFinalPhase()
|
||||||
logger.log("Displayed final phase, moving to next game.")
|
logger.log("Displayed final phase, moving to next game.")
|
||||||
loadGamefile(gameState.gameId + 1)
|
gameState.loadNextGame()
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!gameState.gameData || !gameState.gameData.phases ||
|
if (!gameState.gameData || !gameState.gameData.phases ||
|
||||||
index < 0) {
|
index < 0) {
|
||||||
|
|
@ -171,7 +172,59 @@ export function advanceToNextPhase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayFinalPhase() {
|
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;
|
gameState.messagesPlaying = false;
|
||||||
|
|
||||||
// Advance the phase index
|
// Advance the phase index
|
||||||
if (gameState.gameData && gameState.phaseIndex >= gameState.gameData.phases.length - 1) {
|
gameState.phaseIndex++;
|
||||||
logger.log("Reached end of game, Moving to next in 5 seconds");
|
|
||||||
setTimeout(() => {
|
|
||||||
gameState.loadGameFile(gameState.gameId + 1), 5000
|
|
||||||
})
|
|
||||||
|
|
||||||
} else {
|
|
||||||
gameState.phaseIndex++;
|
|
||||||
}
|
|
||||||
if (config.isDebugMode && gameState.gameData) {
|
if (config.isDebugMode && gameState.gameData) {
|
||||||
console.log(`Moving to phase ${gameState.gameData.phases[gameState.phaseIndex].name}`);
|
console.log(`Moving to phase ${gameState.gameData.phases[gameState.phaseIndex].name}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import { ProvinceENUMSchema } from './map';
|
||||||
|
|
||||||
// Define the possible relationship statuses
|
// Define the possible relationship statuses
|
||||||
const RelationshipStatusSchema = z.enum([
|
const RelationshipStatusSchema = z.enum([
|
||||||
"Enemy",
|
"Enemy",
|
||||||
"Unfriendly",
|
"Unfriendly",
|
||||||
"Neutral",
|
"Neutral",
|
||||||
"Friendly",
|
"Friendly",
|
||||||
"Ally"
|
"Ally"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ const PhaseSchema = z.object({
|
||||||
summary: z.string().optional(),
|
summary: z.string().optional(),
|
||||||
// Add agent_relationships based on the provided lmvsgame.json structure
|
// Add agent_relationships based on the provided lmvsgame.json structure
|
||||||
agent_relationships: z.record(
|
agent_relationships: z.record(
|
||||||
PowerENUMSchema,
|
PowerENUMSchema,
|
||||||
z.record(PowerENUMSchema, RelationshipStatusSchema)
|
z.record(PowerENUMSchema, RelationshipStatusSchema)
|
||||||
).optional(),
|
).optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export enum ProvTypeENUM {
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PowerENUM {
|
export enum PowerENUM {
|
||||||
|
EUROPE = "EUROPE", // TODO: Used in the moments.json file to indicate all Powers
|
||||||
AUSTRIA = "AUSTRIA",
|
AUSTRIA = "AUSTRIA",
|
||||||
ENGLAND = "ENGLAND",
|
ENGLAND = "ENGLAND",
|
||||||
FRANCE = "FRANCE",
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const OrderFromString = z.string().transform((orderStr) => {
|
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.
|
// Split the order into tokens by whitespace.
|
||||||
const tokens = orderStr.trim().split(/\s+/);
|
const tokens = orderStr.trim().split(/\s+/);
|
||||||
// The first token is the unit type (A or F)
|
// The first token is the unit type (A or F)
|
||||||
const unitType = tokens[0];
|
const unitType = tokens[0];
|
||||||
// The second token is the origin province.
|
// The second token is the origin province.
|
||||||
const origin = tokens[1];
|
const origin = cleanProvince(tokens[1]);
|
||||||
|
|
||||||
// Check if this order is a support order.
|
// Check if this order is a support order.
|
||||||
if (tokens.includes("S")) {
|
if (tokens.includes("S")) {
|
||||||
const indexS = tokens.indexOf("S");
|
const indexS = tokens.indexOf("S");
|
||||||
// The tokens immediately after "S" define the supported unit.
|
// The tokens immediately after "S" define the supported unit.
|
||||||
const supportedUnitType = tokens[indexS + 1];
|
const supportedUnitType = tokens[indexS + 1];
|
||||||
const supportedOrigin = tokens[indexS + 2];
|
const supportedOrigin = cleanProvince(tokens[indexS + 2]);
|
||||||
let supportedDestination = null;
|
let supportedDestination = null;
|
||||||
// If there is a hyphen following, then a destination is specified.
|
// If there is a hyphen following, then a destination is specified.
|
||||||
if (tokens.length > indexS + 3 && tokens[indexS + 3] === "-") {
|
if (tokens.length > indexS + 3 && tokens[indexS + 3] === "-") {
|
||||||
supportedDestination = tokens[indexS + 4];
|
supportedDestination = cleanProvince(tokens[indexS + 4]);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: "support",
|
type: "support",
|
||||||
|
|
@ -58,15 +64,15 @@ export const OrderFromString = z.string().transform((orderStr) => {
|
||||||
return {
|
return {
|
||||||
type: "retreat",
|
type: "retreat",
|
||||||
unit: { type: unitType, origin },
|
unit: { type: unitType, origin },
|
||||||
destination: tokens.at(-1),
|
destination: cleanProvince(tokens.at(-1) || ''),
|
||||||
raw: orderStr
|
raw: orderStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (tokens.includes("C")) {
|
else if (tokens.includes("C")) {
|
||||||
return {
|
return {
|
||||||
type: "convoy",
|
type: "convoy",
|
||||||
unit: { type: unitType, origin: tokens.at(-3) },
|
unit: { type: unitType, origin: cleanProvince(tokens.at(-3) || '') },
|
||||||
destination: tokens.at(-1),
|
destination: cleanProvince(tokens.at(-1) || ''),
|
||||||
raw: orderStr
|
raw: orderStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +80,7 @@ export const OrderFromString = z.string().transform((orderStr) => {
|
||||||
else if (tokens.includes("-")) {
|
else if (tokens.includes("-")) {
|
||||||
const dashIndex = tokens.indexOf("-");
|
const dashIndex = tokens.indexOf("-");
|
||||||
// The token immediately after "-" is the destination.
|
// The token immediately after "-" is the destination.
|
||||||
const destination = tokens[dashIndex + 1];
|
const destination = cleanProvince(tokens[dashIndex + 1]);
|
||||||
return {
|
return {
|
||||||
type: "move",
|
type: "move",
|
||||||
unit: { type: unitType, origin },
|
unit: { type: unitType, origin },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue