WIP: Starting the ingestion of moments.json file

This commit is contained in:
Tyler Marques 2025-05-26 10:50:35 -07:00
parent 06cf18c7bf
commit 9314a411f9
No known key found for this signature in database
GPG key ID: CB99EDCF41D3016F
11 changed files with 675 additions and 36 deletions

8
.vscode/launch.json vendored
View file

@ -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
View 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.

View 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;
}

View file

@ -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,

View file

@ -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.")

View file

@ -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

View file

@ -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}`);
}

View file

@ -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(),
});

View file

@ -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",

View 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>;

View file

@ -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 },