mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-19 12:58:09 +00:00
WIP Creating pre-filled event queue
This commit is contained in:
parent
44ace48402
commit
ea4025aa73
13 changed files with 245 additions and 492 deletions
|
|
@ -1,182 +0,0 @@
|
|||
/**
|
||||
* Tests for background audio looping functionality
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Mock HTMLAudioElement
|
||||
class MockAudio {
|
||||
loop = false;
|
||||
volume = 1;
|
||||
paused = true;
|
||||
src = '';
|
||||
currentTime = 0;
|
||||
duration = 10;
|
||||
|
||||
constructor() {
|
||||
// Constructor can be empty or set default values
|
||||
}
|
||||
|
||||
play = vi.fn().mockResolvedValue(undefined);
|
||||
pause = vi.fn();
|
||||
addEventListener = vi.fn();
|
||||
}
|
||||
|
||||
describe('Background Audio Looping', () => {
|
||||
let originalAudio: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original and replace with mock
|
||||
originalAudio = global.Audio;
|
||||
global.Audio = MockAudio as any;
|
||||
|
||||
// Clear any module state by re-importing
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original Audio constructor
|
||||
global.Audio = originalAudio;
|
||||
});
|
||||
|
||||
it('should set loop property to true during initialization', async () => {
|
||||
// Import fresh module to avoid state pollution
|
||||
const { initializeBackgroundAudio } = await import('./backgroundAudio');
|
||||
|
||||
// Initialize background audio
|
||||
initializeBackgroundAudio();
|
||||
|
||||
// The actual test is that initialization doesn't throw and sets up the audio correctly
|
||||
// We can't directly access the private audio instance, but we can test the behavior
|
||||
expect(global.Audio).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle audio loop property correctly', () => {
|
||||
// Create a mock audio instance to test loop behavior
|
||||
const audioElement = new MockAudio() as any;
|
||||
|
||||
// Set loop to true (like our code does)
|
||||
audioElement.loop = true;
|
||||
audioElement.duration = 5; // 5 second track
|
||||
|
||||
// Simulate audio playing and ending
|
||||
audioElement.paused = false;
|
||||
audioElement.currentTime = 0;
|
||||
|
||||
// Simulate what happens when audio reaches the end
|
||||
audioElement.currentTime = audioElement.duration;
|
||||
|
||||
// With loop=true, browser automatically restarts
|
||||
const simulateLoopBehavior = () => {
|
||||
if (audioElement.loop && !audioElement.paused && audioElement.currentTime >= audioElement.duration) {
|
||||
audioElement.currentTime = 0; // Browser resets to start
|
||||
return true; // Indicates loop occurred
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Test loop behavior
|
||||
const looped = simulateLoopBehavior();
|
||||
|
||||
expect(audioElement.loop).toBe(true);
|
||||
expect(looped).toBe(true);
|
||||
expect(audioElement.currentTime).toBe(0); // Should be reset to start
|
||||
});
|
||||
|
||||
it('should verify loop property is essential for continuous playback', () => {
|
||||
const audioWithLoop = new MockAudio() as any;
|
||||
const audioWithoutLoop = new MockAudio() as any;
|
||||
|
||||
// Setup both audio elements
|
||||
audioWithLoop.loop = true;
|
||||
audioWithoutLoop.loop = false;
|
||||
|
||||
audioWithLoop.duration = 10;
|
||||
audioWithoutLoop.duration = 10;
|
||||
|
||||
// Both start playing
|
||||
audioWithLoop.paused = false;
|
||||
audioWithoutLoop.paused = false;
|
||||
|
||||
// Both reach the end
|
||||
audioWithLoop.currentTime = audioWithLoop.duration;
|
||||
audioWithoutLoop.currentTime = audioWithoutLoop.duration;
|
||||
|
||||
// Simulate end behavior
|
||||
const testLooping = (audio: any) => {
|
||||
if (audio.currentTime >= audio.duration) {
|
||||
if (audio.loop) {
|
||||
audio.currentTime = 0; // Loop back to start
|
||||
return 'looped';
|
||||
} else {
|
||||
audio.paused = true; // Stop playing
|
||||
return 'stopped';
|
||||
}
|
||||
}
|
||||
return 'playing';
|
||||
};
|
||||
|
||||
const resultWithLoop = testLooping(audioWithLoop);
|
||||
const resultWithoutLoop = testLooping(audioWithoutLoop);
|
||||
|
||||
expect(resultWithLoop).toBe('looped');
|
||||
expect(resultWithoutLoop).toBe('stopped');
|
||||
expect(audioWithLoop.currentTime).toBe(0); // Reset to start
|
||||
expect(audioWithoutLoop.paused).toBe(true); // Stopped
|
||||
});
|
||||
|
||||
it('should test the actual module behavior', async () => {
|
||||
// Import fresh module
|
||||
const { initializeBackgroundAudio, startBackgroundAudio, stopBackgroundAudio } = await import('./backgroundAudio');
|
||||
|
||||
// Test initialization doesn't throw
|
||||
expect(() => initializeBackgroundAudio()).not.toThrow();
|
||||
|
||||
// Test double initialization protection
|
||||
expect(() => initializeBackgroundAudio()).toThrow('Attempted to init audio twice.');
|
||||
});
|
||||
|
||||
it('should demonstrate loop property importance with realistic scenario', () => {
|
||||
// This test demonstrates why loop=true is critical for background music
|
||||
const backgroundTrack = new MockAudio() as any;
|
||||
|
||||
// Our code sets this to true
|
||||
backgroundTrack.loop = true;
|
||||
backgroundTrack.volume = 0.4;
|
||||
backgroundTrack.src = './sounds/background_ambience.mp3';
|
||||
backgroundTrack.duration = 30; // 30 second ambient track
|
||||
|
||||
// Start playing
|
||||
backgroundTrack.paused = false;
|
||||
backgroundTrack.currentTime = 0;
|
||||
|
||||
// Simulate game running for longer than track duration
|
||||
const gameRuntime = 90; // 90 seconds
|
||||
const timeStep = 1; // 1 second steps
|
||||
|
||||
let currentGameTime = 0;
|
||||
let trackRestarts = 0;
|
||||
|
||||
while (currentGameTime < gameRuntime) {
|
||||
currentGameTime += timeStep;
|
||||
backgroundTrack.currentTime += timeStep;
|
||||
|
||||
// Check if track ended and needs to loop
|
||||
if (backgroundTrack.currentTime >= backgroundTrack.duration) {
|
||||
if (backgroundTrack.loop) {
|
||||
backgroundTrack.currentTime = 0; // Restart
|
||||
trackRestarts++;
|
||||
} else {
|
||||
backgroundTrack.paused = true; // Would stop without loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With a 30-second track and 90-second game, we expect 3 restarts (0-30, 30-60, 60-90)
|
||||
expect(backgroundTrack.loop).toBe(true);
|
||||
expect(trackRestarts).toBe(3);
|
||||
expect(backgroundTrack.paused).toBe(false); // Still playing
|
||||
expect(currentGameTime).toBe(gameRuntime); // Game completed full duration
|
||||
});
|
||||
});
|
||||
|
|
@ -32,7 +32,7 @@ vi.mock('../gameState', () => {
|
|||
start: vi.fn(),
|
||||
stop: vi.fn()
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
gameState: {
|
||||
isPlaying: false,
|
||||
|
|
@ -91,7 +91,7 @@ Object.defineProperty(global, 'document', {
|
|||
});
|
||||
|
||||
// Import after all mocking
|
||||
import { showTwoPowerConversation, closeTwoPowerConversation } from './twoPowerConversation';
|
||||
import { showMomentModal, closeMomentModal } from './momentModal';
|
||||
|
||||
// Get direct reference to the mocked gameState
|
||||
let gameState: any;
|
||||
|
|
@ -101,13 +101,13 @@ describe('twoPowerConversation', () => {
|
|||
// 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 = [];
|
||||
|
|
@ -116,9 +116,9 @@ describe('twoPowerConversation', () => {
|
|||
describe('showTwoPowerConversation', () => {
|
||||
it('should throw error when no messages found (indicates data quality issue)', () => {
|
||||
gameState.isPlaying = true;
|
||||
|
||||
|
||||
expect(() => {
|
||||
showTwoPowerConversation({
|
||||
showMomentModal({
|
||||
power1: 'FRANCE',
|
||||
power2: 'GERMANY'
|
||||
});
|
||||
|
|
@ -127,10 +127,10 @@ describe('twoPowerConversation', () => {
|
|||
|
||||
it('should throw error when empty messages array provided', () => {
|
||||
gameState.isPlaying = false;
|
||||
|
||||
|
||||
expect(() => {
|
||||
showTwoPowerConversation({
|
||||
power1: 'FRANCE',
|
||||
showMomentModal({
|
||||
power1: 'FRANCE',
|
||||
power2: 'GERMANY',
|
||||
messages: []
|
||||
});
|
||||
|
|
@ -143,14 +143,14 @@ describe('twoPowerConversation', () => {
|
|||
{ sender: 'FRANCE', recipient: 'GERMANY', message: 'Hello', time_sent: '1' }
|
||||
];
|
||||
|
||||
showTwoPowerConversation({
|
||||
showMomentModal({
|
||||
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);
|
||||
});
|
||||
|
|
@ -163,24 +163,24 @@ describe('twoPowerConversation', () => {
|
|||
{ sender: 'FRANCE', recipient: 'GERMANY', message: 'Test', time_sent: '1' }
|
||||
];
|
||||
|
||||
showTwoPowerConversation({
|
||||
showMomentModal({
|
||||
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({
|
||||
showMomentModal({
|
||||
power1: 'FRANCE',
|
||||
power2: 'GERMANY',
|
||||
messages: []
|
||||
|
|
@ -188,4 +188,4 @@ describe('twoPowerConversation', () => {
|
|||
}).toThrow('High-interest moment detected between FRANCE and GERMANY but no messages found');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,8 +4,9 @@ import { getPowerDisplayName } from '../utils/powerNames';
|
|||
import { PowerENUM } from '../types/map';
|
||||
import { Moment } from '../types/moments';
|
||||
import { Message } from '../types/gameState';
|
||||
import { ScheduledEvent } from '../events.ts'
|
||||
|
||||
interface TwoPowerDialogueOptions {
|
||||
interface MomentDialogueOptions {
|
||||
moment: Moment;
|
||||
power1?: PowerENUM;
|
||||
power2?: PowerENUM;
|
||||
|
|
@ -20,11 +21,11 @@ let dialogueOverlay: HTMLElement | null = null;
|
|||
* Shows a dialogue box displaying conversation between two powers
|
||||
* @param options Configuration for the dialogue display
|
||||
*/
|
||||
export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void {
|
||||
export function showMomentModal(options: MomentDialogueOptions): void {
|
||||
const { moment, power1, power2, title, onClose } = options;
|
||||
|
||||
// Close any existing dialogue
|
||||
closeTwoPowerConversation();
|
||||
closeMomentModal();
|
||||
|
||||
if (moment.raw_messages.length === 0) {
|
||||
throw new Error(
|
||||
|
|
@ -36,6 +37,16 @@ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void
|
|||
showConversationModalSequence(title, moment, onClose);
|
||||
}
|
||||
|
||||
export function createMomentEvent(moment: Moment): ScheduledEvent {
|
||||
return {
|
||||
id: `moment-${moment.phase}`,
|
||||
callback: () => showMomentModal({ moment }),
|
||||
triggerAtTime
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the conversation modal and sequences all messages through the event queue
|
||||
*/
|
||||
|
|
@ -100,7 +111,7 @@ function scheduleMessageSequence(
|
|||
// Schedule conversation close after all messages are shown
|
||||
gameState.eventQueue.scheduleDelay(config.conversationFinalDelay, () => {
|
||||
console.log('Closing two-power conversation and calling onClose callback');
|
||||
closeTwoPowerConversation();
|
||||
closeMomentModal();
|
||||
if (callbackOnClose) callbackOnClose();
|
||||
}, `close-conversation-after-messages-${Date.now()}`);
|
||||
return;
|
||||
|
|
@ -162,7 +173,7 @@ function displaySingleMessage(
|
|||
* Closes the two-power conversation dialogue
|
||||
* @param immediate If true, removes overlay immediately without animation
|
||||
*/
|
||||
export function closeTwoPowerConversation(immediate: boolean = false): void {
|
||||
export function closeMomentModal(immediate: boolean = false): void {
|
||||
dialogueOverlay = document.getElementById("dialogue-overlay")
|
||||
if (dialogueOverlay) {
|
||||
if (immediate) {
|
||||
|
|
@ -214,9 +225,7 @@ function createDialogueContainer(power1: string, power2: string, title?: string,
|
|||
const container = document.createElement('div');
|
||||
container.className = 'dialogue-container';
|
||||
container.style.cssText = `
|
||||
background: radial-gradient(ellipse at center, #f7ecd1 0%, #dbc08c 100%);
|
||||
border: 3px solid #4f3b16;
|
||||
border-radius: 8px;
|
||||
background: radial-gradient(ellipse at center, #f7ecd1 0%, #dbc08w8px;
|
||||
box-shadow: 0 0 15px rgba(0,0,0,0.5);
|
||||
width: 90%;
|
||||
height: 85%;
|
||||
|
|
@ -466,7 +475,7 @@ function setupEventListeners(onClose?: () => void): void {
|
|||
|
||||
const closeButton = dialogueOverlay.querySelector('.close-button');
|
||||
const handleClose = () => {
|
||||
closeTwoPowerConversation(true); // immediate close for manual actions
|
||||
closeMomentModal(true); // immediate close for manual actions
|
||||
onClose?.();
|
||||
|
||||
// When manually closed, still advance phase if playing
|
||||
53
ai_animation/src/components/newsBanner.ts
Normal file
53
ai_animation/src/components/newsBanner.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { gameState } from "../gameState";
|
||||
import { config } from "../config";
|
||||
import { GamePhase } from "../types/gameState";
|
||||
import { ScheduledEvent } from "../events";
|
||||
|
||||
|
||||
const bannerEl = document.getElementById('news-banner-content');
|
||||
if (!bannerEl) throw Error("News banner not properly initialized")
|
||||
|
||||
export function createUpdateNewsBannerEvent(phase: GamePhase): ScheduledEvent {
|
||||
return { id: `updateNewsBanner-${phase.name}`, callback: () => addToNewsBanner(phase.summary) }
|
||||
}
|
||||
|
||||
function clearNewsBanner() {
|
||||
bannerEl.textContent = ''
|
||||
}
|
||||
/**
|
||||
* Appends text to the scrolling news banner.
|
||||
* If the banner is at its default text or empty, replace it entirely.
|
||||
* Otherwise, just append " | " + newText.
|
||||
* @param newText Text to add to the news banner
|
||||
*/
|
||||
function addToNewsBanner(newText: string): void {
|
||||
if (!bannerEl) {
|
||||
console.warn("News banner element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.isDebugMode) {
|
||||
console.log(`Adding to news banner: "${newText}"`);
|
||||
}
|
||||
|
||||
// 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.uiFadeDelay, () => {
|
||||
// If the banner only has the default text or is empty, replace it
|
||||
if (
|
||||
bannerEl.textContent?.trim() === 'Diplomatic actions unfolding...' ||
|
||||
bannerEl.textContent?.trim() === ''
|
||||
) {
|
||||
bannerEl.textContent = newText;
|
||||
} else {
|
||||
// Otherwise append with a separator
|
||||
bannerEl.textContent += ' | ' + newText;
|
||||
}
|
||||
|
||||
// Fade back in
|
||||
bannerEl.style.opacity = '1';
|
||||
}, `banner-fade-in-${Date.now()}`);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { gameState } from "../gameState";
|
||||
import { PowerENUM } from "../types/map";
|
||||
import { GameSchemaType } from "../types/gameState";
|
||||
import { ScheduledEvent } from "../events";
|
||||
|
||||
// Enum for the different display types
|
||||
export enum DisplayType {
|
||||
|
|
@ -104,15 +105,14 @@ function rotateToNextDisplay(): void {
|
|||
* @param currentPlayerPower The power the current player is controlling
|
||||
* @param forceUpdate Whether to force a full update even if the display type hasn't changed
|
||||
*/
|
||||
export function updateRotatingDisplay(
|
||||
export function createUpdateRotatingDisplayEvent(
|
||||
gameData: GameSchemaType,
|
||||
currentPhaseIndex: number,
|
||||
currentPlayerPower: PowerENUM,
|
||||
forceUpdate: boolean = false
|
||||
): void {
|
||||
): ScheduledEvent {
|
||||
if (!isInitialized || !containerElement) {
|
||||
console.warn("Rotating display not initialized");
|
||||
return;
|
||||
throw Error("Rotating display is not initialized and cannot have an event created")
|
||||
}
|
||||
|
||||
// Check if we need to do a full re-render
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { gameState } from '../gameState';
|
||||
import { showTwoPowerConversation } from '../components/twoPowerConversation';
|
||||
import { showMomentModal } from '../components/momentModal';
|
||||
import { Moment } from '../types/moments';
|
||||
import { _setPhase } from '../phase';
|
||||
import { config } from '../config';
|
||||
|
|
@ -33,11 +33,11 @@ export function initShowRandomMomentTool(debugMenu: any) {
|
|||
// Add button functionality
|
||||
const showBtn = document.getElementById('debug-show-random-moment');
|
||||
const refreshBtn = document.getElementById('debug-refresh-moment-list');
|
||||
|
||||
|
||||
if (showBtn) {
|
||||
showBtn.addEventListener('click', showRandomMoment);
|
||||
}
|
||||
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', updateMomentStatus);
|
||||
}
|
||||
|
|
@ -89,21 +89,21 @@ function getEligibleMoments(): Moment[] {
|
|||
for (let j = i + 1; j < moment.powers_involved.length; j++) {
|
||||
const power1 = moment.powers_involved[i].toUpperCase();
|
||||
const power2 = moment.powers_involved[j].toUpperCase();
|
||||
|
||||
|
||||
const hasConversation = phase.messages.some(msg => {
|
||||
const sender = msg.sender?.toUpperCase();
|
||||
const recipient = msg.recipient?.toUpperCase();
|
||||
|
||||
|
||||
return (sender === power1 && recipient === power2) ||
|
||||
(sender === power2 && recipient === power1);
|
||||
(sender === power2 && recipient === power1);
|
||||
});
|
||||
|
||||
|
||||
if (hasConversation) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
|
@ -126,21 +126,21 @@ function findBestPowerPairForMoment(moment: Moment): { power1: string; power2: s
|
|||
for (let j = i + 1; j < moment.powers_involved.length; j++) {
|
||||
const power1 = moment.powers_involved[i].toUpperCase();
|
||||
const power2 = moment.powers_involved[j].toUpperCase();
|
||||
|
||||
|
||||
const messageCount = phase.messages.filter(msg => {
|
||||
const sender = msg.sender?.toUpperCase();
|
||||
const recipient = msg.recipient?.toUpperCase();
|
||||
|
||||
|
||||
return (sender === power1 && recipient === power2) ||
|
||||
(sender === power2 && recipient === power1);
|
||||
(sender === power2 && recipient === power1);
|
||||
}).length;
|
||||
|
||||
|
||||
if (messageCount > 0 && (!bestPair || messageCount > bestPair.messageCount)) {
|
||||
bestPair = { power1, power2, messageCount };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return bestPair;
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ function findBestPowerPairForMoment(moment: Moment): { power1: string; power2: s
|
|||
*/
|
||||
function showRandomMoment() {
|
||||
const eligibleMoments = getEligibleMoments();
|
||||
|
||||
|
||||
if (eligibleMoments.length === 0) {
|
||||
console.warn('No eligible moments found for conversation display');
|
||||
const statusElement = document.getElementById('debug-moment-status');
|
||||
|
|
@ -166,10 +166,10 @@ function showRandomMoment() {
|
|||
|
||||
// Pick a random moment
|
||||
const randomMoment = eligibleMoments[Math.floor(Math.random() * eligibleMoments.length)];
|
||||
|
||||
|
||||
// Find the best power pair for this moment
|
||||
const powerPair = findBestPowerPairForMoment(randomMoment);
|
||||
|
||||
|
||||
if (!powerPair) {
|
||||
console.warn('No valid power pair found for selected moment');
|
||||
return;
|
||||
|
|
@ -181,15 +181,15 @@ function showRandomMoment() {
|
|||
|
||||
// Find the phase index for this moment
|
||||
const phaseIndex = gameState.gameData.phases.findIndex(p => p.name === randomMoment.phase);
|
||||
|
||||
|
||||
if (phaseIndex === -1) {
|
||||
const errorMsg = `CRITICAL ERROR: Phase ${randomMoment.phase} from moment data not found in game data! This indicates a serious data integrity issue.`;
|
||||
console.error(errorMsg);
|
||||
|
||||
|
||||
if (config.isDebugMode) {
|
||||
alert(errorMsg + '\n\nAvailable phases: ' + gameState.gameData.phases.map(p => p.name).join(', '));
|
||||
}
|
||||
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ function showRandomMoment() {
|
|||
_setPhase(phaseIndex);
|
||||
|
||||
// Show the moment using the two-power conversation display
|
||||
showTwoPowerConversation({
|
||||
showMomentModal({
|
||||
power1: powerPair.power1,
|
||||
power2: powerPair.power2,
|
||||
moment: randomMoment,
|
||||
|
|
@ -215,4 +215,4 @@ function showRandomMoment() {
|
|||
statusElement.textContent = `Showing: ${randomMoment.category}`;
|
||||
statusElement.style.color = '#4dabf7';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { ScheduledEvent } from "./events";
|
||||
import { gameState } from "./gameState";
|
||||
import { logger } from "./logger";
|
||||
import { GamePhase } from "./types/gameState";
|
||||
|
||||
/**
|
||||
* Helper function to get a DOM element by ID and throw an error if not found
|
||||
|
|
@ -29,32 +31,31 @@ function getRequiredTypedElement<T extends HTMLElement>(id: string): T {
|
|||
return element;
|
||||
}
|
||||
|
||||
export function updatePhaseDisplay() {
|
||||
const currentPhase = gameState.gameData.phases[gameState.phaseIndex];
|
||||
export function createUpdateUIEvent(phase: GamePhase): ScheduledEvent {
|
||||
|
||||
// Add fade-out effect
|
||||
phaseDisplay.style.transition = 'opacity 0.3s ease-out';
|
||||
phaseDisplay.style.opacity = '0';
|
||||
|
||||
// Update text after fade-out
|
||||
gameState.eventQueue.scheduleDelay(300, () => {
|
||||
phaseDisplay.textContent = `Era: ${currentPhase.name || 'Unknown Era'}`;
|
||||
let callback = () => {
|
||||
phaseDisplay.textContent = `Era: ${phase.name || 'Unknown Era'}`;
|
||||
// Fade back in
|
||||
phaseDisplay.style.opacity = '1';
|
||||
}, `phase-display-update-${Date.now()}`);
|
||||
}
|
||||
return { id: `updateUI-${phase.name}`, callback }
|
||||
}
|
||||
|
||||
|
||||
export function updateGameIdDisplay() {
|
||||
// Add fade-out effect
|
||||
gameIdDisplay.style.transition = 'opacity 0.3s ease-out';
|
||||
gameIdDisplay.style.opacity = '0';
|
||||
|
||||
// Update text after fade-out
|
||||
gameState.eventQueue.scheduleDelay(300, () => {
|
||||
gameIdDisplay.textContent = `Game: ${gameState.gameId}`;
|
||||
// Fade back in
|
||||
gameIdDisplay.style.opacity = '1';
|
||||
}, `game-id-display-update-${Date.now()}`);
|
||||
gameIdDisplay.textContent = `Game: ${gameState.gameId}`;
|
||||
// Fade back in
|
||||
gameIdDisplay.style.opacity = '1';
|
||||
}
|
||||
|
||||
export function loadGameBtnFunction(file: File) {
|
||||
|
|
|
|||
|
|
@ -676,41 +676,3 @@ function playRandomSoundEffect() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends text to the scrolling news banner.
|
||||
* If the banner is at its default text or empty, replace it entirely.
|
||||
* Otherwise, just append " | " + newText.
|
||||
* @param newText Text to add to the news banner
|
||||
*/
|
||||
export function addToNewsBanner(newText: string): void {
|
||||
const bannerEl = document.getElementById('news-banner-content');
|
||||
if (!bannerEl) {
|
||||
console.warn("News banner element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.isDebugMode) {
|
||||
console.log(`Adding to news banner: "${newText}"`);
|
||||
}
|
||||
|
||||
// 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.uiFadeDelay, () => {
|
||||
// If the banner only has the default text or is empty, replace it
|
||||
if (
|
||||
bannerEl.textContent?.trim() === 'Diplomatic actions unfolding...' ||
|
||||
bannerEl.textContent?.trim() === ''
|
||||
) {
|
||||
bannerEl.textContent = newText;
|
||||
} else {
|
||||
// Otherwise append with a separator
|
||||
bannerEl.textContent += ' | ' + newText;
|
||||
}
|
||||
|
||||
// Fade back in
|
||||
bannerEl.style.opacity = '1';
|
||||
}, `banner-fade-in-${Date.now()}`);
|
||||
}
|
||||
|
|
|
|||
77
ai_animation/src/events.ts
Normal file
77
ai_animation/src/events.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Event queue system for deterministic animations
|
||||
*/
|
||||
|
||||
export interface ScheduledEvent {
|
||||
id: string;
|
||||
callback: () => void;
|
||||
resolved?: boolean;
|
||||
error?: Error; // If the event caused an error, store it here.
|
||||
}
|
||||
|
||||
export class EventQueue {
|
||||
private events: ScheduledEvent[] = [];
|
||||
private startTime: number = 0;
|
||||
private isRunning: boolean = false;
|
||||
|
||||
/**
|
||||
* Start the event queue with current time as reference
|
||||
*/
|
||||
start(): void {
|
||||
this.isRunning = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the event queue
|
||||
*/
|
||||
stop(): void {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the event queue, clearing all events
|
||||
* @param resetCallback Optional callback to run after reset (for cleanup)
|
||||
*/
|
||||
reset(resetCallback?: () => void): void {
|
||||
this.events = [];
|
||||
this.isRunning = false;
|
||||
if (resetCallback) {
|
||||
resetCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the queue
|
||||
*/
|
||||
schedule(event: ScheduledEvent): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the event queue, triggering events that are ready
|
||||
*/
|
||||
update(): void {
|
||||
if (!this.isRunning) return;
|
||||
if (this.events.length < 1) return;
|
||||
|
||||
if (this.events[0].resolved) {
|
||||
this.events.shift()
|
||||
this.events[0].callback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining events count
|
||||
*/
|
||||
get pendingCount(): number {
|
||||
return this.events.filter(e => !e.resolved).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events (for debugging)
|
||||
*/
|
||||
get allEvents(): ScheduledEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
}
|
||||
|
|
@ -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, updatePhaseDisplay } from "./domElements";
|
||||
import { prevBtn, nextBtn, playBtn, speedSelector, mapView, updateGameIdDisplay, createUpdateUIEvent } from "./domElements";
|
||||
import { createChatWindows } from "./domElements/chatWindows";
|
||||
import { logger } from "./logger";
|
||||
import { OrbitControls } from "three/examples/jsm/Addons.js";
|
||||
|
|
@ -13,7 +13,8 @@ import { Tween, Group as TweenGroup } from "@tweenjs/tween.js";
|
|||
import { MomentsDataSchema, Moment, NormalizedMomentsData } from "./types/moments";
|
||||
import { updateLeaderboard } from "./components/leaderboard";
|
||||
import { closeVictoryModal } from "./components/victoryModal.ts";
|
||||
import { EventQueue } from "./types/events";
|
||||
import { EventQueue } from "./events.ts";
|
||||
import { createAnimationsForNextPhase } from "./units/animate.ts";
|
||||
|
||||
//FIXME: This whole file is a mess. Need to organize and format
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ function loadFileFromServer(filePath: string): Promise<string> {
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
function initializeBackgroundAudio(): Audio {
|
||||
|
||||
// Create audio element
|
||||
|
|
@ -185,12 +187,37 @@ class GameState {
|
|||
}
|
||||
set phaseIndex(val: number) {
|
||||
this._phaseIndex = val
|
||||
updatePhaseDisplay()
|
||||
}
|
||||
get phaseIndex() {
|
||||
return this._phaseIndex
|
||||
}
|
||||
|
||||
_fillEventQueue = (gameData: GameSchemaType) => {
|
||||
for (let phase of gameData.phases) {
|
||||
// Update Phase Display
|
||||
let updateUIEvent = createUpdateUIEvent(phase)
|
||||
this.eventQueue.schedule(updateUIEvent)
|
||||
|
||||
// News Banner Text
|
||||
this.eventQueue.schedule(createNewsBannerUpdateEvent(phase))
|
||||
// Narrator Audio
|
||||
this.eventQueue.schedule(createNarratorAudioEvent(phase))
|
||||
// Messages play first
|
||||
let messageEvents = createMessageEvents(phase)
|
||||
this.eventQueue.schedule(messageEvents)
|
||||
|
||||
// Check if there is a moment to display
|
||||
let phaseMoment = this.checkPhaseHasMoment(phase.name)
|
||||
if (phaseMoment) {
|
||||
this.eventQueue.schedule(createMomentEvent(phaseMoment))
|
||||
}
|
||||
let animationEvents = createAnimationsForNextPhase(phase)
|
||||
this.eventQueue.schedule(animationEvents)
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
/**
|
||||
* Load game data from a JSON string and initialize the game state
|
||||
* @param gameDataString JSON string containing game data
|
||||
|
|
@ -355,7 +382,7 @@ class GameState {
|
|||
// Ensure any open overlays are cleaned up before loading next game
|
||||
if (this.isDisplayingMoment) {
|
||||
// Import at runtime to avoid circular dependency
|
||||
import('./components/twoPowerConversation').then(({ closeTwoPowerConversation }) => {
|
||||
import('./components/momentModal.ts').then(({ closeMomentModal: closeTwoPowerConversation }) => {
|
||||
closeTwoPowerConversation(true);
|
||||
});
|
||||
}
|
||||
|
|
@ -367,11 +394,6 @@ class GameState {
|
|||
}
|
||||
this.loadGameFile(gameId).then(() => {
|
||||
gameState.gameId = gameId
|
||||
if (contPlaying) {
|
||||
this.eventQueue.scheduleDelay(config.victoryModalDisplayMs, () => {
|
||||
togglePlayback(true)
|
||||
}, `load-next-game-playback-${Date.now()}`)
|
||||
}
|
||||
}).catch(() => {
|
||||
console.warn("caught error trying to advance game. Setting gameId to 0 and restarting...")
|
||||
this.loadGameFile(0)
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ function initScene() {
|
|||
gameState.loadBoardState().then(() => {
|
||||
initMap(gameState.scene).then(() => {
|
||||
|
||||
// TODO: Re-add the rotating display
|
||||
//
|
||||
// Initialize rotating display
|
||||
initRotatingDisplay();
|
||||
//initRotatingDisplay();
|
||||
|
||||
gameState.cameraPanAnim = createCameraPan()
|
||||
|
||||
|
|
@ -68,11 +70,7 @@ function initScene() {
|
|||
debugMenuInstance.show();
|
||||
}
|
||||
if (isStreamingMode) {
|
||||
|
||||
gameState.eventQueue.start();
|
||||
gameState.eventQueue.scheduleDelay(2000, () => {
|
||||
togglePlayback()
|
||||
}, `streaming-mode-start-${Date.now()}`)
|
||||
}
|
||||
})
|
||||
}).catch(err => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { gameState } from "./gameState";
|
||||
import { logger } from "./logger";
|
||||
import { updatePhaseDisplay, playBtn, prevBtn, nextBtn } from "./domElements";
|
||||
import { playBtn, prevBtn, nextBtn } from "./domElements";
|
||||
import { initUnits } from "./units/create";
|
||||
import { updateSupplyCenterOwnership, updateMapOwnership as _updateMapOwnership, updateMapOwnership } from "./map/state";
|
||||
import { updateChatWindows, addToNewsBanner } from "./domElements/chatWindows";
|
||||
import { updateChatWindows } from "./domElements/chatWindows";
|
||||
import { createUpdateNewsBannerEvent } from "./components/newsBanner";
|
||||
import { createAnimationsForNextPhase } from "./units/animate";
|
||||
import { speakSummary } from "./speech";
|
||||
import { config } from "./config";
|
||||
import { debugMenuInstance } from "./debug/debugMenu";
|
||||
import { showTwoPowerConversation, closeTwoPowerConversation } from "./components/twoPowerConversation";
|
||||
import { showMomentModal, closeMomentModal } from "./components/momentModal";
|
||||
import { closeVictoryModal, showVictoryModal } from "./components/victoryModal";
|
||||
import { notifyPhaseChange } from "./webhooks/phaseNotifier";
|
||||
import { updateLeaderboard } from "./components/leaderboard";
|
||||
|
|
@ -91,7 +92,6 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) {
|
|||
playBtn.textContent = "⏸ Pause";
|
||||
prevBtn.disabled = true;
|
||||
nextBtn.disabled = true;
|
||||
logger.log("Starting playback...");
|
||||
|
||||
// Start background audio when playback starts
|
||||
gameState.audio.play();
|
||||
|
|
@ -101,36 +101,19 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) {
|
|||
|
||||
if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[1].start()
|
||||
|
||||
// First, show the messages of the current phase if it's the initial playback
|
||||
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}`);
|
||||
displayPhase()
|
||||
} else {
|
||||
// No messages, go straight to unit animations
|
||||
logger.log("No messages for this phase, proceeding to animations");
|
||||
}
|
||||
} else {
|
||||
if (gameState.cameraPanAnim) gameState.cameraPanAnim.getAll()[0].pause();
|
||||
playBtn.textContent = "▶ Play";
|
||||
gameState.audio.pause()
|
||||
// (playbackTimer is replaced by event queue system)
|
||||
|
||||
// Stop background audio when pausing
|
||||
gameState.audio.pause();
|
||||
|
||||
// Ensure any open two-power conversations are closed when pausing
|
||||
closeTwoPowerConversation(true); // immediate = true
|
||||
closeMomentModal(true); // immediate = true
|
||||
|
||||
// Stop and reset event queue when pausing with cleanup
|
||||
gameState.eventQueue.stop();
|
||||
gameState.eventQueue.reset(() => {
|
||||
// Ensure proper state cleanup when events are canceled
|
||||
gameState.messagesPlaying = false;
|
||||
gameState.isAnimating = false;
|
||||
});
|
||||
|
||||
gameState.messagesPlaying = false;
|
||||
prevBtn.disabled = false;
|
||||
nextBtn.disabled = false;
|
||||
}
|
||||
|
|
@ -138,10 +121,12 @@ export function togglePlayback(explicitSet: boolean | undefined = undefined) {
|
|||
|
||||
|
||||
export function scheduleNextPhase() {
|
||||
throw new Error("Function is deprecated")
|
||||
gameState.eventQueue.scheduleDelay(0, nextPhase)
|
||||
}
|
||||
|
||||
export function scheduleSummarySpeech() {
|
||||
throw new Error("Function is deprecated")
|
||||
// Delay speech in streaming mode
|
||||
gameState.eventQueue.scheduleDelay(config.speechDelay, () => {
|
||||
// Speak the summary and advance after
|
||||
|
|
@ -159,15 +144,10 @@ export function nextPhase() {
|
|||
const power1 = moment.powers_involved[0];
|
||||
const power2 = moment.powers_involved[1];
|
||||
|
||||
showTwoPowerConversation({
|
||||
showMomentModal({
|
||||
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 {
|
||||
// No conversation to show, proceed with normal flow
|
||||
|
|
@ -216,13 +196,10 @@ export function displayPhase(skipMessages = false) {
|
|||
|
||||
|
||||
// Update UI elements with smooth transitions
|
||||
updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower, true);
|
||||
// TODO: Re-add the rotatingDisplay. Removed it as it won't fit in the eventQueue.
|
||||
// updateRotatingDisplay(gameState.gameData, gameState.phaseIndex, gameState.currentPower, true);
|
||||
_updateMapOwnership();
|
||||
|
||||
// Add phase info to news banner if not already there
|
||||
const phaseBannerText = `Phase: ${currentPhase.name}: ${currentPhase.summary}`;
|
||||
addToNewsBanner(phaseBannerText);
|
||||
|
||||
// Log phase details to console only, don't update info panel with this
|
||||
const phaseInfo = `Phase: ${currentPhase.name}\nSCs: ${currentPhase.state?.centers ? JSON.stringify(currentPhase.state.centers) : 'None'}\nUnits: ${currentPhase.state?.units ? JSON.stringify(currentPhase.state.units) : 'None'}`;
|
||||
console.log(phaseInfo); // Use console.log instead of logger.log
|
||||
|
|
@ -231,7 +208,7 @@ export function displayPhase(skipMessages = false) {
|
|||
updateLeaderboard();
|
||||
|
||||
// Show messages with animation or immediately based on skipMessages flag
|
||||
updateChatWindows(true, scheduleNextPhase);
|
||||
//updateChatWindows(true, scheduleNextPhase);
|
||||
|
||||
// Only animate if not the first phase and animations are requested
|
||||
if (!isFirstPhase && !skipMessages) {
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
/**
|
||||
* Event queue system for deterministic animations
|
||||
*/
|
||||
|
||||
export interface ScheduledEvent {
|
||||
id: string;
|
||||
triggerAtTime: number; // Relative to startTime in seconds
|
||||
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 {
|
||||
private events: ScheduledEvent[] = [];
|
||||
private startTime: number = 0;
|
||||
private isRunning: boolean = false;
|
||||
|
||||
/**
|
||||
* Start the event queue with current time as reference
|
||||
*/
|
||||
start(): void {
|
||||
this.startTime = performance.now();
|
||||
this.isRunning = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the event queue
|
||||
*/
|
||||
stop(): void {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the event queue, clearing all events
|
||||
* @param resetCallback Optional callback to run after reset (for cleanup)
|
||||
*/
|
||||
reset(resetCallback?: () => void): void {
|
||||
this.events = [];
|
||||
this.isRunning = false;
|
||||
if (resetCallback) {
|
||||
resetCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the queue
|
||||
*/
|
||||
schedule(event: ScheduledEvent): void {
|
||||
this.events.push(event);
|
||||
// Keep events sorted by trigger time, then by priority
|
||||
this.events.sort((a, b) => {
|
||||
if (a.triggerAtTime === b.triggerAtTime) {
|
||||
return (b.priority || 0) - (a.priority || 0);
|
||||
}
|
||||
return a.triggerAtTime - b.triggerAtTime;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove resolved events from the queue
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.events = this.events.filter(e => !e.resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the event queue, triggering events that are ready
|
||||
*/
|
||||
update(): void {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
const now = performance.now();
|
||||
const elapsed = now - this.startTime;
|
||||
|
||||
for (const event of this.events) {
|
||||
if (!event.resolved && elapsed >= event.triggerAtTime) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up resolved events periodically
|
||||
if (this.events.length > 0 && this.events.every(e => e.resolved)) {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining events count
|
||||
*/
|
||||
get pendingCount(): number {
|
||||
return this.events.filter(e => !e.resolved).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events (for debugging)
|
||||
*/
|
||||
get allEvents(): ScheduledEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a simple delay callback (like setTimeout)
|
||||
*/
|
||||
scheduleDelay(delayMs: number, callback: () => void, id?: string): void {
|
||||
const now = performance.now();
|
||||
const elapsed = this.isRunning ? now - this.startTime : 0;
|
||||
this.schedule({
|
||||
id: id || `delay-${Date.now()}-${Math.random()}`,
|
||||
triggerAtTime: elapsed + (delayMs), // Schedule relative to current time
|
||||
callback
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a recurring event (like setInterval)
|
||||
* Returns a function to cancel the recurring event
|
||||
*/
|
||||
scheduleRecurring(intervalMs: number, callback: () => void, id?: string): () => void {
|
||||
let counter = 0;
|
||||
const baseId = id || `recurring-${Date.now()}`;
|
||||
const now = performance.now();
|
||||
const startElapsed = this.isRunning ? now - this.startTime : 0;
|
||||
|
||||
const scheduleNext = () => {
|
||||
counter++;
|
||||
this.schedule({
|
||||
id: `${baseId}-${counter}`,
|
||||
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
|
||||
this.events.forEach(event => {
|
||||
if (event.id.startsWith(baseId) && !event.resolved) {
|
||||
event.resolved = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue