WIP: Mostly working version that uses the eventqueue

We've mostly got this working. Events get executed in the array of
events. We don't use setTimeout anymore thankfully. I'm not in love with
this system, it still isn't straight forward about what events happen
where, but I do at least now have an ability to better control the flow.
This commit is contained in:
Tyler Marques 2025-06-13 12:12:03 -07:00
parent a929bf5ee6
commit ef3e3a79fe
No known key found for this signature in database
GPG key ID: CB99EDCF41D3016F
11 changed files with 336 additions and 393 deletions

8
.vscode/launch.json vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void> {
export async function speakSummary(): Promise<void> {
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<void> {
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<void> {
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<void> {
} 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;
}
}

View file

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

View file

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

View file

@ -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<Array<{
sender: string;
recipient: string;
message: string;
phase: string;
}>> {
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 }) => {