From a929bf5ee645469964ba46fc8489bcf30a8e6ae1 Mon Sep 17 00:00:00 2001 From: Tyler Marques Date: Tue, 10 Jun 2025 10:49:47 -0700 Subject: [PATCH] WIP: Majority converted to event queue, continuing to work on others. The bottom newsbar still needs conversion, as do numerous other pieces, but we're working our way there. Attempting adding some tests with Claude, but they aren't functioning yet, making them effectively useless. Continuing to iterate. --- ai_animation/package-lock.json | 11 +- .../components/twoPowerConversation.test.ts | 191 ++++++++++++++++++ .../src/components/twoPowerConversation.ts | 16 +- ai_animation/src/config.ts | 70 ++++++- ai_animation/src/domElements/chatWindows.ts | 19 +- ai_animation/src/phase.ts | 4 +- ai_animation/src/speech.ts | 8 +- .../e2e/message-flow-verification.spec.ts | 178 ++++++++++++++++ ai_animation/tests/e2e/test-helpers.ts | 146 +++++++++++++ 9 files changed, 611 insertions(+), 32 deletions(-) create mode 100644 ai_animation/src/components/twoPowerConversation.test.ts create mode 100644 ai_animation/tests/e2e/message-flow-verification.spec.ts diff --git a/ai_animation/package-lock.json b/ai_animation/package-lock.json index 4d673f6..d705516 100644 --- a/ai_animation/package-lock.json +++ b/ai_animation/package-lock.json @@ -1906,15 +1906,18 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", - "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" diff --git a/ai_animation/src/components/twoPowerConversation.test.ts b/ai_animation/src/components/twoPowerConversation.test.ts new file mode 100644 index 0000000..cd5cddd --- /dev/null +++ b/ai_animation/src/components/twoPowerConversation.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock Three.js and complex dependencies first +vi.mock('three', () => ({ + WebGLRenderer: vi.fn(), + Scene: vi.fn(), + PerspectiveCamera: vi.fn(), + DirectionalLight: vi.fn(), + AmbientLight: vi.fn(), + BoxGeometry: vi.fn(), + MeshStandardMaterial: vi.fn(), + MeshBasicMaterial: vi.fn(), + Mesh: vi.fn(), + WebGLRenderTarget: vi.fn(), +})); + +vi.mock('three/examples/jsm/Addons.js', () => ({ + OrbitControls: vi.fn() +})); + +vi.mock('@tweenjs/tween.js', () => ({ + Tween: vi.fn(), + Group: vi.fn() +})); + +// Mock gameState with minimal implementation +vi.mock('../gameState', () => { + const mockEventQueue = { + pendingEvents: [], + scheduleDelay: vi.fn(), + reset: vi.fn(), + start: vi.fn(), + stop: vi.fn() + }; + + return { + gameState: { + isPlaying: false, + isDisplayingMoment: false, + phaseIndex: 0, + gameData: { + phases: [{ + name: 'S1901M', + messages: [] + }] + }, + eventQueue: mockEventQueue + } + }; +}); + +// Mock config +vi.mock('../config', () => ({ + config: { + conversationModalDelay: 500, + conversationMessageDisplay: 1000, + conversationMessageAnimation: 300, + conversationFinalDelay: 2000 + } +})); + +// Mock utils +vi.mock('../utils/powerNames', () => ({ + getPowerDisplayName: vi.fn(power => power) +})); + +// Mock the phase module to avoid circular dependency +const mockSetPhase = vi.fn(); +vi.mock('../phase', () => ({ + _setPhase: mockSetPhase +})); + +// Setup minimal DOM mocks +Object.defineProperty(global, 'document', { + value: { + createElement: vi.fn(() => ({ + style: {}, + appendChild: vi.fn(), + addEventListener: vi.fn(), + querySelector: vi.fn(() => null), + parentNode: { removeChild: vi.fn() }, + classList: { add: vi.fn() }, + textContent: '', + id: '' + })), + getElementById: vi.fn(() => null), + body: { appendChild: vi.fn() }, + removeEventListener: vi.fn(), + addEventListener: vi.fn() + } +}); + +// Import after all mocking +import { showTwoPowerConversation, closeTwoPowerConversation } from './twoPowerConversation'; + +// Get direct reference to the mocked gameState +let gameState: any; + +describe('twoPowerConversation', () => { + beforeEach(async () => { + // Get the mocked gameState + const gameStateModule = await import('../gameState'); + gameState = gameStateModule.gameState; + + // Reset mocked game state + gameState.isPlaying = false; + gameState.isDisplayingMoment = false; + gameState.phaseIndex = 0; + gameState.gameData.phases[0].messages = []; + + // Reset all mocks + vi.clearAllMocks(); + gameState.eventQueue.pendingEvents = []; + }); + + describe('showTwoPowerConversation', () => { + it('should throw error when no messages found (indicates data quality issue)', () => { + gameState.isPlaying = true; + + expect(() => { + showTwoPowerConversation({ + power1: 'FRANCE', + power2: 'GERMANY' + }); + }).toThrow('High-interest moment detected between FRANCE and GERMANY but no messages found'); + }); + + it('should throw error when empty messages array provided', () => { + gameState.isPlaying = false; + + expect(() => { + showTwoPowerConversation({ + power1: 'FRANCE', + power2: 'GERMANY', + messages: [] + }); + }).toThrow('High-interest moment detected between FRANCE and GERMANY but no messages found'); + }); + + it('should schedule phase advancement when messages exist and game is playing', () => { + gameState.isPlaying = true; + gameState.gameData.phases[0].messages = [ + { sender: 'FRANCE', recipient: 'GERMANY', message: 'Hello', time_sent: '1' } + ]; + + showTwoPowerConversation({ + power1: 'FRANCE', + power2: 'GERMANY' + }); + + // Should have scheduled events in the queue + expect(gameState.eventQueue.scheduleDelay).toHaveBeenCalled(); + + // Should have marked as displaying moment + expect(gameState.isDisplayingMoment).toBe(true); + }); + }); + + describe('Event queue safety', () => { + it('should schedule events when messages exist', () => { + gameState.isPlaying = true; + gameState.gameData.phases[0].messages = [ + { sender: 'FRANCE', recipient: 'GERMANY', message: 'Test', time_sent: '1' } + ]; + + showTwoPowerConversation({ + power1: 'FRANCE', + power2: 'GERMANY' + }); + + // Should have scheduled events in the queue + expect(gameState.eventQueue.scheduleDelay).toHaveBeenCalled(); + + // Should have marked as displaying moment + expect(gameState.isDisplayingMoment).toBe(true); + }); + + it('should throw error for empty message arrays as they indicate data quality issues', () => { + gameState.isPlaying = true; + + // Test with empty messages - should throw error + expect(() => { + showTwoPowerConversation({ + power1: 'FRANCE', + power2: 'GERMANY', + messages: [] + }); + }).toThrow('High-interest moment detected between FRANCE and GERMANY but no messages found'); + }); + }); +}); \ No newline at end of file diff --git a/ai_animation/src/components/twoPowerConversation.ts b/ai_animation/src/components/twoPowerConversation.ts index 8f14d21..05c92ef 100644 --- a/ai_animation/src/components/twoPowerConversation.ts +++ b/ai_animation/src/components/twoPowerConversation.ts @@ -37,8 +37,10 @@ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void const conversationMessages = messages || getMessagesBetweenPowers(power1, power2); if (conversationMessages.length === 0) { - console.warn(`No messages found between ${power1} and ${power2}`); - return; + 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 @@ -99,11 +101,11 @@ function scheduleMessageSequence( power1: string, power2: string ): void { - let currentDelay = 500; // Start after modal is fully visible + let currentDelay = config.conversationModalDelay; // Start after modal is fully visible - // Calculate timing based on mode - const messageDisplayTime = config.isInstantMode ? 100 : config.effectivePlaybackSpeed; - const messageAnimationTime = config.isInstantMode ? 50 : 300; + // Calculate timing from config + const messageDisplayTime = config.conversationMessageDisplay; + const messageAnimationTime = config.conversationMessageAnimation; messages.forEach((message, index) => { // Schedule each message display @@ -116,7 +118,7 @@ function scheduleMessageSequence( }); // Schedule conversation close after all messages are shown - const totalConversationTime = currentDelay + (config.isInstantMode ? 500 : 2000); // Extra delay before closing + const totalConversationTime = currentDelay + config.conversationFinalDelay; // Extra delay before closing gameState.eventQueue.scheduleDelay(totalConversationTime, () => { closeTwoPowerConversation(); diff --git a/ai_animation/src/config.ts b/ai_animation/src/config.ts index 3f19f25..d8d0090 100644 --- a/ai_animation/src/config.ts +++ b/ai_animation/src/config.ts @@ -55,17 +55,81 @@ export const config = { }, /** - * Get effective animation duration (0 if instant mode, normal duration otherwise) + * Get effective animation duration (minimal if instant mode, normal duration otherwise) */ get effectiveAnimationDuration(): number { - return this.isInstantMode ? 0 : this.animationDuration; + return this.isInstantMode ? 1 : this.animationDuration; }, /** * Get effective playback speed (minimal if instant mode, normal speed otherwise) */ get effectivePlaybackSpeed(): number { - return this.isInstantMode ? 10 : this.streamingPlaybackSpeed; + return this.isInstantMode ? 1 : this.streamingPlaybackSpeed; + }, + + // ======================================== + // CENTRALIZED TIMING CONFIGURATION + // All timing values should be accessed through these properties + // ======================================== + + /** + * Message display timing + */ + get messageWordDelay(): number { + return this.isInstantMode ? 1 : 50; // Delay between words in a message + }, + + get messageBetweenDelay(): number { + return this.isInstantMode ? 1 : this.effectivePlaybackSpeed; // Delay between messages + }, + + get messageCompletionDelay(): number { + return this.isInstantMode ? 1 : Math.min(this.effectivePlaybackSpeed / 3, 150); // Delay after message completion + }, + + /** + * UI Animation timing + */ + get uiFadeDelay(): number { + return this.isInstantMode ? 1 : 300; // Banner fade, overlay transitions, etc. + }, + + get uiTransitionDuration(): number { + return this.isInstantMode ? 0 : 0.3; // CSS transition duration in seconds + }, + + /** + * Two-power conversation timing + */ + get conversationMessageDisplay(): number { + return this.isInstantMode ? 50 : this.effectivePlaybackSpeed; // How long each message shows + }, + + get conversationMessageAnimation(): number { + return this.isInstantMode ? 25 : 300; // Animation time for message appearance + }, + + get conversationFinalDelay(): number { + return this.isInstantMode ? 200 : 2000; // Final delay before closing conversation + }, + + get conversationModalDelay(): number { + return this.isInstantMode ? 50 : 500; // Initial delay before showing messages + }, + + /** + * Phase and moment timing + */ + get momentDisplayTimeout(): number { + return this.isInstantMode ? 1000 : 30000; // How long moments display (though this is now managed by conversation) + }, + + /** + * Speech timing (when speech is disabled in instant mode) + */ + get speechDelay(): number { + return this.isInstantMode ? 1 : 2000; // Delay before speech starts }, // Animation timing configuration diff --git a/ai_animation/src/domElements/chatWindows.ts b/ai_animation/src/domElements/chatWindows.ts index 058224a..a2be0c2 100644 --- a/ai_animation/src/domElements/chatWindows.ts +++ b/ai_animation/src/domElements/chatWindows.ts @@ -203,8 +203,8 @@ export function updateChatWindows(stepMessages = false) { console.log(`Found ${relevantMessages.length} messages for player ${gameState.currentPower} in phase ${gameState.currentPhase.name}`); } - if (!stepMessages || config.isInstantMode) { - // Normal mode or instant chat mode: show all messages at once + if (!stepMessages) { + // Normal mode: show all messages at once relevantMessages.forEach(msg => { const isNew = addMessageToChat(msg); if (isNew) { @@ -267,7 +267,7 @@ export function updateChatWindows(stepMessages = false) { index++; // Only increment after animation completes // Schedule next message with proper delay - gameState.eventQueue.scheduleDelay(config.effectivePlaybackSpeed, showNext, `show-next-message-${index}-${Date.now()}`); + gameState.eventQueue.scheduleDelay(config.messageBetweenDelay, showNext, `show-next-message-${index}-${Date.now()}`); }; // Add the message with word animation @@ -409,7 +409,7 @@ function animateMessageWords(message: string, contentSpanId: string, targetPower console.log(`Finished animating message with ${words.length} words in ${targetPower} chat`); // Add a slight delay after the last word for readability - gameState.eventQueue.scheduleDelay(Math.min(config.effectivePlaybackSpeed / 3, 150), () => { + gameState.eventQueue.scheduleDelay(config.messageCompletionDelay, () => { if (onComplete) { onComplete(); // Call the completion callback } @@ -430,9 +430,8 @@ function animateMessageWords(message: string, contentSpanId: string, targetPower // Calculate delay based on word length and playback speed // Longer words get slightly longer display time const wordLength = words[wordIndex - 1].length; - // In streaming mode, use a more consistent delay to prevent overlap - const baseDelay = config.effectivePlaybackSpeed - const delay = Math.max(50, Math.min(200, baseDelay * (wordLength / 4))); + // Use consistent word delay from config + const delay = Math.max(config.messageWordDelay, Math.min(200, config.messageWordDelay * (wordLength / 4))); gameState.eventQueue.scheduleDelay(delay, addNextWord, `add-word-${wordIndex}-${Date.now()}`); // Scroll to ensure newest content is visible @@ -744,12 +743,12 @@ export function addToNewsBanner(newText: string): void { console.log(`Adding to news banner: "${newText}"`); } - // Add a fade-out transition (instant in instant mode) - const transitionDuration = config.isInstantMode ? 0 : 0.3; + // Add a fade-out transition + const transitionDuration = config.uiTransitionDuration; bannerEl.style.transition = `opacity ${transitionDuration}s ease-out`; bannerEl.style.opacity = '0'; - gameState.eventQueue.scheduleDelay(config.isInstantMode ? 0 : 300, () => { + gameState.eventQueue.scheduleDelay(config.uiFadeDelay, () => { // If the banner only has the default text or is empty, replace it if ( bannerEl.textContent?.trim() === 'Diplomatic actions unfolding...' || diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index ed84ad8..0152ce7 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -17,7 +17,7 @@ import { startBackgroundAudio, stopBackgroundAudio } from "./backgroundAudio"; const MOMENT_THRESHOLD = 8.0 // If we're in debug mode or instant mode, show it quick, otherwise show it for 30 seconds -const MOMENT_DISPLAY_TIMEOUT_MS = config.isDebugMode || config.isInstantMode ? 100 : 30000 +const MOMENT_DISPLAY_TIMEOUT_MS = config.isDebugMode ? 100 : config.momentDisplayTimeout // FIXME: Going to previous phases is borked. Units do not animate properly, map doesn't update. export function _setPhase(phaseIndex: number) { @@ -320,7 +320,7 @@ export function advanceToNextPhase() { console.log(`Processing phase transition for ${currentPhase.name}`); } - const speechDelay = 2000 + const speechDelay = config.speechDelay // First show summary if available if (currentPhase.summary && currentPhase.summary.trim() !== '') { diff --git a/ai_animation/src/speech.ts b/ai_animation/src/speech.ts index 6d36dda..c4ff033 100644 --- a/ai_animation/src/speech.ts +++ b/ai_animation/src/speech.ts @@ -67,12 +67,8 @@ async function testElevenLabsKey() { * @returns Promise that resolves when audio completes or rejects on error */ export async function speakSummary(summaryText: string): Promise { - if (!config.speechEnabled || config.isInstantMode) { - if (config.isInstantMode) { - console.log("Instant mode enabled, skipping TTS"); - } else { - console.log("Speech disabled via config, skipping TTS"); - } + if (!config.speechEnabled) { + console.log("Speech disabled via config, skipping TTS"); return; } diff --git a/ai_animation/tests/e2e/message-flow-verification.spec.ts b/ai_animation/tests/e2e/message-flow-verification.spec.ts new file mode 100644 index 0000000..cbcbcf9 --- /dev/null +++ b/ai_animation/tests/e2e/message-flow-verification.spec.ts @@ -0,0 +1,178 @@ +import { test, expect, Page } from '@playwright/test'; +import { waitForGameReady, getCurrentPhaseName, enableInstantMode, getAllChatMessages, waitForMessagesToComplete } from './test-helpers'; + +interface MessageRecord { + content: string; + chatWindow: string; + phase: string; + timestamp: number; +} + +/** + * Gets expected messages for current power from the browser's game data + */ +async function getExpectedMessagesFromBrowser(page: Page): Promise> { + return await page.evaluate(() => { + const gameData = window.gameState?.gameData; + const currentPower = window.gameState?.currentPower; + + if (!gameData || !currentPower) return []; + + const relevantMessages: Array<{ + sender: string; + recipient: string; + message: string; + phase: string; + }> = []; + + 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 + }); + } + }); + } + }); + + return relevantMessages; + }); +} + + + +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 + 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 + })); + + expect(gameState.hasGameData).toBe(true); + expect(gameState.currentPower).toBeTruthy(); + expect(gameState.hasEventQueue).toBe(true); + + console.log(`Game loaded with current power: ${gameState.currentPower}`); + + // Start playback for a short time to verify message system works + await page.click('#play-btn'); + + // Monitor for basic functionality over 10 seconds + let messageAnimationDetected = false; + let eventQueueActive = false; + + for (let i = 0; i < 100; i++) { // 10 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; + } + + await page.waitForTimeout(100); + } + + // 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) + expect(eventQueueActive).toBe(true); + + console.log('✅ Basic message system functionality verified'); + }); + + test('should verify no simultaneous message animations', async ({ page }) => { + await page.goto('http://localhost:5173'); + await waitForGameReady(page); + + // Enable instant mode for faster testing + await enableInstantMode(page); + + let simultaneousAnimationDetected = false; + let animationCount = 0; + + // Start playback + await page.click('#play-btn'); + + // Monitor animation state for overlaps + for (let i = 0; i < 100; i++) { // 10 seconds + const animationStatus = await page.evaluate(() => { + // Check if multiple animation systems are active simultaneously + const messagesPlaying = window.gameState?.messagesPlaying || false; + const chatMessages = document.querySelectorAll('.chat-message'); + const recentlyAdded = Array.from(chatMessages).filter(msg => { + const timeStamp = msg.dataset.timestamp; + return timeStamp && (Date.now() - parseInt(timeStamp)) < 500; // Added in last 500ms + }); + + return { + messagesPlaying, + messageCount: chatMessages.length, + recentMessages: recentlyAdded.length + }; + }); + + if (animationStatus.messagesPlaying) { + animationCount++; + + // Check if too many messages appear simultaneously (could indicate race condition) + if (animationStatus.recentMessages > 3) { + simultaneousAnimationDetected = true; + console.warn(`Potential simultaneous animation: ${animationStatus.recentMessages} recent messages`); + } + } + + await page.waitForTimeout(100); + } + + // Stop playback + await page.click('#play-btn'); + + console.log(`Animation cycles detected: ${animationCount}`); + console.log(`Simultaneous animations detected: ${simultaneousAnimationDetected}`); + + // We should see some animations but no simultaneous ones + expect(simultaneousAnimationDetected).toBe(false); + + console.log('✅ No simultaneous message animations detected'); + }); +}); \ No newline at end of file diff --git a/ai_animation/tests/e2e/test-helpers.ts b/ai_animation/tests/e2e/test-helpers.ts index 5774b82..0c89427 100644 --- a/ai_animation/tests/e2e/test-helpers.ts +++ b/ai_animation/tests/e2e/test-helpers.ts @@ -283,3 +283,149 @@ export async function advanceGameManually( return false; // Didn't reach victory } + +/** + * Helper function to wait for messages to complete animating in current phase + */ +export async function waitForMessagesToComplete(page: Page, timeoutMs = 10000): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const isAnimating = await page.evaluate(() => window.gameState?.messagesPlaying || false); + + if (!isAnimating) { + return; // Messages completed + } + + await page.waitForTimeout(100); + } + + throw new Error('Timeout waiting for messages to complete animation'); +} + +/** + * Helper function to get current power that the player is assigned + */ +export async function getCurrentPower(page: Page): Promise { + try { + return await page.evaluate(() => window.gameState?.currentPower || null); + } catch { + return null; + } +} + +/** + * Helper function to count visible chat messages across all chat windows + */ +export async function countVisibleChatMessages(page: Page): Promise { + try { + return await page.evaluate(() => { + const chatMessages = document.querySelectorAll('.chat-message'); + return chatMessages.length; + }); + } catch { + return 0; + } +} + +/** + * Helper function to get all chat messages with their metadata + */ +export async function getAllChatMessages(page: Page): Promise> { + try { + return await page.evaluate(() => { + const messages: Array<{ + content: string; + chatWindow: string; + phase: string; + }> = []; + + const chatMessages = document.querySelectorAll('.chat-message'); + chatMessages.forEach(messageElement => { + const chatWindow = messageElement.closest('.chat-window'); + const chatWindowId = chatWindow?.id || 'unknown'; + + // Extract message content (excluding sender label) + const contentSpan = messageElement.querySelector('span:not([class*="power-"])'); + const timeDiv = messageElement.querySelector('.message-time'); + + if (contentSpan && timeDiv) { + messages.push({ + content: contentSpan.textContent || '', + chatWindow: chatWindowId, + phase: timeDiv.textContent || '' + }); + } + }); + + return messages; + }); + } catch { + return []; + } +} + +/** + * Helper function to enable instant mode for faster testing + */ +export async function enableInstantMode(page: Page): Promise { + await page.evaluate(() => { + if (window.config) { + window.config.setInstantMode(true); + } + }); +} + +/** + * Helper function to check if the event queue has any pending events + */ +export async function hasEventQueueEvents(page: Page): Promise { + try { + return await page.evaluate(() => { + return window.gameState?.eventQueue?.pendingEvents?.length > 0 || false; + }); + } catch { + return false; + } +} + +/** + * Helper function to monitor message animation state continuously + * Returns a function to call to get the animation history + */ +export async function startMessageAnimationMonitoring(page: Page): Promise<() => Promise>> { + // Set up monitoring in the browser context + await page.evaluate(() => { + window.messageAnimationHistory = []; + + const monitor = () => { + const isAnimating = window.gameState?.messagesPlaying || false; + const phase = document.querySelector('#phase-display')?.textContent?.replace('Era: ', '') || ''; + const messageCount = document.querySelectorAll('.chat-message').length; + + window.messageAnimationHistory.push({ + timestamp: Date.now(), + isAnimating, + phase, + messageCount + }); + }; + + // Monitor every 50ms + window.messageAnimationInterval = setInterval(monitor, 50); + }); + + // Return function to get the history + return async () => { + return await page.evaluate(() => window.messageAnimationHistory || []); + }; +}