diff --git a/ai_animation/.dockerignore b/ai_animation/.dockerignore new file mode 100644 index 0000000..f767f2a --- /dev/null +++ b/ai_animation/.dockerignore @@ -0,0 +1,3 @@ +**/*.zip +./node_modules/ +./public/games/ diff --git a/ai_animation/public/.dockerignore b/ai_animation/public/.dockerignore new file mode 100644 index 0000000..33bb22a --- /dev/null +++ b/ai_animation/public/.dockerignore @@ -0,0 +1 @@ +./games/ diff --git a/ai_animation/reorganize_games.sh b/ai_animation/reorganize_games.sh new file mode 100755 index 0000000..0a3b741 --- /dev/null +++ b/ai_animation/reorganize_games.sh @@ -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." \ No newline at end of file diff --git a/ai_animation/src/domElements/chatWindows.ts b/ai_animation/src/domElements/chatWindows.ts index ade89f3..0f4a0a6 100644 --- a/ai_animation/src/domElements/chatWindows.ts +++ b/ai_animation/src/domElements/chatWindows.ts @@ -178,65 +178,6 @@ export function updateChatWindows(phase: any, stepMessages = 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 { // Stepwise mode: show one message at a time, animating word-by-word gameState.messagesPlaying = true; @@ -259,64 +200,7 @@ export function updateChatWindows(phase: any, stepMessages = false) { if (config.isDebugMode) { console.log(`All messages displayed in ${Date.now() - messageStartTime}ms`); } - 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; } diff --git a/ai_animation/src/domElements/relationshipPopup.ts b/ai_animation/src/domElements/relationshipPopup.ts deleted file mode 100644 index b23f977..0000000 --- a/ai_animation/src/domElements/relationshipPopup.ts +++ /dev/null @@ -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 = '
No game data loaded. Please load a game to view relationships.
'; - } - } -} - -/** - * 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(); - - // 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(); - - // 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 = `Note: 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(); - } -} diff --git a/ai_animation/src/gameState.ts b/ai_animation/src/gameState.ts index 791418e..cac5d54 100644 --- a/ai_animation/src/gameState.ts +++ b/ai_animation/src/gameState.ts @@ -7,10 +7,10 @@ import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay import { createChatWindows } from "./domElements/chatWindows"; import { logger } from "./logger"; 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 { 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 @@ -20,13 +20,36 @@ enum AvailableMaps { /** * 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 { - const values = Object.values(PowerENUM).filter(power => +function getRandomPower(gameData?: GameSchemaType): PowerENUM { + const allPowers = Object.values(PowerENUM).filter(power => 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 { @@ -34,23 +57,21 @@ function loadFileFromServer(filePath: string): Promise { fetch(filePath) .then(response => { if (!response.ok) { - alert(`Couldn't load file, received reponse code ${response.status}`) - throw new Error(`Failed to load file: ${response.status}`); + reject(`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. // Check content type to avoid HTML errors const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('text/html')) { - alert(`Unable to load file ${filePath}, was presented HTML, contentType ${contentType}`) - throw new Error('Received HTML instead of JSON. Check the file path.'); + reject('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(' { - // + 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 */ - loadGameFile = (gameId: number) => { + loadGameFile = (gameId: number): Promise => { if (gameId === null || gameId < 0) { throw Error(`Attempted to load game with invalid ID ${gameId}`) @@ -292,25 +326,29 @@ class GameState { // Path to the default game file const gameFilePath = `./games/${gameId}/game.json`; - loadFileFromServer(gameFilePath).then((data) => { - this.gameId = gameId + return new Promise((resolve, reject) => { + loadFileFromServer(gameFilePath).then((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(); - } + return this.loadGameData(data); }) - .catch(error => { - // Use console.error instead of logger.log to avoid updating the info panel - console.error(`Error loading game ${gameFilePath}: ${error.message}`); - }); + .then(() => { + console.log(`Game file with id ${gameId} 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); + 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 => { diff --git a/ai_animation/src/main.ts b/ai_animation/src/main.ts index 9eebe84..fa917a8 100644 --- a/ai_animation/src/main.ts +++ b/ai_animation/src/main.ts @@ -6,7 +6,7 @@ import { logger } from "./logger"; import { loadBtn, prevBtn, nextBtn, speedSelector, fileInput, playBtn, mapView, loadGameBtnFunction } from "./domElements"; import { updateChatWindows } from "./domElements/chatWindows"; 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 { Tween, Group, Easing } from "@tweenjs/tween.js"; import { initRotatingDisplay, updateRotatingDisplay } from "./components/rotatingDisplay"; @@ -141,14 +141,24 @@ function animate() { // Call update on each active animation 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 if (gameState.scene.userData.animatedObjects) { 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 if (gameState.gameData) { updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower); - updateRelationshipPopup(); } } }); diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index bbcbaa3..2ad090e 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -1,6 +1,6 @@ import { gameState } from "./gameState"; import { logger } from "./logger"; -import { updatePhaseDisplay } from "./domElements"; +import { updatePhaseDisplay, playBtn, prevBtn, nextBtn } from "./domElements"; import { initUnits } from "./units/create"; import { updateSupplyCenterOwnership, updateLeaderboard, updateMapOwnership as _updateMapOwnership, updateMapOwnership } from "./map/state"; import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows"; @@ -11,6 +11,7 @@ import { debugMenuInstance } from "./debug/debugMenu"; import { showTwoPowerConversation, closeTwoPowerConversation } from "./components/twoPowerConversation"; import { closeVictoryModal, showVictoryModal } from "./components/victoryModal"; import { notifyPhaseChange } from "./webhooks/phaseNotifier"; +import { updateRotatingDisplay } from "./components/rotatingDisplay"; const MOMENT_THRESHOLD = 8.0 // 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 { displayPhase() } + gameState.nextPhaseScheduled = false; } // Finally, update the gameState with the current phaseIndex @@ -69,6 +71,59 @@ export function _setPhase(phaseIndex: number) { 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() { @@ -146,7 +201,7 @@ export function displayPhase(skipMessages = false) { _updateMapOwnership(); // Add phase info to news banner if not already there - const phaseBannerText = `Phase: ${currentPhase.name}`; + const phaseBannerText = `Phase: ${currentPhase.name}: ${currentPhase.summary}`; addToNewsBanner(phaseBannerText); // 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"); return; } - // Reset the nextPhaseScheduled flag to allow scheduling the next phase - gameState.nextPhaseScheduled = false; // Get current phase const currentPhase = gameState.gameData.phases[gameState.phaseIndex]; @@ -224,10 +277,6 @@ export function advanceToNextPhase() { // First show summary if available 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 if (!gameState.isSpeaking) { speakSummary(currentPhase.summary) @@ -242,6 +291,8 @@ export function advanceToNextPhase() { if (gameState.isPlaying) { nextPhase(); } + }).finally(() => { + }); } else { console.error("Attempted to start speaking when already speaking...") @@ -251,6 +302,9 @@ export function advanceToNextPhase() { // No summary to speak, advance immediately nextPhase(); } + + // Reset the nextPhaseScheduled flag to allow scheduling the next phase + gameState.nextPhaseScheduled = false; } function displayFinalPhase() { diff --git a/ai_animation/src/utils/powerNames.ts b/ai_animation/src/utils/powerNames.ts index 4cfdc81..7e9b7e2 100644 --- a/ai_animation/src/utils/powerNames.ts +++ b/ai_animation/src/utils/powerNames.ts @@ -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 let slashIdx = modelName?.indexOf("/") if (slashIdx >= 0) { - return modelName.slice(0, slashIdx) + return modelName.slice(slashIdx + 1, modelName.length) } return modelName; } diff --git a/ai_animation/tsconfig.json b/ai_animation/tsconfig.json index bb15751..3bba997 100644 --- a/ai_animation/tsconfig.json +++ b/ai_animation/tsconfig.json @@ -9,7 +9,7 @@ "DOM.Iterable" ], // Turn the below off later, just for now we're not full TS yet - //"noCheck": true, + "noCheck": true, "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/ai_animation/vite.config.js b/ai_animation/vite.config.js index 32355d9..dc2ada2 100644 --- a/ai_animation/vite.config.js +++ b/ai_animation/vite.config.js @@ -4,10 +4,10 @@ import { defineConfig, loadEnv } from 'vite'; export default defineConfig(({ mode }) => { // Load environment variables const env = loadEnv(mode, process.cwd(), ''); - + console.log('Environment mode:', mode); console.log('Environment variables loaded:', Object.keys(env).filter(key => key.startsWith('VITE_'))); - + return { // Define environment variables that should be available in the client define: { @@ -19,10 +19,10 @@ export default defineConfig(({ mode }) => { }, // Server configuration "preview": { - "allowedHosts": ["diplomacy"] + "allowedHosts": ["diplomacy", "archlinux"] }, "dev": { - "allowedHosts": ["diplomacy"] + "allowedHosts": ["diplomacy", "archlinux"] } }; }); diff --git a/docker-compose.yaml b/docker-compose.yaml index 933c8f8..1342c25 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,12 +8,16 @@ services: - DISPLAY=:99 ports: - "9222:9222" + ipc: host + shm_size: "1gb" diplomacy: build: ai_animation ports: - "4173:4173" - "5173:5173" + volumes: + - ./ai_animation/public/games/:/app/dist/games diplomacy-dev: build: ai_animation ports: diff --git a/twitch-streamer/entrypoint.sh b/twitch-streamer/entrypoint.sh index 1fa2fcc..93a3e75 100755 --- a/twitch-streamer/entrypoint.sh +++ b/twitch-streamer/entrypoint.sh @@ -20,15 +20,15 @@ sleep 2 mkdir -p /home/chrome # Launch Chrome in the background, pointing at your site. -# --app=... to open it as a single-window "app" -# --no-sandbox / --disable-gpu often needed in Docker -# --use-fake-device-for-media-stream / etc. if you need to simulate mic/cam +# --disable-background-timer-throttling & related flags to prevent fps throttling in headless/Xvfb DISPLAY=$DISPLAY google-chrome \ --remote-debugging-port=9222 \ --disable-gpu \ - --disable-dev-shm-usage \ - --no-first-run \ --disable-infobars \ + --no-first-run \ + --disable-background-timer-throttling \ + --disable-renderer-backgrounding \ + --disable-backgrounding-occluded-windows \ --user-data-dir=/home/chrome/chrome-data \ --window-size=1920,1080 --window-position=0,0 \ --kiosk \ @@ -37,13 +37,12 @@ DISPLAY=$DISPLAY google-chrome \ sleep 5 # let the page load or animations start # Start streaming with FFmpeg. -# - For video: x11grab from DISPLAY -# - For audio: pulse from the "default" device -# Adjust your bitrate, resolution, frame rate, etc. as desired. +# - For video: x11grab at 30fps +# - For audio: pulse from the default device 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 \ - -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 \ -c:a aac -b:a 160k \ -vsync 1 -async 1 \