Merge pull request #34 from Tylermarques/main

Lots of minor bugfixes
This commit is contained in:
AlxAI 2025-06-04 19:39:26 -07:00 committed by GitHub
commit 417e508300
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 212 additions and 478 deletions

View file

@ -0,0 +1,3 @@
**/*.zip
./node_modules/
./public/games/

View file

@ -0,0 +1 @@
./games/

View file

@ -0,0 +1,38 @@
#!/bin/bash
# Navigate to the games directory
cd "public/games" || exit 1
# Initialize counter
counter=0
# Process each directory (excluding files)
for dir in */; do
# Remove trailing slash from directory name
dir_name="${dir%/}"
# Skip if it's not a directory or if it's a numeric directory (already processed)
if [[ ! -d "$dir_name" ]] || [[ "$dir_name" =~ ^[0-9]+$ ]]; then
continue
fi
echo "Processing: $dir_name"
# Create empty file with same name as directory in parent directory
touch "../$dir_name"
# Check if lmvsgame.json exists and rename it to game.json
if [[ -f "$dir_name/lmvsgame.json" ]]; then
mv "$dir_name/lmvsgame.json" "$dir_name/game.json"
echo " Renamed lmvsgame.json to game.json"
fi
# Rename directory to integer
mv "$dir_name" "$counter"
echo " Renamed directory to: $counter"
# Increment counter
((counter++))
done
echo "Reorganization complete. Processed $counter directories."

View file

@ -178,65 +178,6 @@ export function updateChatWindows(phase: any, stepMessages = false) {
} }
}); });
gameState.messagesPlaying = false; gameState.messagesPlaying = false;
// If instant chat is enabled during stepwise mode, immediately proceed to next phase logic
if (stepMessages && config.isInstantMode) {
// Trigger the same logic as the end of stepwise message display
if (gameState.isPlaying && !gameState.isSpeaking) {
if (gameState.gameData && gameState.gameData.phases) {
const currentPhase = gameState.gameData.phases[gameState.phaseIndex];
if (config.isDebugMode) {
console.log(`Instant chat enabled - processing end of phase ${currentPhase.name}`);
}
// Show summary first if available
if (currentPhase.summary?.trim()) {
addToNewsBanner(`(${currentPhase.name}) ${currentPhase.summary}`);
}
// Get previous phase for animations
const prevIndex = gameState.phaseIndex > 0 ? gameState.phaseIndex - 1 : null;
const previousPhase = prevIndex !== null ? gameState.gameData.phases[prevIndex] : null;
// Only schedule next phase if not already scheduled
if (!gameState.nextPhaseScheduled) {
gameState.nextPhaseScheduled = true;
// Show animations for current phase's orders
if (previousPhase) {
if (config.isDebugMode) {
console.log(`Animating orders from ${previousPhase.name} to ${currentPhase.name}`);
}
// After animations complete, advance to next phase with longer delay
gameState.playbackTimer = setTimeout(() => {
if (gameState.isPlaying) {
if (config.isDebugMode) {
console.log(`Animations complete, advancing from ${currentPhase.name}`);
}
advanceToNextPhase();
}
}, config.playbackSpeed + config.animationDuration); // Wait for both summary and animations
} else {
// For first phase, use shorter delay since there are no animations
if (config.isDebugMode) {
console.log(`First phase ${currentPhase.name} - no animations to wait for`);
}
gameState.playbackTimer = setTimeout(() => {
if (gameState.isPlaying) {
if (config.isDebugMode) {
console.log(`Advancing from first phase ${currentPhase.name}`);
}
advanceToNextPhase();
}
}, config.playbackSpeed); // Only wait for summary, no animation delay
}
}
}
}
}
} else { } else {
// Stepwise mode: show one message at a time, animating word-by-word // Stepwise mode: show one message at a time, animating word-by-word
gameState.messagesPlaying = true; gameState.messagesPlaying = true;
@ -259,64 +200,7 @@ export function updateChatWindows(phase: any, stepMessages = false) {
if (config.isDebugMode) { if (config.isDebugMode) {
console.log(`All messages displayed in ${Date.now() - messageStartTime}ms`); console.log(`All messages displayed in ${Date.now() - messageStartTime}ms`);
} }
gameState.messagesPlaying = false; gameState.messagesPlaying = false;
// Only proceed if we're in playback mode and not speaking
if (gameState.isPlaying && !gameState.isSpeaking) {
if (gameState.gameData && gameState.gameData.phases) {
const currentPhase = gameState.gameData.phases[gameState.phaseIndex];
if (config.isDebugMode) {
console.log(`Processing end of phase ${currentPhase.name}`);
}
// Show summary first if available
if (currentPhase.summary?.trim()) {
addToNewsBanner(`(${currentPhase.name}) ${currentPhase.summary}`);
}
// Get previous phase for animations
const prevIndex = gameState.phaseIndex > 0 ? gameState.phaseIndex - 1 : null;
const previousPhase = prevIndex !== null ? gameState.gameData.phases[prevIndex] : null;
// Only schedule next phase if not already scheduled
if (!gameState.nextPhaseScheduled) {
gameState.nextPhaseScheduled = true;
// Show animations for current phase's orders
if (previousPhase) {
if (config.isDebugMode) {
console.log(`Animating orders from ${previousPhase.name} to ${currentPhase.name}`);
}
// After animations complete, advance to next phase with longer delay
gameState.playbackTimer = setTimeout(() => {
if (gameState.isPlaying) {
if (config.isDebugMode) {
console.log(`Animations complete, advancing from ${currentPhase.name}`);
}
advanceToNextPhase();
}
}, config.effectivePlaybackSpeed + config.effectiveAnimationDuration); // Wait for both summary and animations
} else {
// For first phase, use shorter delay since there are no animations
if (config.isDebugMode) {
console.log(`First phase ${currentPhase.name} - no animations to wait for`);
}
gameState.playbackTimer = setTimeout(() => {
if (gameState.isPlaying) {
if (config.isDebugMode) {
console.log(`Advancing from first phase ${currentPhase.name}`);
}
advanceToNextPhase();
}
}, config.effectivePlaybackSpeed); // Only wait for summary, no animation delay
}
}
}
}
return; return;
} }

View file

@ -1,244 +0,0 @@
import { relationshipsBtn } from '../domElements';
import { gameState } from '../gameState';
import { PowerENUM } from '../types/map';
import { GameSchemaType } from '../types/gameState';
import { renderRelationshipHistoryChartView, DisplayType } from '../components/rotatingDisplay';
import { getPowerDisplayName } from '../utils/powerNames';
// DOM element references
let relationshipPopupContainer: HTMLElement | null = null;
let relationshipContent: HTMLElement | null = null;
let closeButton: HTMLElement | null = null;
/**
* Initialize the relationship popup by creating DOM elements and attaching event handlers
*/
export function initRelationshipPopup(): void {
// Create the container if it doesn't exist
if (!document.getElementById('relationship-popup-container')) {
createRelationshipPopupElements();
}
// Get references to the created elements
relationshipPopupContainer = document.getElementById('relationship-popup-container');
relationshipContent = document.getElementById('relationship-content');
closeButton = document.getElementById('relationship-close-btn');
// Add event listeners
if (closeButton) {
closeButton.addEventListener('click', hideRelationshipPopup);
}
// Add click handler for the relationships button
if (relationshipsBtn) {
relationshipsBtn.addEventListener('click', toggleRelationshipPopup);
}
}
/**
* Create all DOM elements needed for the relationship popup
*/
function createRelationshipPopupElements(): void {
const container = document.createElement('div');
container.id = 'relationship-popup-container';
container.className = 'relationship-popup-container';
// Create header
const header = document.createElement('div');
header.className = 'relationship-header';
const title = document.createElement('h2');
title.textContent = 'Diplomatic Relations';
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.id = 'relationship-close-btn';
closeBtn.textContent = '×';
closeBtn.title = 'Close Relationships Chart';
header.appendChild(closeBtn);
container.appendChild(header);
// Create content container
const content = document.createElement('div');
content.id = 'relationship-content';
content.className = 'relationship-content';
container.appendChild(content);
// Add to document
document.body.appendChild(container);
}
/**
* Toggle the visibility of the relationship popup
*/
export function toggleRelationshipPopup(): void {
if (relationshipPopupContainer) {
if (relationshipPopupContainer.classList.contains('visible')) {
hideRelationshipPopup();
} else {
showRelationshipPopup();
}
}
}
/**
* Show the relationship popup
*/
export function showRelationshipPopup(): void {
if (relationshipPopupContainer && relationshipContent) {
relationshipPopupContainer.classList.add('visible');
// Only render if we have game data
if (gameState.gameData) {
renderRelationshipChart();
} else {
relationshipContent.innerHTML = '<div class="no-data-message">No game data loaded. Please load a game to view relationships.</div>';
}
}
}
/**
* Hide the relationship popup
*/
export function hideRelationshipPopup(): void {
if (relationshipPopupContainer) {
relationshipPopupContainer.classList.remove('visible');
}
}
/**
* Render the relationship chart in the popup
*/
function renderRelationshipChart(): void {
if (!relationshipContent || !gameState.gameData) return;
// Clear current content
relationshipContent.innerHTML = '';
// Get a list of powers that have relationship data
const powersWithRelationships = new Set<string>();
// Check all phases for relationships
if (gameState.gameData && gameState.gameData.phases) {
// Debug what relationship data we have
let hasRelationshipData = false;
for (const phase of gameState.gameData.phases) {
if (phase.agent_relationships) {
hasRelationshipData = true;
// Add powers that have relationship data defined
Object.keys(phase.agent_relationships).forEach(power => {
powersWithRelationships.add(power);
});
}
}
if (!hasRelationshipData) {
console.log("No relationship data found in any phase");
}
}
// Create a container for each power's relationship chart
for (const power of Object.values(PowerENUM)) {
// Skip any non-string values
if (typeof power !== 'string') continue;
// Check if this power has relationship data
if (powersWithRelationships.has(power)) {
const powerContainer = document.createElement('div');
powerContainer.className = `power-relationship-container power-${power.toLowerCase()}`;
const powerHeader = document.createElement('h3');
powerHeader.className = `power-${power.toLowerCase()}`;
powerHeader.textContent = getPowerDisplayName(power as PowerENUM);
powerContainer.appendChild(powerHeader);
const chartContainer = document.createElement('div');
chartContainer.className = 'relationship-chart-container';
// Use the existing chart rendering function
renderRelationshipHistoryChartView(
chartContainer,
gameState.gameData,
gameState.phaseIndex,
power as PowerENUM
);
powerContainer.appendChild(chartContainer);
relationshipContent.appendChild(powerContainer);
}
}
// If no powers have relationship data, create message to say so
if (powersWithRelationships.size === 0) {
console.log("No relationship data found in game");
// Create sample relationship data for all powers in the game
const allPowers = new Set<string>();
// Find all powers from units and centers
if (gameState.gameData && gameState.gameData.phases && gameState.gameData.phases.length > 0) {
const currentPhase = gameState.gameData.phases[gameState.phaseIndex];
if (currentPhase.state?.units) {
Object.keys(currentPhase.state.units).forEach(power => allPowers.add(power));
}
if (currentPhase.state?.centers) {
Object.keys(currentPhase.state.centers).forEach(power => allPowers.add(power));
}
// Only proceed if we found some powers
if (allPowers.size > 0) {
console.log(`Found ${allPowers.size} powers in game, creating sample relationships`);
// For each power, create a container and chart
for (const power of allPowers) {
const powerContainer = document.createElement('div');
powerContainer.className = `power-relationship-container power-${power.toLowerCase()}`;
const powerHeader = document.createElement('h3');
powerHeader.className = `power-${power.toLowerCase()}`;
powerHeader.textContent = getPowerDisplayName(power as PowerENUM);
powerContainer.appendChild(powerHeader);
const chartContainer = document.createElement('div');
chartContainer.className = 'relationship-chart-container';
// Create a message about sample data
const sampleMessage = document.createElement('div');
sampleMessage.className = 'sample-data-message';
sampleMessage.innerHTML = `<strong>Note:</strong> No relationship data found for ${power}.
This chart will display when relationship data is available.`;
chartContainer.appendChild(sampleMessage);
powerContainer.appendChild(chartContainer);
relationshipContent.appendChild(powerContainer);
}
} else {
// If we couldn't find any powers, show the no data message
const noDataMsg = document.createElement('div');
noDataMsg.className = 'no-data-message';
noDataMsg.textContent = 'No relationship data available in this game file.';
relationshipContent.appendChild(noDataMsg);
}
} else {
// If no phases, show the no data message
const noDataMsg = document.createElement('div');
noDataMsg.className = 'no-data-message';
noDataMsg.textContent = 'No relationship data available in this game file.';
relationshipContent.appendChild(noDataMsg);
}
}
}
/**
* Update the relationship popup when game data changes
*/
export function updateRelationshipPopup(): void {
if (relationshipPopupContainer &&
relationshipPopupContainer.classList.contains('visible')) {
renderRelationshipChart();
}
}

View file

@ -7,10 +7,10 @@ import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay
import { createChatWindows } from "./domElements/chatWindows"; import { createChatWindows } from "./domElements/chatWindows";
import { logger } from "./logger"; import { logger } from "./logger";
import { OrbitControls } from "three/examples/jsm/Addons.js"; import { OrbitControls } from "three/examples/jsm/Addons.js";
import { displayInitialPhase } from "./phase"; import { displayInitialPhase, togglePlayback } 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, MomentsDataSchemaType, Moment, NormalizedMomentsData } from "./types/moments"; import { MomentsDataSchema, Moment, NormalizedMomentsData } 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
@ -20,13 +20,36 @@ enum AvailableMaps {
/** /**
* Return a random power from the PowerENUM for the player to control * Return a random power from the PowerENUM for the player to control
* Only returns powers that have more than 2 supply centers in the last phase
*/ */
function getRandomPower(): PowerENUM { function getRandomPower(gameData?: GameSchemaType): PowerENUM {
const values = Object.values(PowerENUM).filter(power => const allPowers = Object.values(PowerENUM).filter(power =>
power !== PowerENUM.GLOBAL && power !== PowerENUM.EUROPE power !== PowerENUM.GLOBAL && power !== PowerENUM.EUROPE
); );
const idx = Math.floor(Math.random() * values.length);
return values[idx]; // If no game data provided, return any random power
if (!gameData || !gameData.phases || gameData.phases.length === 0) {
const idx = Math.floor(Math.random() * allPowers.length);
return allPowers[idx];
}
// Get the last phase to check supply centers
const lastPhase = gameData.phases[gameData.phases.length - 1];
// Filter powers that have more than 2 supply centers
const eligiblePowers = allPowers.filter(power => {
const centers = lastPhase.state?.centers?.[power];
return centers && centers.length > 2;
});
// If no powers have more than 2 centers, fall back to any power
if (eligiblePowers.length === 0) {
const idx = Math.floor(Math.random() * allPowers.length);
return allPowers[idx];
}
const idx = Math.floor(Math.random() * eligiblePowers.length);
return eligiblePowers[idx];
} }
function loadFileFromServer(filePath: string): Promise<string> { function loadFileFromServer(filePath: string): Promise<string> {
@ -34,23 +57,21 @@ function loadFileFromServer(filePath: string): Promise<string> {
fetch(filePath) fetch(filePath)
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
alert(`Couldn't load file, received reponse code ${response.status}`) reject(`Failed to load file: ${response.status}`);
throw new Error(`Failed to load 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. // 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 // Check content type to avoid HTML errors
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) { if (contentType && contentType.includes('text/html')) {
alert(`Unable to load file ${filePath}, was presented HTML, contentType ${contentType}`) reject('Received HTML instead of JSON. Check the file path.');
throw new Error('Received HTML instead of JSON. Check the file path.');
} }
return response.text(); return response.text();
}) })
.then(data => { .then(data => {
// 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')) {
throw new Error('Received HTML instead of JSON. Check the file path.'); reject('Received HTML instead of JSON. Check the file path.');
} }
resolve(data) resolve(data)
}) })
@ -157,8 +178,8 @@ class GameState {
playBtn.disabled = false; playBtn.disabled = false;
speedSelector.disabled = false; speedSelector.disabled = false;
// Set the poewr if the game specifies it, else random. // Set the power if the game specifies it, else random.
this.currentPower = this.gameData.power !== undefined ? this.gameData.power : getRandomPower(); this.currentPower = this.gameData.power !== undefined ? this.gameData.power : getRandomPower(this.gameData);
const momentsFilePath = `./games/${this.gameId}/moments.json`; const momentsFilePath = `./games/${this.gameId}/moments.json`;
@ -273,18 +294,31 @@ class GameState {
* Loads the next game in the order, reseting the board and gameState * Loads the next game in the order, reseting the board and gameState
*/ */
loadNextGame = () => { loadNextGame = () => {
// let gameId = this.gameId + 1
let contPlaying = false
if (this.isPlaying) {
contPlaying = true
}
this.loadGameFile(gameId).then(() => {
this.gameId += 1 if (contPlaying) {
togglePlayback(true)
}
}).catch(() => {
console.warn("caught error trying to advance game. Setting gameId to 0 and restarting...")
this.loadGameFile(0)
if (contPlaying) {
togglePlayback(true)
}
})
// Try to load the next game, if it fails, show end screen forever
} }
/* /*
* Given a gameId, load that game's state into the GameState Object * Given a gameId, load that game's state into the GameState Object
*/ */
loadGameFile = (gameId: number) => { loadGameFile = (gameId: number): Promise<void> => {
if (gameId === null || gameId < 0) { if (gameId === null || gameId < 0) {
throw Error(`Attempted to load game with invalid ID ${gameId}`) throw Error(`Attempted to load game with invalid ID ${gameId}`)
@ -292,25 +326,29 @@ class GameState {
// Path to the default game file // Path to the default game file
const gameFilePath = `./games/${gameId}/game.json`; const gameFilePath = `./games/${gameId}/game.json`;
loadFileFromServer(gameFilePath).then((data) => { return new Promise((resolve, reject) => {
this.gameId = gameId loadFileFromServer(gameFilePath).then((data) => {
return this.loadGameData(data); return this.loadGameData(data);
})
.then(() => {
console.log("Default game file loaded and parsed successfully");
// Explicitly hide standings board after loading game
hideStandingsBoard();
// Update rotating display and relationship popup with game data
if (this.gameData) {
updateRotatingDisplay(this.gameData, this.phaseIndex, this.currentPower);
updateGameIdDisplay();
}
}) })
.catch(error => { .then(() => {
// Use console.error instead of logger.log to avoid updating the info panel console.log(`Game file with id ${gameId} loaded and parsed successfully`);
console.error(`Error loading game ${gameFilePath}: ${error.message}`); // Explicitly hide standings board after loading game
}); hideStandingsBoard();
// Update rotating display and relationship popup with game data
if (this.gameData) {
updateRotatingDisplay(this.gameData, this.phaseIndex, this.currentPower);
this.gameId = gameId
updateGameIdDisplay();
resolve()
}
})
.catch(error => {
// Use console.error instead of logger.log to avoid updating the info panel
console.error(`Error loading game ${gameFilePath}: ${error}`);
reject()
});
})
} }
checkPhaseHasMoment = (phaseName: string): Moment | null => { checkPhaseHasMoment = (phaseName: string): Moment | null => {

View file

@ -6,7 +6,7 @@ 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";
import { initStandingsBoard, hideStandingsBoard, showStandingsBoard } from "./domElements/standingsBoard"; import { initStandingsBoard, hideStandingsBoard, showStandingsBoard } from "./domElements/standingsBoard";
import { displayPhaseWithAnimation, advanceToNextPhase, resetToPhase, nextPhase, previousPhase } from "./phase"; import { displayPhaseWithAnimation, advanceToNextPhase, resetToPhase, nextPhase, previousPhase, togglePlayback } from "./phase";
import { config } from "./config"; import { config } from "./config";
import { Tween, Group, Easing } from "@tweenjs/tween.js"; import { Tween, Group, Easing } from "@tweenjs/tween.js";
import { initRotatingDisplay, updateRotatingDisplay } from "./components/rotatingDisplay"; import { initRotatingDisplay, updateRotatingDisplay } from "./components/rotatingDisplay";
@ -141,14 +141,24 @@ function animate() {
// Call update on each active animation // Call update on each active animation
gameState.unitAnimations.forEach((anim) => anim.update()) gameState.unitAnimations.forEach((anim) => anim.update())
// If all animations are complete and we're in playback mode
if (gameState.unitAnimations.length === 0 && gameState.isPlaying && !gameState.messagesPlaying && !gameState.isSpeaking) {
// Schedule next phase after a pause delay
console.log(`Scheduling next phase in ${config.effectivePlaybackSpeed}ms`);
gameState.playbackTimer = setTimeout(() => advanceToNextPhase(), config.effectivePlaybackSpeed);
}
} }
// If all animations are complete and we're in playback mode
if (gameState.unitAnimations.length === 0 && gameState.isPlaying && !gameState.messagesPlaying && !gameState.isSpeaking && !gameState.nextPhaseScheduled) {
// Schedule next phase after a pause delay
console.log(`Scheduling next phase in ${config.effectivePlaybackSpeed}ms`);
gameState.nextPhaseScheduled = true;
gameState.playbackTimer = setTimeout(() => {
try {
advanceToNextPhase()
} catch {
// FIXME: This is a dumb patch for us not being able to find the unit we expect to find.
// We should instead bee figuring out why units aren't where we expect them to be when the engine has said that is a valid move
nextPhase()
gameState.nextPhaseScheduled;
}
}, config.effectivePlaybackSpeed);
}
// Update any pulsing or wave animations on supply centers or units // Update any pulsing or wave animations on supply centers or units
if (gameState.scene.userData.animatedObjects) { if (gameState.scene.userData.animatedObjects) {
gameState.scene.userData.animatedObjects.forEach(obj => { gameState.scene.userData.animatedObjects.forEach(obj => {
@ -186,58 +196,6 @@ function onWindowResize() {
// --- PLAYBACK CONTROLS ---
function togglePlayback() {
// If the game doesn't have any data, or there are no phases, return;
if (!gameState.gameData || gameState.gameData.phases.length <= 0) {
alert("This game file appears to be broken. Please reload the page and load a different game.")
throw Error("Bad gameState, exiting.")
};
// TODO: Likely not how we want to handle the speaking section of this.
// Should be able to pause the other elements while we're speaking
if (gameState.isSpeaking) return;
gameState.isPlaying = !gameState.isPlaying;
if (gameState.isPlaying) {
playBtn.textContent = "⏸ Pause";
prevBtn.disabled = true;
nextBtn.disabled = true;
logger.log("Starting playback...");
if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[1].start()
// Hide standings board when playback starts
hideStandingsBoard();
// Update rotating display
if (gameState.gameData) {
updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower);
}
// First, show the messages of the current phase if it's the initial playback
const phase = gameState.gameData.phases[gameState.phaseIndex];
if (phase.messages && phase.messages.length) {
// Show messages with stepwise animation
logger.log(`Playing ${phase.messages.length} messages from phase ${gameState.phaseIndex + 1}/${gameState.gameData.phases.length}`);
updateChatWindows(phase, true);
} else {
// No messages, go straight to unit animations
logger.log("No messages for this phase, proceeding to animations");
displayPhaseWithAnimation();
}
} else {
if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[0].pause();
playBtn.textContent = "▶ Play";
if (gameState.playbackTimer) {
clearTimeout(gameState.playbackTimer);
gameState.playbackTimer = null;
}
gameState.messagesPlaying = false;
prevBtn.disabled = false;
nextBtn.disabled = false;
}
}
@ -252,7 +210,6 @@ fileInput.addEventListener('change', e => {
// Update rotating display and relationship popup with game data // Update rotating display and relationship popup with game data
if (gameState.gameData) { if (gameState.gameData) {
updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower); updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower);
updateRelationshipPopup();
} }
} }
}); });

View file

@ -1,6 +1,6 @@
import { gameState } from "./gameState"; import { gameState } from "./gameState";
import { logger } from "./logger"; import { logger } from "./logger";
import { updatePhaseDisplay } from "./domElements"; import { updatePhaseDisplay, playBtn, prevBtn, nextBtn } from "./domElements";
import { initUnits } from "./units/create"; import { initUnits } from "./units/create";
import { updateSupplyCenterOwnership, updateLeaderboard, updateMapOwnership as _updateMapOwnership, updateMapOwnership } from "./map/state"; import { updateSupplyCenterOwnership, updateLeaderboard, updateMapOwnership as _updateMapOwnership, updateMapOwnership } from "./map/state";
import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows"; import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows";
@ -11,6 +11,7 @@ import { debugMenuInstance } from "./debug/debugMenu";
import { showTwoPowerConversation, closeTwoPowerConversation } from "./components/twoPowerConversation"; import { showTwoPowerConversation, closeTwoPowerConversation } from "./components/twoPowerConversation";
import { closeVictoryModal, showVictoryModal } from "./components/victoryModal"; import { closeVictoryModal, showVictoryModal } from "./components/victoryModal";
import { notifyPhaseChange } from "./webhooks/phaseNotifier"; import { notifyPhaseChange } from "./webhooks/phaseNotifier";
import { updateRotatingDisplay } from "./components/rotatingDisplay";
const MOMENT_THRESHOLD = 8.0 const MOMENT_THRESHOLD = 8.0
// If we're in debug mode or instant mode, show it quick, otherwise show it for 30 seconds // If we're in debug mode or instant mode, show it quick, otherwise show it for 30 seconds
@ -60,6 +61,7 @@ export function _setPhase(phaseIndex: number) {
} else { } else {
displayPhase() displayPhase()
} }
gameState.nextPhaseScheduled = false;
} }
// Finally, update the gameState with the current phaseIndex // Finally, update the gameState with the current phaseIndex
@ -69,6 +71,59 @@ export function _setPhase(phaseIndex: number) {
notifyPhaseChange(oldPhaseIndex, phaseIndex); notifyPhaseChange(oldPhaseIndex, phaseIndex);
} }
// --- PLAYBACK CONTROLS ---
export function togglePlayback(explicitSet: boolean) {
// If the game doesn't have any data, or there are no phases, return;
if (!gameState.gameData || gameState.gameData.phases.length <= 0) {
alert("This game file appears to be broken. Please reload the page and load a different game.")
throw Error("Bad gameState, exiting.")
};
// TODO: Likely not how we want to handle the speaking section of this.
// Should be able to pause the other elements while we're speaking
if (gameState.isSpeaking) return;
gameState.isPlaying = !gameState.isPlaying;
if (typeof explicitSet === "boolean") {
gameState.isPlaying = explicitSet
}
if (gameState.isPlaying) {
playBtn.textContent = "⏸ Pause";
prevBtn.disabled = true;
nextBtn.disabled = true;
logger.log("Starting playback...");
if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[1].start()
// Update rotating display
if (gameState.gameData) {
updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower);
}
// First, show the messages of the current phase if it's the initial playback
const phase = gameState.gameData.phases[gameState.phaseIndex];
if (phase.messages && phase.messages.length) {
// Show messages with stepwise animation
logger.log(`Playing ${phase.messages.length} messages from phase ${gameState.phaseIndex + 1}/${gameState.gameData.phases.length}`);
updateChatWindows(phase, true);
} else {
// No messages, go straight to unit animations
logger.log("No messages for this phase, proceeding to animations");
displayPhaseWithAnimation();
}
} else {
if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[0].pause();
playBtn.textContent = "▶ Play";
if (gameState.playbackTimer) {
clearTimeout(gameState.playbackTimer);
gameState.playbackTimer = null;
}
gameState.messagesPlaying = false;
prevBtn.disabled = false;
nextBtn.disabled = false;
}
}
export function nextPhase() { export function nextPhase() {
@ -146,7 +201,7 @@ export function displayPhase(skipMessages = false) {
_updateMapOwnership(); _updateMapOwnership();
// Add phase info to news banner if not already there // Add phase info to news banner if not already there
const phaseBannerText = `Phase: ${currentPhase.name}`; const phaseBannerText = `Phase: ${currentPhase.name}: ${currentPhase.summary}`;
addToNewsBanner(phaseBannerText); addToNewsBanner(phaseBannerText);
// Log phase details to console only, don't update info panel with this // Log phase details to console only, don't update info panel with this
@ -207,8 +262,6 @@ export function advanceToNextPhase() {
logger.log("Cannot advance phase: invalid game state"); logger.log("Cannot advance phase: invalid game state");
return; return;
} }
// Reset the nextPhaseScheduled flag to allow scheduling the next phase
gameState.nextPhaseScheduled = false;
// Get current phase // Get current phase
const currentPhase = gameState.gameData.phases[gameState.phaseIndex]; const currentPhase = gameState.gameData.phases[gameState.phaseIndex];
@ -224,10 +277,6 @@ export function advanceToNextPhase() {
// First show summary if available // First show summary if available
if (currentPhase.summary && currentPhase.summary.trim() !== '') { if (currentPhase.summary && currentPhase.summary.trim() !== '') {
// Update the news banner with full summary
addToNewsBanner(`(${currentPhase.name}) ${currentPhase.summary}`);
console.log("Added summary to news banner, preparing to call speakSummary");
// Speak the summary and advance after // Speak the summary and advance after
if (!gameState.isSpeaking) { if (!gameState.isSpeaking) {
speakSummary(currentPhase.summary) speakSummary(currentPhase.summary)
@ -242,6 +291,8 @@ export function advanceToNextPhase() {
if (gameState.isPlaying) { if (gameState.isPlaying) {
nextPhase(); nextPhase();
} }
}).finally(() => {
}); });
} else { } else {
console.error("Attempted to start speaking when already speaking...") console.error("Attempted to start speaking when already speaking...")
@ -251,6 +302,9 @@ export function advanceToNextPhase() {
// No summary to speak, advance immediately // No summary to speak, advance immediately
nextPhase(); nextPhase();
} }
// Reset the nextPhaseScheduled flag to allow scheduling the next phase
gameState.nextPhaseScheduled = false;
} }
function displayFinalPhase() { function displayFinalPhase() {

View file

@ -25,7 +25,7 @@ export function getPowerDisplayName(power: PowerENUM | string): string {
// Remove the extra long parts of some of the model names e.g. openroute-meta/llama-4-maverick -> llama-4-maverick // Remove the extra long parts of some of the model names e.g. openroute-meta/llama-4-maverick -> llama-4-maverick
let slashIdx = modelName?.indexOf("/") let slashIdx = modelName?.indexOf("/")
if (slashIdx >= 0) { if (slashIdx >= 0) {
return modelName.slice(0, slashIdx) return modelName.slice(slashIdx + 1, modelName.length)
} }
return modelName; return modelName;
} }

View file

@ -9,7 +9,7 @@
"DOM.Iterable" "DOM.Iterable"
], ],
// Turn the below off later, just for now we're not full TS yet // Turn the below off later, just for now we're not full TS yet
//"noCheck": true, "noCheck": true,
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",

View file

@ -19,10 +19,10 @@ export default defineConfig(({ mode }) => {
}, },
// Server configuration // Server configuration
"preview": { "preview": {
"allowedHosts": ["diplomacy"] "allowedHosts": ["diplomacy", "archlinux"]
}, },
"dev": { "dev": {
"allowedHosts": ["diplomacy"] "allowedHosts": ["diplomacy", "archlinux"]
} }
}; };
}); });

View file

@ -8,12 +8,16 @@ services:
- DISPLAY=:99 - DISPLAY=:99
ports: ports:
- "9222:9222" - "9222:9222"
ipc: host
shm_size: "1gb"
diplomacy: diplomacy:
build: ai_animation build: ai_animation
ports: ports:
- "4173:4173" - "4173:4173"
- "5173:5173" - "5173:5173"
volumes:
- ./ai_animation/public/games/:/app/dist/games
diplomacy-dev: diplomacy-dev:
build: ai_animation build: ai_animation
ports: ports:

View file

@ -20,15 +20,15 @@ sleep 2
mkdir -p /home/chrome mkdir -p /home/chrome
# Launch Chrome in the background, pointing at your site. # Launch Chrome in the background, pointing at your site.
# --app=... to open it as a single-window "app" # --disable-background-timer-throttling & related flags to prevent fps throttling in headless/Xvfb
# --no-sandbox / --disable-gpu often needed in Docker
# --use-fake-device-for-media-stream / etc. if you need to simulate mic/cam
DISPLAY=$DISPLAY google-chrome \ DISPLAY=$DISPLAY google-chrome \
--remote-debugging-port=9222 \ --remote-debugging-port=9222 \
--disable-gpu \ --disable-gpu \
--disable-dev-shm-usage \
--no-first-run \
--disable-infobars \ --disable-infobars \
--no-first-run \
--disable-background-timer-throttling \
--disable-renderer-backgrounding \
--disable-backgrounding-occluded-windows \
--user-data-dir=/home/chrome/chrome-data \ --user-data-dir=/home/chrome/chrome-data \
--window-size=1920,1080 --window-position=0,0 \ --window-size=1920,1080 --window-position=0,0 \
--kiosk \ --kiosk \
@ -37,13 +37,12 @@ DISPLAY=$DISPLAY google-chrome \
sleep 5 # let the page load or animations start sleep 5 # let the page load or animations start
# Start streaming with FFmpeg. # Start streaming with FFmpeg.
# - For video: x11grab from DISPLAY # - For video: x11grab at 30fps
# - For audio: pulse from the "default" device # - For audio: pulse from the default device
# Adjust your bitrate, resolution, frame rate, etc. as desired.
exec ffmpeg -y \ exec ffmpeg -y \
-f x11grab -thread_queue_size 512 -r 30 -s 1920x1080 -i $DISPLAY \ -f x11grab -video_size 1920x1080 -framerate 30 -thread_queue_size 512 -i $DISPLAY \
-f pulse -thread_queue_size 512 -i default \ -f pulse -thread_queue_size 512 -i default \
-c:v libx264 -preset veryfast -b:v 6000k -maxrate 6000k -bufsize 12000k \ -c:v libx264 -preset ultrafast -b:v 6000k -maxrate 6000k -bufsize 12000k \
-pix_fmt yuv420p \ -pix_fmt yuv420p \
-c:a aac -b:a 160k \ -c:a aac -b:a 160k \
-vsync 1 -async 1 \ -vsync 1 -async 1 \