import { afterEach, expect, test, vi } from "vitest"; import { defineModule, TIME_MANAGER_SYNC_KEY, withTimeManager } from "@rpgjs/common"; import { testing } from "@rpgjs/testing"; import { inject as diInject } from "@signe/di"; import { ClientTimeManager, RpgClient } from "../../client/src"; import { RpgPlayer, RpgServer, TimeManager, inject as injectServer } from "../src"; const serverModule = defineModule({ maps: [ { id: "map1", file: "", }, { id: "map2", file: "", }, ], player: { async onConnected(player: RpgPlayer) { await player.changeMap("map1", { x: 100, y: 100 }); }, }, }); const clientModule = defineModule({}); let fixture: any; const flushEnvironment = async () => { for (let index = 0; index < 8; index += 1) { await Promise.resolve(); } }; const createRuntimeMap = (id: string, events: Record[] = []) => ({ id, $broadcast: vi.fn(), applySyncToClient: vi.fn(), $applySync: vi.fn(), setWeather: vi.fn(), clearWeather: vi.fn(), setLighting: vi.fn(), getWeather: vi.fn(() => null), getLighting: vi.fn(() => null), getEvents: vi.fn(() => events), hooks: { getHookFunctions: vi.fn(() => []), }, setSync(definitions: Record) { for (const [key, definition] of Object.entries(definitions)) { let value = definition.$initial; const signal = (() => value) as (() => unknown) & { set: (next: unknown) => void }; signal.set = (next: unknown) => { value = next; }; (this as any)[key] = signal; } }, }); afterEach(() => { fixture?.clear(); vi.useRealTimers(); }); test("withTimeManager syncs map time snapshots and lets the client infer current time", async () => { const module = withTimeManager({ start: "0001-01-01 08:00", scale: 10, calendar: { months: 12, daysPerMonth: 30, daysPerWeek: 7, seasons: ["spring", "summer", "autumn", "winter"], }, }); fixture = await testing([ module, { server: serverModule, client: clientModule, }, ]); const client = await fixture.createClient(); let player = await client.waitForMapChange("map1"); await fixture.wait(0); await fixture.applySyncToClient(); const serverTime = injectServer(TimeManager); const clientTime = diInject(client.client.context, ClientTimeManager); const map = player.getCurrentMap() as any; expect(map[TIME_MANAGER_SYNC_KEY]()).toMatchObject({ scale: 10, paused: false, }); expect(map[TIME_MANAGER_SYNC_KEY]().elapsedMinutes).toBeCloseTo(480, 0); expect(clientTime.state()).toMatchObject({ year: 1, month: 1, day: 1, hour: 8, minute: 0, weekday: 0, season: "spring", }); serverTime.set({ hour: 22, minute: 30 }); await fixture.applySyncToClient(); expect(clientTime.state()).toMatchObject({ hour: 22, minute: 30, }); serverTime.advance({ hours: 2 }); await fixture.applySyncToClient(); expect(clientTime.state()).toMatchObject({ day: 2, hour: 0, minute: 30, }); serverTime.pause(); await fixture.applySyncToClient(); const pausedState = clientTime.state(Date.now())!; const laterPausedState = clientTime.state(Date.now() + 60_000)!; expect(laterPausedState.elapsedMinutes).toBe(pausedState.elapsedMinutes); serverTime.resume(); serverTime.setScale(30); await fixture.applySyncToClient(); const projectedState = clientTime.state(Date.now() + 60_000)!; expect(Math.floor(projectedState.elapsedMinutes - clientTime.state()!.elapsedMinutes)).toBe(30); await player.changeMap("map2", { x: 10, y: 10 }); player = await client.waitForMapChange("map2"); await fixture.wait(0); await fixture.applySyncToClient(); expect(player.getCurrentMap()?.id).toBe("map2"); expect(clientTime.state()).toMatchObject({ scale: 30, }); }); test("time manager applies initial lighting without broadcasting before map runtime is ready", async () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 07:00", lighting: { enabled: true, transitionMs: 900, phases: { dawn: { hour: 6, lighting: { ambient: { darkness: 0.2 }, sun: { intensity: 0.6 }, }, }, }, }, }); const setLighting = vi.fn(); const transitionLighting = vi.fn(); const map = { setSync(definitions: Record) { for (const [key, definition] of Object.entries(definitions)) { let value = definition.$initial; const signal = (() => value) as (() => unknown) & { set: (next: unknown) => void }; signal.set = (next: unknown) => { value = next; }; (this as any)[key] = signal; } }, setLighting, transitionLighting, }; expect(() => timeManager.registerMap(map as any)).not.toThrow(); await flushEnvironment(); expect(transitionLighting).not.toHaveBeenCalled(); expect(setLighting).toHaveBeenCalledWith(expect.any(Object), { sync: false }); }); test("time manager refreshes lighting when projected time crosses a phase", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); try { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 07:59", scale: 60, lighting: { enabled: true, phases: { dawn: { hour: 6, lighting: { ambient: { darkness: 0.2 }, }, }, day: { hour: 8, lighting: { ambient: { darkness: 0 }, }, }, }, }, }); const setLighting = vi.fn(); const map = { $broadcast: vi.fn(), applySyncToClient: vi.fn(), $applySync: vi.fn(), setSync(definitions: Record) { for (const [key, definition] of Object.entries(definitions)) { let value = definition.$initial; const signal = (() => value) as (() => unknown) & { set: (next: unknown) => void }; signal.set = (next: unknown) => { value = next; }; (this as any)[key] = signal; } }, setLighting, }; timeManager.registerMap(map as any); await flushEnvironment(); expect(setLighting).toHaveBeenLastCalledWith(expect.objectContaining({ ambient: expect.objectContaining({ darkness: 0.2 }), }), undefined); vi.advanceTimersByTime(1000); await flushEnvironment(); expect(setLighting).toHaveBeenLastCalledWith(expect.objectContaining({ ambient: expect.objectContaining({ darkness: 0 }), }), undefined); } finally { vi.useRealTimers(); } }); test("time manager applies weather ambiences with month weights", async () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 08:00", weather: { enabled: true, default: { ambiences: { clear: { weather: null, weight: { default: 0 }, duration: { hours: 1 }, }, rain: { weather: { effect: "rain", params: { density: 180 }, }, weight: { default: 0, months: { 1: 100 } }, duration: { hours: 1 }, }, }, }, }, }); const map = createRuntimeMap("map1"); timeManager.registerMap(map as any); await flushEnvironment(); expect(map.setWeather).toHaveBeenCalledWith(expect.objectContaining({ effect: "rain", params: { density: 180 }, startedAt: expect.any(Number), }), undefined); expect(map.clearWeather).not.toHaveBeenCalled(); }); test("time manager prefers map weather table over the default table", async () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 08:00", weather: { enabled: true, default: { ambiences: { rain: { weather: { effect: "rain" }, weight: 100, duration: { hours: 1 }, }, }, }, maps: { map2: { ambiences: { fog: { weather: { effect: "fog" }, weight: 100, duration: { hours: 1 }, }, }, }, }, }, }); const map1 = createRuntimeMap("map1"); const map2 = createRuntimeMap("map2"); timeManager.registerMap(map1 as any); timeManager.registerMap(map2 as any); await flushEnvironment(); expect(map1.setWeather).toHaveBeenCalledWith(expect.objectContaining({ effect: "rain" }), undefined); expect(map2.setWeather).toHaveBeenCalledWith(expect.objectContaining({ effect: "fog" }), undefined); }); test("time manager can roll a clear weather ambience", async () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 08:00", weather: { enabled: true, default: { ambiences: { clear: { weather: null, weight: 100, duration: { hours: 2 }, }, }, }, }, }); const map = createRuntimeMap("map1"); timeManager.registerMap(map as any); await flushEnvironment(); expect(map.clearWeather).toHaveBeenCalledWith(undefined); expect(map.setWeather).not.toHaveBeenCalled(); }); test("time manager refreshes weather when ambience duration expires", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 08:00", scale: 60, weather: { enabled: true, default: { ambiences: { cloud: { weather: { effect: "cloud" }, weight: 100, duration: { minutes: 1 }, }, }, }, }, }); const map = createRuntimeMap("map1"); timeManager.registerMap(map as any); await flushEnvironment(); expect(map.setWeather).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1000); await flushEnvironment(); expect(map.setWeather).toHaveBeenCalledTimes(2); }); test("time manager leaves manual weather untouched when weather config is omitted", async () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 08:00", }); const map = createRuntimeMap("map1"); timeManager.registerMap(map as any); await flushEnvironment(); expect(map.setWeather).not.toHaveBeenCalled(); expect(map.clearWeather).not.toHaveBeenCalled(); }); test("time manager calls plugin and event hooks when the day changes", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const onDayChange = vi.fn(); const eventDayChange = vi.fn(); const event = { onDayChange: eventDayChange, execMethod(method: string, args: unknown[]) { return (this as any)[method]?.(...args); }, }; const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 23:59", scale: 60, hooks: { onDayChange, }, }); const map = createRuntimeMap("map1", [event]); timeManager.registerMap(map as any); await flushEnvironment(); expect(onDayChange).not.toHaveBeenCalled(); vi.advanceTimersByTime(1000); await flushEnvironment(); expect(onDayChange).toHaveBeenCalledTimes(1); expect(eventDayChange).toHaveBeenCalledTimes(1); expect(onDayChange.mock.calls[0][0]).toMatchObject({ previous: { day: 1, hour: 23, minute: 59 }, current: { day: 2, hour: 0, minute: 0 }, reason: "tick", }); }); test("time manager calls lighting phase hooks when the phase changes", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const onLightingPhaseChange = vi.fn(); const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 07:59", scale: 60, lighting: { enabled: true, phases: { dawn: { hour: 6, lighting: { ambient: { darkness: 0.2 } } }, day: { hour: 8, lighting: { ambient: { darkness: 0 } } }, }, }, hooks: { onLightingPhaseChange, }, }); const map = createRuntimeMap("map1"); timeManager.registerMap(map as any); await flushEnvironment(); vi.advanceTimersByTime(1000); await flushEnvironment(); expect(onLightingPhaseChange).toHaveBeenCalledTimes(1); expect(onLightingPhaseChange.mock.calls[0][0]).toMatchObject({ previousKey: "dawn", currentKey: "day", currentLighting: { ambient: { darkness: 0 } }, }); }); test("time manager before weather hook can cancel a weather transition", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const onBeforeWeatherChange = vi.fn(() => false as const); const onWeatherChange = vi.fn(); const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 08:00", scale: 60, weather: { enabled: true, default: { ambiences: { rain: { weather: { effect: "rain" }, weight: 100, duration: { minutes: 1 }, }, }, }, }, hooks: { onBeforeWeatherChange, onWeatherChange, }, }); const map = createRuntimeMap("map1"); timeManager.registerMap(map as any); await flushEnvironment(); expect(map.setWeather).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1000); await flushEnvironment(); expect(onBeforeWeatherChange).toHaveBeenCalledTimes(1); expect(map.setWeather).toHaveBeenCalledTimes(1); expect(onWeatherChange).not.toHaveBeenCalled(); }); test("time manager before weather hook can replace a weather ambience", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const onBeforeWeatherChange = vi.fn(() => ({ key: "festival-clear", ambience: { weather: null, weight: 1, duration: { hours: 8 }, }, })); const onWeatherChange = vi.fn(); const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 08:00", scale: 60, weather: { enabled: true, default: { ambiences: { rain: { weather: { effect: "rain" }, weight: 100, duration: { minutes: 1 }, }, }, }, }, hooks: { onBeforeWeatherChange, onWeatherChange, }, }); const map = createRuntimeMap("map1"); timeManager.registerMap(map as any); await flushEnvironment(); vi.advanceTimersByTime(1000); await flushEnvironment(); expect(onBeforeWeatherChange).toHaveBeenCalledTimes(1); expect(map.clearWeather).toHaveBeenCalledWith(undefined); expect(onWeatherChange).toHaveBeenCalledWith(expect.objectContaining({ previousKey: "rain", currentKey: "festival-clear", currentWeather: null, })); }); test("time manager merges the phase lighting into the lighting of the map and keeps its light spots", async () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 08:00", lighting: { enabled: true, phases: { day: { hour: 6, lighting: { ambient: { darkness: 0 } } } }, }, }); const map = { ...createRuntimeMap("map1"), patchLighting: vi.fn() }; timeManager.registerMap(map as any); await flushEnvironment(); expect(map.setLighting).not.toHaveBeenCalled(); expect(map.patchLighting).toHaveBeenCalledWith({ ambient: { darkness: 0 } }, undefined); }); test("time manager day night lighting enables the cycle without replacing the lighting of the map", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const onLightingPhaseChange = vi.fn(); const onPhaseChange = vi.fn(); const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 17:29", scale: 60, lighting: { enabled: true, dayNight: { lightIntensity: 1.5 }, phases: { night: { hour: 21, lighting: {} } } }, hooks: { onLightingPhaseChange, onPhaseChange }, }); const map = { ...createRuntimeMap("map1"), patchLighting: vi.fn() }; timeManager.registerMap(map as any); await flushEnvironment(); expect(map.setLighting).not.toHaveBeenCalled(); expect(map.patchLighting).toHaveBeenCalledWith({ dayNight: { enabled: true, lightIntensity: 1.5, timeManager: true } }, undefined); // 17:30 is the start of the dusk. vi.advanceTimersByTime(1000); await flushEnvironment(); expect(onLightingPhaseChange).toHaveBeenCalledTimes(1); expect(onLightingPhaseChange.mock.calls[0][0]).toMatchObject({ previousKey: "day", currentKey: "dusk" }); expect(onPhaseChange).toHaveBeenCalledTimes(1); expect(map.patchLighting).toHaveBeenCalledTimes(2); }); test("time state exposes the decimal hour and the phase of the day", () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 18:30" }); expect(timeManager.state()).toMatchObject({ hour: 18, minute: 30, phase: "dusk" }); expect(timeManager.state().hourFloat).toBeCloseTo(18.5, 1); }); test("time manager lighting can be disabled or overridden for a map", async () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 12:00", lighting: { enabled: true, dayNight: true, maps: { cave: false, tavern: { dayNight: { lightIntensity: 2 } } }, }, }); const cave = { ...createRuntimeMap("cave"), patchLighting: vi.fn() }; const tavern = { ...createRuntimeMap("tavern"), patchLighting: vi.fn() }; const town = { ...createRuntimeMap("town"), patchLighting: vi.fn() }; for (const map of [cave, tavern, town]) { timeManager.registerMap(map as any); } await flushEnvironment(); expect(cave.patchLighting).not.toHaveBeenCalled(); expect(tavern.patchLighting).toHaveBeenCalledWith({ dayNight: { enabled: true, lightIntensity: 2, timeManager: true } }, undefined); expect(town.patchLighting).toHaveBeenCalledWith({ dayNight: { enabled: true, timeManager: true } }, undefined); }); test("time manager keeps a map that declares dayNight.enabled false without a cycle", async () => { const timeManager = new TimeManager(); timeManager.configure({ start: "0001-01-01 12:00", lighting: { enabled: true, dayNight: true } }); const house = { ...createRuntimeMap("house"), getLighting: vi.fn(() => ({ dayNight: { enabled: false } })), patchLighting: vi.fn(), }; timeManager.registerMap(house as any); await flushEnvironment(); expect(house.patchLighting).not.toHaveBeenCalled(); expect(house.setLighting).not.toHaveBeenCalled(); });