diff --git a/.vscode/launch.json b/.vscode/launch.json index ae81577..680ed90 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,13 +2,13 @@ "version": "0.2.0", "configurations": [ { - "type": "firefox", + "type": "pwa-chrome", "request": "launch", - "name": "Firefox Debug 9223", - "url": "http://localhost:5173", + "name": "Firefox Debug 5179", + "url": "http://localhost:5179", "webRoot": "${workspaceFolder}/ai_animation/", "sourceMapPathOverrides": { - "http://localhost:5173/*": "${webRoot}/*" + "http://localhost:5179/*": "${webRoot}/*" }, "runtimeArgs": [ "--remote-debugging-port=9223" diff --git a/ai_animation/src/components/twoPowerConversation.ts b/ai_animation/src/components/twoPowerConversation.ts index 05c92ef..9962f1c 100644 --- a/ai_animation/src/components/twoPowerConversation.ts +++ b/ai_animation/src/components/twoPowerConversation.ts @@ -3,21 +3,14 @@ import { config } from '../config'; import { getPowerDisplayName } from '../utils/powerNames'; import { PowerENUM } from '../types/map'; import { Moment } from '../types/moments'; - -interface ConversationMessage { - sender: string; - recipient: string; - message: string; - time_sent?: string; - [key: string]: any; -} +import { Message } from '../types/gameState'; interface TwoPowerDialogueOptions { - power1: string; - power2: string; - messages?: ConversationMessage[]; + moment: Moment; + power1?: PowerENUM; + power2?: PowerENUM; + messages?: Message[]; title?: string; - moment?: Moment; onClose?: () => void; } @@ -28,44 +21,39 @@ let dialogueOverlay: HTMLElement | null = null; * @param options Configuration for the dialogue display */ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void { - const { power1, power2, messages, title, moment, onClose } = options; + const { moment, power1, power2, 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) { + if (moment.raw_messages.length === 0) { throw new Error( `High-interest moment detected between ${power1} and ${power2} but no messages found. ` + `This indicates a data quality issue - moments should only be created when there are actual conversations to display.` ); } - // Mark as displaying moment immediately - gameState.isDisplayingMoment = true; - - // Schedule the conversation to be shown through event queue - gameState.eventQueue.scheduleDelay(0, () => { - showConversationModalSequence(power1, power2, title, moment, conversationMessages, onClose); - }, `show-conversation-${Date.now()}`); + showConversationModalSequence(title, moment, onClose); } /** * Shows the conversation modal and sequences all messages through the event queue */ function showConversationModalSequence( - power1: string, - power2: string, - title: string | undefined, - moment: Moment | undefined, - conversationMessages: ConversationMessage[], - onClose?: () => void + title: string | undefined, + moment: Moment, + onClose?: () => void, + power1?: string, + power2?: string, ): void { // Create overlay dialogueOverlay = createDialogueOverlay(); + if (!power1 || !power2) { + power1 = moment.powers_involved[0] + power2 = moment.powers_involved[1] + } + // Create dialogue container const dialogueContainer = createDialogueContainer(power1, power2, title, moment); @@ -82,14 +70,14 @@ function showConversationModalSequence( dialogueOverlay.appendChild(dialogueContainer); document.body.appendChild(dialogueOverlay); - // Set up event listeners + // Set up event listeners for close functionality setupEventListeners(onClose); - // Trigger fade in - gameState.eventQueue.scheduleDelay(10, () => dialogueOverlay!.style.opacity = '1', `fade-in-overlay-${Date.now()}`); + dialogueOverlay!.style.opacity = '1' // Schedule messages to be displayed sequentially through event queue - scheduleMessageSequence(conversationArea, conversationMessages, power1, power2); + console.log(`Starting two-power conversation with ${moment.raw_messages.length} messages`); + scheduleMessageSequence(conversationArea, moment.raw_messages, power1, power2, onClose); } /** @@ -97,49 +85,55 @@ function showConversationModalSequence( */ function scheduleMessageSequence( container: HTMLElement, - messages: ConversationMessage[], + messages: Message[], power1: string, - power2: string + power2: string, + callbackOnClose?: () => void ): void { - let currentDelay = config.conversationModalDelay; // Start after modal is fully visible - - // Calculate timing from config - const messageDisplayTime = config.conversationMessageDisplay; - const messageAnimationTime = config.conversationMessageAnimation; - - messages.forEach((message, index) => { - // Schedule each message display - gameState.eventQueue.scheduleDelay(currentDelay, () => { - displaySingleMessage(container, message, power1, power2); - }, `display-message-${index}-${Date.now()}`); - - // Increment delay for next message - currentDelay += messageDisplayTime + messageAnimationTime; - }); - - // Schedule conversation close after all messages are shown - const totalConversationTime = currentDelay + config.conversationFinalDelay; // Extra delay before closing - gameState.eventQueue.scheduleDelay(totalConversationTime, () => { - closeTwoPowerConversation(); - - // After closing conversation, advance to next phase if playing - if (gameState.isPlaying) { - // Import _setPhase dynamically to avoid circular dependency - import('../phase').then(({ _setPhase }) => { - _setPhase(gameState.phaseIndex + 1); - }); + let messageIndex = 0; + + // Function to show the next message + const showNext = () => { + // All messages have been displayed + if (messageIndex >= messages.length) { + console.log(`All ${messages.length} conversation messages displayed, scheduling close in ${config.conversationFinalDelay}ms`); + // Schedule conversation close after all messages are shown + gameState.eventQueue.scheduleDelay(config.conversationFinalDelay, () => { + console.log('Closing two-power conversation and calling onClose callback'); + closeTwoPowerConversation(); + if (callbackOnClose) callbackOnClose(); + }, `close-conversation-after-messages-${Date.now()}`); + return; } - }, `close-conversation-after-messages-${Date.now()}`); + + // Get the next message + const message = messages[messageIndex]; + + // Function to call after message animation completes + const onMessageComplete = () => { + messageIndex++; + console.log(`Conversation message ${messageIndex} of ${messages.length} completed`); + // Schedule next message with proper delay + gameState.eventQueue.scheduleDelay(config.messageBetweenDelay, showNext, `show-next-conversation-message-${messageIndex}-${Date.now()}`); + }; + + // Display the message with word-by-word animation + displaySingleMessage(container, message, power1, power2, onMessageComplete); + }; + + // Start the message sequence with initial delay + gameState.eventQueue.scheduleDelay(config.conversationModalDelay, showNext, `start-conversation-sequence-${Date.now()}`); } /** - * Displays a single message with animation + * Displays a single message with word-by-word animation */ function displaySingleMessage( container: HTMLElement, - message: ConversationMessage, + message: Message, power1: string, - power2: string + power2: string, + callbackFn?: () => void ): void { const messageElement = createMessageElement(message, power1, power2); container.appendChild(messageElement); @@ -147,16 +141,21 @@ function displaySingleMessage( // Animate message appearance messageElement.style.opacity = '0'; messageElement.style.transform = 'translateY(20px)'; + messageElement.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + messageElement.style.opacity = '1'; + messageElement.style.transform = 'translateY(0)'; - // Use event queue for smooth animation - gameState.eventQueue.scheduleDelay(50, () => { - 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; - // Scroll to bottom - container.scrollTop = container.scrollHeight; - }, `animate-message-${Date.now()}`); + // Start word-by-word animation for the message content + const messageBubble = messageElement.querySelector('.message-bubble'); + if (messageBubble) { + animateMessageWords(message.message, messageBubble as HTMLElement, container, callbackFn); + } else { + // Fallback if message bubble not found + if (callbackFn) callbackFn(); + } } /** @@ -164,6 +163,7 @@ function displaySingleMessage( * @param immediate If true, removes overlay immediately without animation */ export function closeTwoPowerConversation(immediate: boolean = false): void { + dialogueOverlay = document.getElementById("dialogue-overlay") if (dialogueOverlay) { if (immediate) { // Immediate cleanup for phase transitions @@ -171,51 +171,21 @@ export function closeTwoPowerConversation(immediate: boolean = false): void { dialogueOverlay.parentNode.removeChild(dialogueOverlay); } dialogueOverlay = null; - gameState.isDisplayingMoment = false; } else { - // Normal fade-out animation - dialogueOverlay.classList.add('fade-out'); - gameState.eventQueue.scheduleDelay(300, () => { - if (dialogueOverlay?.parentNode) { - dialogueOverlay.parentNode.removeChild(dialogueOverlay); - } - dialogueOverlay = null; - gameState.isDisplayingMoment = false; - }, `close-conversation-${Date.now()}`); + if (dialogueOverlay?.parentNode) { + dialogueOverlay.parentNode.removeChild(dialogueOverlay); + } + dialogueOverlay = null; } } } -/** - * 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) => { - 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, b) => { - // Sort by time_sent if available, otherwise maintain original order - if (a.time_sent && b.time_sent) { - return a.time_sent > b.time_sent; - } - return 0; - }); -} - /** * Creates the main overlay element */ function createDialogueOverlay(): HTMLElement { const overlay = document.createElement('div'); - overlay.className = 'dialogue-overlay'; + overlay.id = 'dialogue-overlay'; overlay.style.cssText = ` position: fixed; top: 0; @@ -498,7 +468,7 @@ function setupEventListeners(onClose?: () => void): void { const handleClose = () => { closeTwoPowerConversation(true); // immediate close for manual actions onClose?.(); - + // When manually closed, still advance phase if playing if (gameState.isPlaying) { import('../phase').then(({ _setPhase }) => { @@ -531,7 +501,7 @@ function setupEventListeners(onClose?: () => void): void { /** * Creates a message element for display */ -function createMessageElement(message: ConversationMessage, power1: string, power2: string): HTMLElement { +function createMessageElement(message: Message, power1: string, power2: string): HTMLElement { const messageDiv = document.createElement('div'); const isFromPower1 = message.sender.toUpperCase() === power1.toUpperCase(); @@ -554,9 +524,9 @@ function createMessageElement(message: ConversationMessage, power1: string, powe color: #4f3b16; `; - // Message bubble + // Message bubble (initially empty for word-by-word animation) const messageBubble = document.createElement('div'); - messageBubble.textContent = message.message; + messageBubble.className = 'message-bubble'; messageBubble.style.cssText = ` background: ${isFromPower1 ? '#e6f3ff' : '#fff3e6'}; border: 2px solid ${isFromPower1 ? '#4a90e2' : '#e67e22'}; @@ -574,3 +544,56 @@ function createMessageElement(message: ConversationMessage, power1: string, powe return messageDiv; } + +/** + * Animates message text one word at a time (adapted from chatWindows.ts) + */ +function animateMessageWords( + message: string, + contentElement: HTMLElement, + container: HTMLElement, + onComplete: (() => void) | null +): void { + const words = message.split(/\s+/); + + // Clear any existing content + contentElement.textContent = ''; + let wordIndex = 0; + + // Function to add the next word + const addNextWord = () => { + if (wordIndex >= words.length) { + // All words added - message is complete + console.log(`Finished animating conversation message with ${words.length} words`); + + // Add a slight delay after the last word for readability + gameState.eventQueue.scheduleDelay(config.messageCompletionDelay, () => { + if (onComplete) { + onComplete(); // Call the completion callback + } + }, `conversation-message-complete-${Date.now()}`); + + return; + } + + // Add space if not the first word + if (wordIndex > 0) { + contentElement.textContent += ' '; + } + + // Add the next word + contentElement.textContent += words[wordIndex]; + wordIndex++; + + // Calculate delay based on word length and playback speed + const wordLength = words[wordIndex - 1].length; + const delay = Math.max(config.messageWordDelay, Math.min(200, config.messageWordDelay * (wordLength / 4))); + gameState.eventQueue.scheduleDelay(delay, addNextWord, `add-conversation-word-${wordIndex}-${Date.now()}`); + + // Scroll to ensure newest content is visible + container.scrollTop = container.scrollHeight; + }; + + // Start animation + addNextWord(); +} diff --git a/ai_animation/src/config.ts b/ai_animation/src/config.ts index d8d0090..9894446 100644 --- a/ai_animation/src/config.ts +++ b/ai_animation/src/config.ts @@ -81,7 +81,7 @@ export const config = { }, get messageBetweenDelay(): number { - return this.isInstantMode ? 1 : this.effectivePlaybackSpeed; // Delay between messages + return this.isInstantMode ? 0.001 : this.effectivePlaybackSpeed; // Delay between messages }, get messageCompletionDelay(): number { diff --git a/ai_animation/src/domElements/chatWindows.ts b/ai_animation/src/domElements/chatWindows.ts index a2be0c2..838db3f 100644 --- a/ai_animation/src/domElements/chatWindows.ts +++ b/ai_animation/src/domElements/chatWindows.ts @@ -1,10 +1,12 @@ import * as THREE from "three"; import { gameState } from "../gameState"; import { config } from "../config"; -import { advanceToNextPhase } from "../phase"; +import { advanceToNextPhase, scheduleNextPhase } from "../phase"; import { GamePhase, Message } from "../types/gameState"; import { getPowerDisplayName, getAllPowerDisplayNames } from '../utils/powerNames'; import { PowerENUM } from '../types/map'; +import { createAnimationsForNextPhase } from "../units/animate"; +import { speakSummary } from "../speech"; //TODO: Sometimes the LLMs use lists, and they don't work in the chats. The just appear as bullets within a single line. @@ -178,7 +180,6 @@ function playChatMessage(messageIndex) { * @param stepMessages Whether to animate messages one-by-word (true) or show all at once (false) */ export function updateChatWindows(stepMessages = false) { - gameState.messagesPlaying = true // Exit early if no messages if (!gameState.currentPhase.messages || !gameState.currentPhase.messages.length) { console.log("No messages to display for this phase"); @@ -213,10 +214,8 @@ export function updateChatWindows(stepMessages = false) { animateHeadNod(msg, (messageCounter % config.soundEffectFrequency === 0)); } }); - gameState.messagesPlaying = false; } else { // Stepwise mode: show one message at a time, animating word-by-word - gameState.messagesPlaying = true; let index = 0; // Store the start time for debugging @@ -227,7 +226,6 @@ export function updateChatWindows(stepMessages = false) { // If we're not playing or user has manually advanced, stop message animation if (!gameState.isPlaying && !config.isDebugMode) { console.log("Playback stopped, halting message animations"); - gameState.messagesPlaying = false; return; } @@ -236,21 +234,8 @@ export function updateChatWindows(stepMessages = false) { if (config.isDebugMode) { console.log(`All messages displayed in ${Date.now() - messageStartTime}ms`); } - gameState.messagesPlaying = false; - - // Trigger unit animations now that messages are done - // This imports a circular dependency, so we use a dynamic import - import('../units/animate').then(({ createAnimationsForNextPhase }) => { - const phaseIndex = gameState.phaseIndex; - const isFirstPhase = phaseIndex === 0; - const previousPhase = !isFirstPhase && phaseIndex > 0 ? gameState.gameData.phases[phaseIndex - 1] : null; - - if (!isFirstPhase && previousPhase) { - console.log("Messages complete, starting unit animations"); - createAnimationsForNextPhase(); - } - }); - + console.log("Messages complete, triggering next phase"); + scheduleNextPhase(); return; } diff --git a/ai_animation/src/gameState.ts b/ai_animation/src/gameState.ts index 54bf96d..603e524 100644 --- a/ai_animation/src/gameState.ts +++ b/ai_animation/src/gameState.ts @@ -4,7 +4,7 @@ import type { GameSchemaType, Message } from "./types/gameState"; import { debugMenuInstance } from "./debug/debugMenu.ts" import { config } from "./config.ts" import { GameSchema, type MessageSchema } from "./types/gameState"; -import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay } from "./domElements"; +import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay, updatePhaseDisplay } from "./domElements"; import { createChatWindows } from "./domElements/chatWindows"; import { logger } from "./logger"; import { OrbitControls } from "three/examples/jsm/Addons.js"; @@ -87,18 +87,11 @@ class GameState { gameId: number gameData!: GameSchemaType momentsData!: NormalizedMomentsData - phaseIndex: number + _phaseIndex: number boardName: string currentPower!: PowerENUM - - // state locks - messagesPlaying: boolean - phaseChatMessages: Message[] isPlaying: boolean - isSpeaking: boolean - isAnimating: boolean - isDisplayingMoment: boolean // Used when we're displaying a moment, should pause all other items - nextPhaseScheduled: boolean // Flag to prevent multiple phase transitions being scheduled + //Scene for three.js scene: THREE.Scene @@ -124,18 +117,10 @@ class GameState { eventQueue: EventQueue constructor(boardName: AvailableMaps) { - this.phaseIndex = 0 + this._phaseIndex = 0 this.boardName = boardName - this.gameId = 16 - this.phaseChatMessages = [] - - // State locks - this.isSpeaking = false + this.gameId = 0 this.isPlaying = false - this.isAnimating = false - this.isDisplayingMoment = false - this.messagesPlaying = false - this.nextPhaseScheduled = false this.scene = new THREE.Scene() this.unitMeshes = [] @@ -145,6 +130,13 @@ class GameState { this.eventQueue = new EventQueue() this.loadBoardState() } + set phaseIndex(val: number) { + this._phaseIndex = val + updatePhaseDisplay() + } + get phaseIndex() { + return this._phaseIndex + } /** * Load game data from a JSON string and initialize the game state @@ -196,6 +188,8 @@ class GameState { .then((data) => { const parsedData = JSON.parse(data); + // FIXME: Why do we have two different moments data types?!? There should only be a single one. + // // Check if this is the comprehensive format and normalize it if ('analysis_results' in parsedData && parsedData.analysis_results) { // Transform comprehensive format to animation format @@ -378,7 +372,7 @@ class GameState { checkPhaseHasMoment = (phaseName: string): Moment | null => { let momentMatch = this.momentsData.moments.filter((moment) => { - return moment.phase === phaseName + return moment.phase === phaseName && moment.raw_messages.length > 0 }) // If there is more than one moment per turn, only return the largest one. diff --git a/ai_animation/src/main.ts b/ai_animation/src/main.ts index 2c0733b..21ed3c8 100644 --- a/ai_animation/src/main.ts +++ b/ai_animation/src/main.ts @@ -9,7 +9,7 @@ import { initRotatingDisplay, } from "./components/rotatingDisplay"; import { debugMenuInstance } from "./debug/debugMenu"; import { initializeBackgroundAudio, startBackgroundAudio } from "./backgroundAudio"; import { updateLeaderboard } from "./components/leaderboard"; -import { _setPhase, advanceToNextPhase, displayInitialPhase, nextPhase, previousPhase } from "./phase"; +import { _setPhase, nextPhase, previousPhase } from "./phase"; import { togglePlayback } from "./phase"; //TODO: Create a function that finds a suitable unit location within a given polygon, for placing units better @@ -48,7 +48,6 @@ function initScene() { if (phaseStartIdx !== undefined) { gameState.eventQueue.start(); gameState.eventQueue.scheduleDelay(500, () => { - // FIXME: Race condition waiting to happen. I'm delaying this call as I'm too tired to do this properly right now. _setPhase(phaseStartIdx) }, `phase-start-delay-${Date.now()}`) } else if (!isStreamingMode && !config.isTestingMode) { @@ -121,57 +120,14 @@ function createCameraPan(): Group { return new Group(moveToStartSweepAnim, cameraSweepOperation); } -// --- ANIMATION LOOP --- -/* - * Main animation loop that runs continuously - * Handles camera movement, animations, and game state transitions - */ -function animate() { - requestAnimationFrame(animate); - - // Update event queue - gameState.eventQueue.update(); - - if (gameState.isPlaying) { - // Update the camera angle - // FIXME: This has to call the update functino twice inorder to avoid a bug in Tween.js, see here https://github.com/tweenjs/tween.js/issues/677 - gameState.cameraPanAnim.update(); - gameState.cameraPanAnim.update(); - - // If all animations are complete - if (gameState.unitAnimations.length === 0 && !gameState.messagesPlaying && !gameState.isSpeaking && !gameState.nextPhaseScheduled) { - // Schedule next phase after a pause delay using event queue - console.log(`Scheduling next phase in ${config.effectivePlaybackSpeed}ms`); - gameState.nextPhaseScheduled = true; - gameState.eventQueue.scheduleDelay(config.effectivePlaybackSpeed, () => { - 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() - } - }, `next-phase-${Date.now()}`); - } - } else { - // Manual camera controls when not in playback mode - gameState.camControls.update(); - } +function updateAnimations() { // Check if all animations are complete if (gameState.unitAnimations.length > 0) { // Filter out completed animations - const previousCount = gameState.unitAnimations.length; gameState.unitAnimations = gameState.unitAnimations.filter(anim => anim.isPlaying()); - - // Log when animations complete - if (previousCount > 0 && gameState.unitAnimations.length === 0) { - console.log("All unit animations have completed"); - } - // Call update on each active animation gameState.unitAnimations.forEach((anim) => anim.update()) - } // Update any pulsing or wave animations on supply centers or units @@ -194,10 +150,30 @@ function animate() { } }); } +} +// --- ANIMATION LOOP --- +/* + * Main animation loop that runs continuously + * Handles camera movement, animations, and game state transitions + */ +function animate() { + requestAnimationFrame(animate); + // All things that aren't ThreeJS items happen in the eventQueue. The queue if filled with the first phase before the animate is kicked off, then all subsequent events are updated when other events finish. F + // For instance, when the messages finish playing, they should kick off the check to see if we should advance turns. + gameState.eventQueue.update(); + + if (gameState.isPlaying) { + // Update the camera angle + // FIXME: This has to call the update functino twice inorder to avoid a bug in Tween.js, see here https://github.com/tweenjs/tween.js/issues/677 + gameState.cameraPanAnim.update(); + gameState.cameraPanAnim.update(); + + } + + updateAnimations() gameState.camControls.update(); gameState.renderer.render(gameState.scene, gameState.camera); - } @@ -231,7 +207,7 @@ nextBtn.addEventListener('click', () => { nextPhase() }); -playBtn.addEventListener('click', () => { +playBtn.addEventListener('click', () => { // Ensure background audio is ready when user manually clicks play startBackgroundAudio(); togglePlayback(); diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index 0152ce7..0aeea08 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -49,26 +49,15 @@ export function _setPhase(phaseIndex: number) { // Clear any existing animations to prevent overlap // (playbackTimer is replaced by event queue system) - // Ensure any open two-power conversations are closed immediately before resetting event queue - if (gameState.isDisplayingMoment) { - closeTwoPowerConversation(true); // immediate = true - } // Reset event queue for new phase with cleanup callback gameState.eventQueue.reset(() => { - // Ensure proper state cleanup when events are canceled - gameState.messagesPlaying = false; - gameState.isAnimating = false; }); - + if (gameState.isPlaying) { gameState.eventQueue.start(); } - // Reset animation state (redundant but kept for clarity) - gameState.isAnimating = false; - gameState.messagesPlaying = false; - // Advance the phase index gameState.phaseIndex++; if (config.isDebugMode && gameState.gameData) { @@ -126,7 +115,6 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { if (gameState.currentPhase.messages && gameState.currentPhase.messages.length) { // Show messages with stepwise animation logger.log(`Playing ${gameState.currentPhase.messages.length} messages from phase ${gameState.phaseIndex + 1}/${gameState.gameData.phases.length}`); - gameState.nextPhaseScheduled = true displayPhase() } else { // No messages, go straight to unit animations @@ -136,15 +124,13 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[0].pause(); playBtn.textContent = "▶ Play"; // (playbackTimer is replaced by event queue system) - + // Stop background audio when pausing stopBackgroundAudio(); - + // Ensure any open two-power conversations are closed when pausing - if (gameState.isDisplayingMoment) { - closeTwoPowerConversation(true); // immediate = true - } - + closeTwoPowerConversation(true); // immediate = true + // Stop and reset event queue when pausing with cleanup gameState.eventQueue.stop(); gameState.eventQueue.reset(() => { @@ -152,7 +138,7 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { gameState.messagesPlaying = false; gameState.isAnimating = false; }); - + gameState.messagesPlaying = false; prevBtn.disabled = false; nextBtn.disabled = false; @@ -160,32 +146,42 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) { } +export function scheduleNextPhase() { + gameState.eventQueue.scheduleDelay(0, nextPhase) +} + +export function scheduleSummarySpeech() { + // Delay speech in streaming mode + gameState.eventQueue.scheduleDelay(config.speechDelay, () => { + // Speak the summary and advance after + speakSummary() + }, `speech-delay-${Date.now()}`); +} + +/** Handels all the end-of-phase items before calling _setPhase(). + * + */ export function nextPhase() { - if (!gameState.isDisplayingMoment && gameState.gameData && gameState.momentsData) { - let moment = gameState.checkPhaseHasMoment(gameState.gameData.phases[gameState.phaseIndex].name) - if (moment !== null && moment.interest_score >= MOMENT_THRESHOLD && moment.powers_involved.length >= 2) { - moment.hasBeenDisplayed = true + let moment = gameState.checkPhaseHasMoment(gameState.gameData.phases[gameState.phaseIndex].name) + if (moment !== null && moment.interest_score >= MOMENT_THRESHOLD && moment.powers_involved.length >= 2) { - const power1 = moment.powers_involved[0]; - const power2 = moment.powers_involved[1]; + const power1 = moment.powers_involved[0]; + const power2 = moment.powers_involved[1]; - showTwoPowerConversation({ - power1: power1, - power2: power2, - moment: moment - }) - if (gameState.isPlaying) { - // The conversation will close itself and then advance the phase - // We don't need to schedule a timeout here anymore since the conversation manages its own timing - console.log("Two-power conversation will handle its own timing and phase advancement"); - } else { - _setPhase(gameState.phaseIndex + 1) + showTwoPowerConversation({ + power1: power1, + power2: power2, + moment: moment, + onClose: () => { + // Schedule the speaking of the summary after the conversation closes + scheduleSummarySpeech(); + if (gameState.isPlaying) _setPhase(gameState.phaseIndex + 1) } - } else { - _setPhase(gameState.phaseIndex + 1) - } + }) } else { - console.log("not moving") + // No conversation to show, proceed with normal flow + scheduleSummarySpeech(); + _setPhase(gameState.phaseIndex + 1) } } @@ -220,8 +216,6 @@ export function displayPhase(skipMessages = false) { // Only get previous phase if not the first phase const prevIndex = isFirstPhase ? null : (index > 0 ? index - 1 : null); const previousPhase = prevIndex !== null ? gameState.gameData.phases[prevIndex] : null; - updatePhaseDisplay() - // Update supply centers @@ -271,7 +265,6 @@ export function displayPhase(skipMessages = false) { } else { logger.log("No animations for this phase transition"); } - gameState.nextPhaseScheduled = false; } @@ -293,68 +286,6 @@ export function displayPhaseWithAnimation() { } -/** - * Advances to the next phase in the game sequence - * Handles speaking summaries and transitioning to the next phase - */ -export function advanceToNextPhase() { - // If we're not "playing" through the game, just skipping phases, move everything along - if (!gameState.isPlaying) { - nextPhase() - } - - if (!gameState.gameData || !gameState.gameData.phases || gameState.phaseIndex < 0) { - logger.log("Cannot advance phase: invalid game state"); - return; - } - - // Get current phase - const currentPhase = gameState.gameData.phases[gameState.phaseIndex]; - - console.log(`Current phase: ${currentPhase.name}, Has summary: ${Boolean(currentPhase.summary)}`); - if (currentPhase.summary) { - console.log(`Summary preview: "${currentPhase.summary.substring(0, 50)}..."`); - } - - if (config.isDebugMode) { - console.log(`Processing phase transition for ${currentPhase.name}`); - } - - const speechDelay = config.speechDelay - - // First show summary if available - if (currentPhase.summary && currentPhase.summary.trim() !== '') { - // Delay speech in streaming mode - gameState.eventQueue.scheduleDelay(speechDelay, () => { - // Speak the summary and advance after - if (!gameState.isSpeaking) { - speakSummary(currentPhase.summary) - .then(() => { - console.log("Speech completed successfully"); - if (gameState.isPlaying) { - nextPhase(); - } - }) - .catch((error) => { - console.error("Speech failed with error:", error); - if (gameState.isPlaying) { - nextPhase(); - } - }).finally(() => { - // Any cleanup code here - }); - } else { - console.error("Attempted to start speaking when already speaking..."); - } - }, `speech-delay-${Date.now()}`); - } else { - console.log("No summary available, skipping speech"); - // No summary to speak, advance immediately - nextPhase(); - } - -} - function displayFinalPhase() { if (!gameState.gameData || !gameState.gameData.phases || gameState.gameData.phases.length === 0) { return; diff --git a/ai_animation/src/speech.ts b/ai_animation/src/speech.ts index c4ff033..2f840d1 100644 --- a/ai_animation/src/speech.ts +++ b/ai_animation/src/speech.ts @@ -63,14 +63,14 @@ async function testElevenLabsKey() { * Call ElevenLabs TTS to speak the summary out loud. * Returns a promise that resolves only after the audio finishes playing (or fails). * Truncates text to first 100 characters for brevity and API limitations. - * @param summaryText The text to be spoken * @returns Promise that resolves when audio completes or rejects on error */ -export async function speakSummary(summaryText: string): Promise { +export async function speakSummary(): Promise { if (!config.speechEnabled) { console.log("Speech disabled via config, skipping TTS"); return; } + const summaryText = gameState.currentPhase.summary if (!summaryText || summaryText.trim() === '') { console.warn("No summary text provided to speakSummary function"); @@ -98,9 +98,6 @@ export async function speakSummary(summaryText: string): Promise { return; } - // Set the speaking flag to block other animations/transitions - gameState.isSpeaking = true; - try { // Truncate text to first 100 characters for ElevenLabs let textForSpeaking; @@ -138,14 +135,12 @@ export async function speakSummary(summaryText: string): Promise { const audio = new Audio(audioUrl); audio.play().then(() => { audio.onended = () => { - // Clear the speaking flag when audio finishes - gameState.isSpeaking = false; + console.log("Speech completed successfully"); resolve(); }; }).catch(err => { console.error("Audio playback error"); // Make sure to clear the flag even if there's an error - gameState.isSpeaking = false; reject(err); }); }); @@ -153,7 +148,8 @@ export async function speakSummary(summaryText: string): Promise { } catch (err) { console.error("Failed to generate TTS from ElevenLabs"); // Make sure to clear the flag if there's any exception - gameState.isSpeaking = false; throw err; } } + + diff --git a/ai_animation/src/types/events.ts b/ai_animation/src/types/events.ts index 61cfdee..a3c98b5 100644 --- a/ai_animation/src/types/events.ts +++ b/ai_animation/src/types/events.ts @@ -8,6 +8,7 @@ export interface ScheduledEvent { callback: () => void; resolved?: boolean; priority?: number; // Higher numbers execute first for events at same time + error?: Error; // If the event caused an error, store it here. } export class EventQueue { @@ -19,7 +20,7 @@ export class EventQueue { * Start the event queue with current time as reference */ start(): void { - this.startTime = performance.now() / 1000; + this.startTime = performance.now(); this.isRunning = true; } @@ -60,7 +61,12 @@ export class EventQueue { * Remove resolved events from the queue */ cleanup(): void { - this.events = this.events.filter(event => !event.resolved); + let clearedQueue = this.events.filter(event => !event.resolved); + if (clearedQueue.length <= 1) { + console.log(this.events) + throw new Error("We've cleared all the messages out of the queue") + } + this.events = clearedQueue } /** @@ -69,13 +75,27 @@ export class EventQueue { update(): void { if (!this.isRunning) return; - const now = performance.now() / 1000; + const now = performance.now(); const elapsed = now - this.startTime; for (const event of this.events) { if (!event.resolved && elapsed >= event.triggerAtTime) { - event.callback(); - event.resolved = true; + try { + + event.callback(); + } catch (err) { + // TODO: Need some system here to catch and report errors, but we mark them as resolved now so that we don't call an erroring fucntion repeatedly. + this.events.slice(this.events.indexOf(event), 1) + if (err instanceof Error) { + event.error = err + console.error(err) + } else { + console.error(`Got type "${typeof err} as error for event with id ${event.id}`) + console.error(err) + } + } finally { + event.resolved = true; + } } } @@ -103,11 +123,11 @@ export class EventQueue { * Schedule a simple delay callback (like setTimeout) */ scheduleDelay(delayMs: number, callback: () => void, id?: string): void { - const now = performance.now() / 1000; + const now = performance.now(); const elapsed = this.isRunning ? now - this.startTime : 0; this.schedule({ id: id || `delay-${Date.now()}-${Math.random()}`, - triggerAtTime: elapsed + (delayMs / 1000), // Schedule relative to current time + triggerAtTime: elapsed + (delayMs), // Schedule relative to current time callback }); } @@ -119,23 +139,23 @@ export class EventQueue { scheduleRecurring(intervalMs: number, callback: () => void, id?: string): () => void { let counter = 0; const baseId = id || `recurring-${Date.now()}`; - const now = performance.now() / 1000; + const now = performance.now(); const startElapsed = this.isRunning ? now - this.startTime : 0; - + const scheduleNext = () => { counter++; this.schedule({ id: `${baseId}-${counter}`, - triggerAtTime: startElapsed + (intervalMs * counter) / 1000, + triggerAtTime: startElapsed + (intervalMs * counter), callback: () => { callback(); scheduleNext(); // Schedule the next occurrence } }); }; - + scheduleNext(); - + // Return cancel function return () => { // Mark all future events for this recurring schedule as resolved @@ -146,4 +166,4 @@ export class EventQueue { }); }; } -} \ No newline at end of file +} diff --git a/ai_animation/src/types/moments.ts b/ai_animation/src/types/moments.ts index 83b0d46..d23fe14 100644 --- a/ai_animation/src/types/moments.ts +++ b/ai_animation/src/types/moments.ts @@ -60,7 +60,7 @@ export const LieSchema = z.object({ liar: PowerENUMSchema, recipient: PowerENUMSchema, promise: z.string(), - diary_intent: z.string(), + diary_intent: z.string().nullable(), actual_action: z.string(), intentional: z.boolean(), explanation: z.string() diff --git a/ai_animation/tests/e2e/message-flow-verification.spec.ts b/ai_animation/tests/e2e/message-flow-verification.spec.ts index cbcbcf9..22cac66 100644 --- a/ai_animation/tests/e2e/message-flow-verification.spec.ts +++ b/ai_animation/tests/e2e/message-flow-verification.spec.ts @@ -9,98 +9,120 @@ interface MessageRecord { } /** - * Gets expected messages for current power from the browser's game data + * Comprehensive test to verify message system functionality and data quality */ -async function getExpectedMessagesFromBrowser(page: Page): Promise> { +async function verifyMessageSystemHealth(page: Page): Promise<{ + hasValidGameData: boolean; + messageCount: number; + eventQueueActive: boolean; + momentsWithNoMessages: number; +}> { return await page.evaluate(() => { const gameData = window.gameState?.gameData; const currentPower = window.gameState?.currentPower; + const momentsData = window.gameState?.momentsData; - if (!gameData || !currentPower) return []; - - const relevantMessages: Array<{ - sender: string; - recipient: string; - message: string; - phase: string; - }> = []; + if (!gameData || !currentPower) { + return { + hasValidGameData: false, + messageCount: 0, + eventQueueActive: false, + momentsWithNoMessages: 0 + }; + } + // Count relevant messages + let messageCount = 0; gameData.phases.forEach((phase: any) => { if (phase.messages) { phase.messages.forEach((msg: any) => { - // Apply same filtering logic as updateChatWindows() if (msg.sender === currentPower || msg.recipient === currentPower || msg.recipient === 'GLOBAL') { - relevantMessages.push({ - sender: msg.sender, - recipient: msg.recipient, - message: msg.message, - phase: phase.name - }); + messageCount++; } }); } }); - return relevantMessages; + // Check for moments that might have no messages (data quality issue) + let momentsWithNoMessages = 0; + if (momentsData && Array.isArray(momentsData)) { + momentsData.forEach((moment: any) => { + if (moment.interest_score >= 8.0 && moment.powers_involved?.length >= 2) { + const power1 = moment.powers_involved[0]; + const power2 = moment.powers_involved[1]; + + // Find the phase for this moment + const phaseForMoment = gameData.phases.find((p: any) => p.name === moment.phase); + if (phaseForMoment && phaseForMoment.messages) { + const conversationMessages = phaseForMoment.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); + }); + + if (conversationMessages.length === 0) { + momentsWithNoMessages++; + } + } + } + }); + } + + return { + hasValidGameData: true, + messageCount, + eventQueueActive: window.gameState?.eventQueue?.pendingEvents?.length > 0 || false, + momentsWithNoMessages + }; }); } test.describe('Message Flow Verification', () => { - test('should verify basic message system functionality', async ({ page }) => { - // This test verifies the message system works and doesn't get stuck + test('should verify message system health and data quality', async ({ page }) => { + // This test verifies the message system works and validates data quality await page.goto('http://localhost:5173'); await waitForGameReady(page); // Enable instant mode for faster testing await enableInstantMode(page); - // Verify game state is accessible - const gameState = await page.evaluate(() => ({ - hasGameData: !!window.gameState?.gameData, - currentPower: window.gameState?.currentPower, - phaseIndex: window.gameState?.phaseIndex, - hasEventQueue: !!window.gameState?.eventQueue - })); + // Get comprehensive health check + const healthStatus = await verifyMessageSystemHealth(page); - expect(gameState.hasGameData).toBe(true); - expect(gameState.currentPower).toBeTruthy(); - expect(gameState.hasEventQueue).toBe(true); + expect(healthStatus.hasValidGameData).toBe(true); - console.log(`Game loaded with current power: ${gameState.currentPower}`); + console.log(`Message system health check:`); + console.log(`- Total relevant messages: ${healthStatus.messageCount}`); + console.log(`- Event queue active: ${healthStatus.eventQueueActive}`); + console.log(`- Moments with no messages: ${healthStatus.momentsWithNoMessages}`); - // Start playback for a short time to verify message system works + // Data quality verification: should have no moments without messages + // (Our new error-throwing approach prevents these from being processed) + if (healthStatus.momentsWithNoMessages > 0) { + console.warn(`⚠️ Found ${healthStatus.momentsWithNoMessages} high-interest moments with no messages`); + console.warn(`This indicates potential data quality issues that would now throw errors`); + } + + // Start playback briefly to verify system works await page.click('#play-btn'); - // Monitor for basic functionality over 10 seconds - let messageAnimationDetected = false; + // Monitor for basic functionality over 5 seconds let eventQueueActive = false; - for (let i = 0; i < 100; i++) { // 10 seconds in 100ms intervals + for (let i = 0; i < 50; i++) { // 5 seconds in 100ms intervals const status = await page.evaluate(() => ({ - isAnimating: window.gameState?.messagesPlaying || false, hasEvents: window.gameState?.eventQueue?.pendingEvents?.length > 0 || false, - phase: document.querySelector('#phase-display')?.textContent?.replace('Era: ', '') || '' })); - if (status.isAnimating) { - messageAnimationDetected = true; - } - if (status.hasEvents) { eventQueueActive = true; - } - - // If we've detected both, we can finish early - if (messageAnimationDetected && eventQueueActive) { break; } @@ -110,14 +132,10 @@ test.describe('Message Flow Verification', () => { // Stop playback await page.click('#play-btn'); - // Verify basic functionality was detected - console.log(`Message animation detected: ${messageAnimationDetected}`); - console.log(`Event queue active: ${eventQueueActive}`); - - // At minimum, the event queue should be active (even if no messages in first phase) + // At minimum, the event queue should be active expect(eventQueueActive).toBe(true); - console.log('✅ Basic message system functionality verified'); + console.log('✅ Message system health and data quality verified'); }); test('should verify no simultaneous message animations', async ({ page }) => {