Compare commits

...

7 Commits

Author SHA1 Message Date
CI
4a78d55d22 chore: release version v1.77.7 2025-09-03 13:03:26 +00:00
Dmitry Popov
dc252b8c4b Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-09-03 15:02:57 +02:00
Dmitry Popov
c433205e89 fix: Updated character tracking 2025-09-03 15:02:53 +02:00
CI
d6bc5b57b1 chore: [skip ci] 2025-09-02 17:34:32 +00:00
CI
280a286266 chore: release version v1.77.6 2025-09-02 17:34:32 +00:00
Dmitry Popov
d5c18b5de3 fix: Updated character tracking, added grace period to reduce false-positive cases 2025-09-02 19:33:57 +02:00
CI
7452e5d011 chore: [skip ci] 2025-09-02 10:37:50 +00:00
18 changed files with 588 additions and 318 deletions

View File

@@ -2,6 +2,24 @@
<!-- changelog -->
## [v1.77.7](https://github.com/wanderer-industries/wanderer/compare/v1.77.6...v1.77.7) (2025-09-03)
### Bug Fixes:
* Updated character tracking
## [v1.77.6](https://github.com/wanderer-industries/wanderer/compare/v1.77.5...v1.77.6) (2025-09-02)
### Bug Fixes:
* Updated character tracking, added grace period to reduce false-positive cases
## [v1.77.5](https://github.com/wanderer-industries/wanderer/compare/v1.77.4...v1.77.5) (2025-09-02)

View File

@@ -1,7 +1,7 @@
import { useReactFlow } from 'reactflow';
import { useCallback, useRef } from 'react';
import { CommandSelectSystems } from '@/hooks/Mapper/types';
import { OnMapSelectionChange } from '@/hooks/Mapper/components/map/map.types.ts';
import { CommandSelectSystems } from '@/hooks/Mapper/types';
import { useCallback, useRef } from 'react';
import { useReactFlow } from 'reactflow';
export const useSelectSystems = (onSelectionChange: OnMapSelectionChange) => {
const rf = useReactFlow();

View File

@@ -1,4 +1,3 @@
import { ForwardedRef, useImperativeHandle, useRef } from 'react';
import {
CommandAddConnections,
CommandAddSystems,
@@ -19,8 +18,11 @@ import {
CommandUpdateSystems,
MapHandlers,
} from '@/hooks/Mapper/types/mapHandlers.ts';
import { ForwardedRef, useImperativeHandle, useRef } from 'react';
import { OnMapSelectionChange } from '@/hooks/Mapper/components/map/map.types.ts';
import {
useCenterSystem,
useCommandsCharacters,
useCommandsConnections,
useMapAddSystems,
@@ -28,10 +30,8 @@ import {
useMapInit,
useMapRemoveSystems,
useMapUpdateSystems,
useCenterSystem,
useSelectSystems,
} from './api';
import { OnMapSelectionChange } from '@/hooks/Mapper/components/map/map.types.ts';
export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange: OnMapSelectionChange) => {
const mapInit = useMapInit();
@@ -49,91 +49,87 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
const { charactersUpdated, presentCharacters, characterAdded, characterRemoved, characterUpdated } =
useCommandsCharacters();
useImperativeHandle(
ref,
() => {
return {
command(type, data) {
switch (type) {
case Commands.init:
mapInit(data as CommandInit);
break;
case Commands.addSystems:
setTimeout(() => mapAddSystems(data as CommandAddSystems), 100);
break;
case Commands.updateSystems:
mapUpdateSystems(data as CommandUpdateSystems);
break;
case Commands.removeSystems:
setTimeout(() => removeSystems(data as CommandRemoveSystems), 100);
break;
case Commands.addConnections:
setTimeout(() => addConnections(data as CommandAddConnections), 100);
break;
case Commands.removeConnections:
setTimeout(() => removeConnections(data as CommandRemoveConnections), 100);
break;
case Commands.charactersUpdated:
charactersUpdated(data as CommandCharactersUpdated);
break;
case Commands.characterAdded:
characterAdded(data as CommandCharacterAdded);
break;
case Commands.characterRemoved:
characterRemoved(data as CommandCharacterRemoved);
break;
case Commands.characterUpdated:
characterUpdated(data as CommandCharacterUpdated);
break;
case Commands.presentCharacters:
presentCharacters(data as CommandPresentCharacters);
break;
case Commands.updateConnection:
updateConnection(data as CommandUpdateConnection);
break;
case Commands.mapUpdated:
mapUpdated(data as CommandMapUpdated);
break;
case Commands.killsUpdated:
killsUpdated(data as CommandKillsUpdated);
break;
useImperativeHandle(ref, () => {
return {
command(type, data) {
switch (type) {
case Commands.init:
mapInit(data as CommandInit);
break;
case Commands.addSystems:
setTimeout(() => mapAddSystems(data as CommandAddSystems), 100);
break;
case Commands.updateSystems:
mapUpdateSystems(data as CommandUpdateSystems);
break;
case Commands.removeSystems:
setTimeout(() => removeSystems(data as CommandRemoveSystems), 100);
break;
case Commands.addConnections:
setTimeout(() => addConnections(data as CommandAddConnections), 100);
break;
case Commands.removeConnections:
setTimeout(() => removeConnections(data as CommandRemoveConnections), 100);
break;
case Commands.charactersUpdated:
charactersUpdated(data as CommandCharactersUpdated);
break;
case Commands.characterAdded:
characterAdded(data as CommandCharacterAdded);
break;
case Commands.characterRemoved:
characterRemoved(data as CommandCharacterRemoved);
break;
case Commands.characterUpdated:
characterUpdated(data as CommandCharacterUpdated);
break;
case Commands.presentCharacters:
presentCharacters(data as CommandPresentCharacters);
break;
case Commands.updateConnection:
updateConnection(data as CommandUpdateConnection);
break;
case Commands.mapUpdated:
mapUpdated(data as CommandMapUpdated);
break;
case Commands.killsUpdated:
killsUpdated(data as CommandKillsUpdated);
break;
case Commands.centerSystem:
setTimeout(() => {
const systemId = `${data}`;
centerSystem(systemId as CommandSelectSystem);
}, 100);
break;
case Commands.centerSystem:
setTimeout(() => {
const systemId = `${data}`;
centerSystem(systemId as CommandSelectSystem);
}, 100);
break;
case Commands.selectSystem:
selectSystems({ systems: [data as string], delay: 500 });
break;
case Commands.selectSystem:
selectSystems({ systems: [data as string], delay: 500 });
break;
case Commands.selectSystems:
selectSystems(data as CommandSelectSystems);
break;
case Commands.selectSystems:
selectSystems(data as CommandSelectSystems);
break;
case Commands.pingAdded:
case Commands.pingCancelled:
case Commands.routes:
case Commands.signaturesUpdated:
case Commands.linkSignatureToSystem:
case Commands.detailedKillsUpdated:
case Commands.characterActivityData:
case Commands.trackingCharactersData:
case Commands.updateActivity:
case Commands.updateTracking:
case Commands.userSettingsUpdated:
// do nothing
break;
case Commands.pingAdded:
case Commands.pingCancelled:
case Commands.routes:
case Commands.signaturesUpdated:
case Commands.linkSignatureToSystem:
case Commands.detailedKillsUpdated:
case Commands.characterActivityData:
case Commands.trackingCharactersData:
case Commands.updateActivity:
case Commands.updateTracking:
case Commands.userSettingsUpdated:
// do nothing
break;
default:
console.warn(`Map handlers: Unknown command: ${type}`, data);
break;
}
},
};
},
[],
);
default:
console.warn(`Map handlers: Unknown command: ${type}`, data);
break;
}
},
};
}, []);
};

View File

@@ -1,6 +1,7 @@
export * from './useClipboard';
export * from './useConfirmPopup';
export * from './useEventBuffer';
export * from './useHotkey';
export * from './usePageVisibility';
export * from './useSkipContextMenu';
export * from './useThrottle';
export * from './useConfirmPopup';

View File

@@ -0,0 +1,41 @@
import debounce from 'lodash.debounce';
import { useCallback, useRef } from 'react';
export type UseEventBufferHandler<T> = (event: T) => void;
export const useEventBuffer = <T>(handler: UseEventBufferHandler<T>) => {
// @ts-ignore
const eventsBufferRef = useRef<T[]>([]);
const eventTick = useCallback(
debounce(() => {
if (eventsBufferRef.current.length === 0) {
return;
}
const event = eventsBufferRef.current.shift()!;
handler(event);
// TODO - do not delete THIS code it needs for debug
// console.log('JOipP', `Tick Buff`, eventsBufferRef.current.length);
if (eventsBufferRef.current.length > 0) {
eventTick();
}
}, 10),
[],
);
const eventTickRef = useRef(eventTick);
eventTickRef.current = eventTick;
// @ts-ignore
const handleEvent = useCallback(event => {
if (!eventTickRef.current) {
return;
}
eventsBufferRef.current.push(event);
eventTickRef.current();
}, []);
return { handleEvent };
};

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { CommandInit } from '@/hooks/Mapper/types';
import { MapRootData, useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useLoadSystemStatic } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic.ts';
import { CommandInit } from '@/hooks/Mapper/types';
import { useCallback } from 'react';
export const useMapInit = () => {
const { update } = useMapRootState();

View File

@@ -1,4 +1,3 @@
import { ForwardedRef, useImperativeHandle } from 'react';
import {
CommandAddConnections,
CommandAddSystems,
@@ -8,24 +7,25 @@ import {
CommandCharactersUpdated,
CommandCharacterUpdated,
CommandCommentAdd,
CommandCommentRemoved,
CommandInit,
CommandLinkSignatureToSystem,
CommandMapUpdated,
CommandPingAdded,
CommandPingCancelled,
CommandPresentCharacters,
CommandRemoveConnections,
CommandRemoveSystems,
CommandRoutes,
Commands,
CommandSignaturesUpdated,
CommandTrackingCharactersData,
CommandUpdateConnection,
CommandUpdateSystems,
CommandUserSettingsUpdated,
Commands,
MapHandlers,
CommandCommentRemoved,
CommandPingAdded,
CommandPingCancelled,
} from '@/hooks/Mapper/types/mapHandlers.ts';
import { ForwardedRef, useImperativeHandle } from 'react';
import {
useCommandComments,
@@ -39,9 +39,9 @@ import {
useUserRoutes,
} from './api';
import { useCommandsActivity } from './api/useCommandsActivity';
import { emitMapEvent } from '@/hooks/Mapper/events';
import { DetailedKill } from '../../types/kills';
import { useCommandsActivity } from './api/useCommandsActivity';
export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const mapInit = useMapInit();
@@ -63,127 +63,123 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const { pingAdded, pingCancelled } = useCommandPings();
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
useImperativeHandle(
ref,
() => {
return {
command(type, data) {
switch (type) {
case Commands.init: // USED
mapInit(data as CommandInit);
break;
case Commands.addSystems: // USED
addSystems(data as CommandAddSystems);
break;
case Commands.updateSystems: // USED
updateSystems(data as CommandUpdateSystems);
break;
case Commands.removeSystems: // USED
removeSystems(data as CommandRemoveSystems);
break;
case Commands.addConnections: // USED
addConnections(data as CommandAddConnections);
break;
case Commands.removeConnections: // USED
removeConnections(data as CommandRemoveConnections);
break;
case Commands.updateConnection: // USED
updateConnection(data as CommandUpdateConnection);
break;
case Commands.charactersUpdated: // USED
charactersUpdated(data as CommandCharactersUpdated);
break;
case Commands.characterAdded: // USED
characterAdded(data as CommandCharacterAdded);
break;
case Commands.characterRemoved: // USED
characterRemoved(data as CommandCharacterRemoved);
break;
case Commands.characterUpdated: // USED
characterUpdated(data as CommandCharacterUpdated);
break;
case Commands.presentCharacters: // USED
presentCharacters(data as CommandPresentCharacters);
break;
case Commands.mapUpdated: // USED
mapUpdated(data as CommandMapUpdated);
break;
case Commands.routes:
mapRoutes(data as CommandRoutes);
break;
case Commands.userRoutes:
mapUserRoutes(data as CommandRoutes);
break;
useImperativeHandle(ref, () => {
return {
command(type, data) {
switch (type) {
case Commands.init: // USED
mapInit(data as CommandInit);
break;
case Commands.addSystems: // USED
addSystems(data as CommandAddSystems);
break;
case Commands.updateSystems: // USED
updateSystems(data as CommandUpdateSystems);
break;
case Commands.removeSystems: // USED
removeSystems(data as CommandRemoveSystems);
break;
case Commands.addConnections: // USED
addConnections(data as CommandAddConnections);
break;
case Commands.removeConnections: // USED
removeConnections(data as CommandRemoveConnections);
break;
case Commands.updateConnection: // USED
updateConnection(data as CommandUpdateConnection);
break;
case Commands.charactersUpdated: // USED
charactersUpdated(data as CommandCharactersUpdated);
break;
case Commands.characterAdded: // USED
characterAdded(data as CommandCharacterAdded);
break;
case Commands.characterRemoved: // USED
characterRemoved(data as CommandCharacterRemoved);
break;
case Commands.characterUpdated: // USED
characterUpdated(data as CommandCharacterUpdated);
break;
case Commands.presentCharacters: // USED
presentCharacters(data as CommandPresentCharacters);
break;
case Commands.mapUpdated: // USED
mapUpdated(data as CommandMapUpdated);
break;
case Commands.routes:
mapRoutes(data as CommandRoutes);
break;
case Commands.userRoutes:
mapUserRoutes(data as CommandRoutes);
break;
case Commands.signaturesUpdated: // USED
updateSystemSignatures(data as CommandSignaturesUpdated);
break;
case Commands.signaturesUpdated: // USED
updateSystemSignatures(data as CommandSignaturesUpdated);
break;
case Commands.linkSignatureToSystem: // USED
setTimeout(() => {
updateLinkSignatureToSystem(data as CommandLinkSignatureToSystem);
}, 200);
break;
case Commands.linkSignatureToSystem: // USED
setTimeout(() => {
updateLinkSignatureToSystem(data as CommandLinkSignatureToSystem);
}, 200);
break;
case Commands.centerSystem: // USED
// do nothing here
break;
case Commands.centerSystem: // USED
// do nothing here
break;
case Commands.selectSystem: // USED
// do nothing here
break;
case Commands.selectSystem: // USED
// do nothing here
break;
case Commands.killsUpdated:
// do nothing here
break;
case Commands.killsUpdated:
// do nothing here
break;
case Commands.detailedKillsUpdated:
updateDetailedKills(data as Record<string, DetailedKill[]>);
break;
case Commands.detailedKillsUpdated:
updateDetailedKills(data as Record<string, DetailedKill[]>);
break;
case Commands.characterActivityData:
characterActivityData(data as CommandCharacterActivityData);
break;
case Commands.characterActivityData:
characterActivityData(data as CommandCharacterActivityData);
break;
case Commands.trackingCharactersData:
trackingCharactersData(data as CommandTrackingCharactersData);
break;
case Commands.trackingCharactersData:
trackingCharactersData(data as CommandTrackingCharactersData);
break;
case Commands.updateActivity:
break;
case Commands.updateActivity:
break;
case Commands.updateTracking:
break;
case Commands.updateTracking:
break;
case Commands.userSettingsUpdated:
userSettingsUpdated(data as CommandUserSettingsUpdated);
break;
case Commands.userSettingsUpdated:
userSettingsUpdated(data as CommandUserSettingsUpdated);
break;
case Commands.systemCommentAdded:
addComment(data as CommandCommentAdd);
break;
case Commands.systemCommentAdded:
addComment(data as CommandCommentAdd);
break;
case Commands.systemCommentRemoved:
removeComment(data as CommandCommentRemoved);
break;
case Commands.systemCommentRemoved:
removeComment(data as CommandCommentRemoved);
break;
case Commands.pingAdded:
pingAdded(data as CommandPingAdded);
break;
case Commands.pingAdded:
pingAdded(data as CommandPingAdded);
break;
case Commands.pingCancelled:
pingCancelled(data as CommandPingCancelled);
break;
case Commands.pingCancelled:
pingCancelled(data as CommandPingCancelled);
break;
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;
}
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;
}
emitMapEvent({ name: type, data });
},
};
},
[],
);
emitMapEvent({ name: type, data });
},
};
}, []);
};

View File

@@ -1,7 +1,8 @@
import { useEventBuffer } from '@/hooks/Mapper/hooks';
import usePageVisibility from '@/hooks/Mapper/hooks/usePageVisibility.ts';
import { MapHandlers } from '@/hooks/Mapper/types/mapHandlers.ts';
import { RefObject, useCallback, useEffect, useRef } from 'react';
import debounce from 'lodash.debounce';
import usePageVisibility from '@/hooks/Mapper/hooks/usePageVisibility.ts';
// const inIndex = 0;
// const prevEventTime = +new Date();
@@ -10,10 +11,28 @@ const LAST_VERSION_KEY = 'wandererLastVersion';
// @ts-ignore
export const useMapperHandlers = (handlerRefs: RefObject<MapHandlers>[], hooksRef: RefObject<any>) => {
const visible = usePageVisibility();
const wasHiddenOnce = useRef(false);
const visibleRef = useRef(visible);
visibleRef.current = visible;
// @ts-ignore
const handleBufferedEvent = useCallback(({ type, body }) => {
if (!visibleRef.current) {
return;
}
handlerRefs.forEach(ref => {
if (!ref.current) {
return;
}
ref.current?.command(type, body);
});
}, []);
const { handleEvent: handleMapEvent } = useEventBuffer<any>(handleBufferedEvent);
// TODO - do not delete THIS code it needs for debug
// const [record, setRecord] = useLocalStorageState<boolean>('record', {
// defaultValue: false,
@@ -54,52 +73,6 @@ export const useMapperHandlers = (handlerRefs: RefObject<MapHandlers>[], hooksRe
[hooksRef.current],
);
// @ts-ignore
const eventsBufferRef = useRef<{ type; body }[]>([]);
const eventTick = useCallback(
debounce(() => {
if (eventsBufferRef.current.length === 0) {
return;
}
const { type, body } = eventsBufferRef.current.shift()!;
handlerRefs.forEach(ref => {
if (!ref.current) {
return;
}
ref.current?.command(type, body);
});
// TODO - do not delete THIS code it needs for debug
// console.log('JOipP', `Tick Buff`, eventsBufferRef.current.length);
if (eventsBufferRef.current.length > 0) {
eventTick();
}
}, 10),
[],
);
const eventTickRef = useRef(eventTick);
eventTickRef.current = eventTick;
// @ts-ignore
const handleMapEvent = useCallback(({ type, body }) => {
// TODO - do not delete THIS code it needs for debug
// const currentTime = +new Date();
// const timeDiff = currentTime - prevEventTime;
// prevEventTime = currentTime;
// console.log('JOipP', `IN [${inIndex++}] [${timeDiff}] ${getFormattedTime()}`, { type, body });
if (!eventTickRef.current || !visibleRef.current) {
return;
}
eventsBufferRef.current.push({ type, body });
eventTickRef.current();
}, []);
useEffect(() => {
if (!visible && !wasHiddenOnce.current) {
wasHiddenOnce.current = true;

View File

@@ -54,6 +54,7 @@ defmodule WandererApp.Application do
child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
{PartitionSupervisor,
child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
WandererAppWeb.PresenceGracePeriodManager,
WandererAppWeb.Presence,
WandererAppWeb.Endpoint
]

View File

@@ -14,7 +14,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
@check_start_queue_interval :timer.seconds(1)
@garbage_collection_interval :timer.minutes(15)
@untrack_characters_interval :timer.minutes(1)
@untrack_characters_interval :timer.minutes(5)
@inactive_character_timeout :timer.minutes(10)
@untrack_character_timeout :timer.minutes(10)
@@ -54,6 +54,8 @@ defmodule WandererApp.Character.TrackerManager.Impl do
true
)
Logger.debug(fn -> "Add character to track_characters_queue: #{inspect(character_id)}" end)
WandererApp.Cache.insert_or_update(
"track_characters_queue",
[character_id],
@@ -69,29 +71,25 @@ defmodule WandererApp.Character.TrackerManager.Impl do
def stop_tracking(state, character_id) do
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
true <- Enum.member?(characters, character_id),
{:ok, %{start_time: start_time}} <-
WandererApp.Character.get_character_state(character_id, false) do
false <- WandererApp.Cache.has_key?("#{character_id}:track_requested") do
Logger.debug(fn -> "Shutting down character tracker: #{inspect(character_id)}" end)
WandererApp.Cache.delete("character:#{character_id}:last_active_time")
WandererApp.Character.delete_character_state(character_id)
tracked_characters =
characters |> Enum.reject(fn c_id -> c_id == character_id end)
WandererApp.Cache.insert("tracked_characters", tracked_characters)
WandererApp.Character.TrackerPoolDynamicSupervisor.stop_tracking(character_id)
duration = DateTime.diff(DateTime.utc_now(), start_time, :second)
:telemetry.execute([:wanderer_app, :character, :tracker, :running], %{
duration: duration
})
:telemetry.execute([:wanderer_app, :character, :tracker, :stopped], %{count: 1})
end
WandererApp.Cache.insert_or_update(
"tracked_characters",
[],
fn tracked_characters ->
tracked_characters
|> Enum.reject(fn c_id -> c_id == character_id end)
end
)
state
end
@@ -129,7 +127,8 @@ defmodule WandererApp.Character.TrackerManager.Impl do
"character_untrack_queue",
[{map_id, character_id}],
fn untrack_queue ->
[{map_id, character_id} | untrack_queue] |> Enum.uniq()
[{map_id, character_id} | untrack_queue]
|> Enum.uniq_by(fn {map_id, character_id} -> map_id <> character_id end)
end
)
end

View File

@@ -59,7 +59,7 @@ defmodule WandererApp.Map.Server.CharactersImpl do
def update_tracked_characters(map_id) do
Task.start_link(fn ->
{:ok, map_tracked_character_ids} =
{:ok, all_map_tracked_character_ids} =
map_id
|> WandererApp.MapCharacterSettingsRepo.get_tracked_by_map_all()
|> case do
@@ -67,16 +67,10 @@ defmodule WandererApp.Map.Server.CharactersImpl do
_ -> {:ok, []}
end
{:ok, tracked_characters} = WandererApp.Cache.lookup("tracked_characters", [])
map_active_tracked_characters =
map_tracked_character_ids
|> Enum.filter(fn character -> character in tracked_characters end)
{:ok, old_map_tracked_characters} =
{:ok, actual_map_tracked_characters} =
WandererApp.Cache.lookup("maps:#{map_id}:tracked_characters", [])
characters_to_remove = old_map_tracked_characters -- map_active_tracked_characters
characters_to_remove = actual_map_tracked_characters -- all_map_tracked_character_ids
WandererApp.Cache.insert_or_update(
"map_#{map_id}:invalidate_character_ids",
@@ -86,8 +80,6 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
WandererApp.Cache.insert("maps:#{map_id}:tracked_characters", map_active_tracked_characters)
:ok
end)
end
@@ -95,7 +87,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
def untrack_characters(map_id, character_ids) do
character_ids
|> Enum.each(fn character_id ->
is_character_map_active?(map_id, character_id)
character_map_active = is_character_map_active?(map_id, character_id)
character_map_active
|> untrack_character(map_id, character_id)
end)
end
@@ -222,8 +216,7 @@ defmodule WandererApp.Map.Server.CharactersImpl do
{:ok, presence_character_ids} =
WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
WandererApp.Cache.lookup!("maps:#{map_id}:tracked_characters", [])
|> Enum.filter(fn character_id -> character_id in presence_character_ids end)
presence_character_ids
|> Enum.map(fn character_id ->
Task.start_link(fn ->
character_updates =

View File

@@ -581,18 +581,27 @@ defmodule WandererApp.Map.Server.Impl do
{:ok, presence_character_ids} =
WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
characters_ids =
map_id
|> WandererApp.Map.get_map!()
|> Map.get(:characters, [])
{:ok, old_presence_character_ids} =
WandererApp.Cache.lookup("map_#{map_id}:old_presence_character_ids", [])
new_present_character_ids =
presence_character_ids
|> Enum.filter(fn character_id ->
not Enum.member?(old_presence_character_ids, character_id)
end)
not_present_character_ids =
characters_ids
old_presence_character_ids
|> Enum.filter(fn character_id ->
not Enum.member?(presence_character_ids, character_id)
end)
CharactersImpl.track_characters(map_id, presence_character_ids)
WandererApp.Cache.insert(
"map_#{map_id}:old_presence_character_ids",
presence_character_ids
)
CharactersImpl.track_characters(map_id, new_present_character_ids)
CharactersImpl.untrack_characters(map_id, not_present_character_ids)
broadcast!(

View File

@@ -38,8 +38,6 @@ defmodule WandererAppWeb.UserAuth do
{:halt, redirect_require_login(socket)}
%User{characters: characters} ->
:ok = track_characters(characters)
{:cont, new_socket}
end

View File

@@ -703,6 +703,18 @@ defmodule WandererAppWeb.MapCoreEventHandler do
Process.send_after(self(), %{event: :load_map_pings}, 200)
Process.send_after(
self(),
%{
event: :maybe_select_system,
payload: %{
character_id: main_character_id,
solar_system_id: nil
}
},
200
)
if needs_tracking_setup do
Process.send_after(self(), %{event: :show_tracking}, 10)

View File

@@ -44,16 +44,24 @@ defmodule WandererAppWeb.MapSystemsEventHandler do
current_user: current_user,
tracked_characters: tracked_characters,
map_id: map_id,
map_user_settings: map_user_settings
map_user_settings: map_user_settings,
main_character_eve_id: main_character_eve_id,
following_character_eve_id: following_character_eve_id
}
} = socket
) do
character =
tracked_characters
|> Enum.find(fn tracked_character -> tracked_character.id == character_id end)
if is_nil(character_id) do
tracked_characters
|> Enum.find(fn tracked_character ->
tracked_character.eve_id == (following_character_eve_id || main_character_eve_id)
end)
else
tracked_characters
|> Enum.find(fn tracked_character -> tracked_character.id == character_id end)
end
is_user_character =
not is_nil(character)
is_user_character = not is_nil(character)
is_select_on_spash =
map_user_settings
@@ -61,10 +69,9 @@ defmodule WandererAppWeb.MapSystemsEventHandler do
|> WandererApp.MapUserSettingsRepo.get_boolean_setting("select_on_spash")
is_following =
case WandererApp.MapUserSettingsRepo.get(map_id, current_user.id) do
{:ok, %{following_character_eve_id: following_character_eve_id}}
when not is_nil(following_character_eve_id) ->
is_user_character && following_character_eve_id == character.eve_id
case is_user_character && not is_nil(following_character_eve_id) do
true ->
following_character_eve_id == character.eve_id
_ ->
false
@@ -77,8 +84,17 @@ defmodule WandererAppWeb.MapSystemsEventHandler do
else
# Always select the system when auto-select is enabled (following or select_on_spash).
# The frontend will handle deselecting other systems
#
select_solar_system_id =
if not is_nil(solar_system_id) do
"#{solar_system_id}"
else
{:ok, character} = WandererApp.Character.get_map_character(map_id, character.id)
"#{character.solar_system_id}"
end
socket
|> MapEventHandler.push_map_event("select_system", solar_system_id)
|> MapEventHandler.push_map_event("select_system", select_solar_system_id)
end
end

View File

@@ -24,24 +24,8 @@ defmodule WandererAppWeb.Presence do
%{character_id: character_id, tracked: any_tracked, from: from}
end)
presence_tracked_character_ids =
presence_data
|> Enum.filter(fn %{tracked: tracked} -> tracked end)
|> Enum.map(fn %{character_id: character_id} ->
character_id
end)
WandererApp.Cache.insert(
"map_#{map_id}:presence_character_ids",
presence_tracked_character_ids
)
WandererApp.Cache.insert(
"map_#{map_id}:presence_data",
presence_data
)
WandererApp.Cache.insert("map_#{map_id}:presence_updated", true)
# Delegate all cache operations to the PresenceGracePeriodManager
WandererAppWeb.PresenceGracePeriodManager.process_presence_change(map_id, presence_data)
{:ok, state}
end

View File

@@ -0,0 +1,233 @@
defmodule WandererAppWeb.PresenceGracePeriodManager do
@moduledoc """
Manages grace period for character presence tracking.
This module prevents rapid start/stop cycles of character tracking
by introducing a 5-minute grace period before stopping tracking
for characters that leave presence.
"""
use GenServer
require Logger
# 30 minutes
@grace_period_ms :timer.minutes(30)
@check_remove_queue_interval :timer.seconds(30)
defstruct pending_removals: %{}, timers: %{}, to_remove: []
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Process presence changes with grace period logic.
Updates the cache with the final list of character IDs that should be tracked,
accounting for the grace period.
"""
def process_presence_change(map_id, presence_data) do
GenServer.cast(__MODULE__, {:process_presence_change, map_id, presence_data})
end
@impl true
def init(_opts) do
Logger.info("#{__MODULE__} started")
Process.send_after(self(), :check_remove_queue, @check_remove_queue_interval)
{:ok, %__MODULE__{}}
end
@impl true
def handle_cast({:process_presence_change, map_id, presence_data}, state) do
# Extract currently tracked character IDs from presence data
current_tracked_character_ids =
presence_data
|> Enum.filter(fn %{tracked: tracked} -> tracked end)
|> Enum.map(fn %{character_id: character_id} -> character_id end)
# Get previous tracked character IDs from cache
previous_tracked_character_ids = get_previous_character_ids(map_id)
current_set = MapSet.new(current_tracked_character_ids)
previous_set = MapSet.new(previous_tracked_character_ids)
# Characters that just joined (not in previous, but in current)
newly_joined = MapSet.difference(current_set, previous_set)
# Characters that just left (in previous, but not in current)
newly_left = MapSet.difference(previous_set, current_set)
# Process newly joined characters - cancel any pending removals
state =
state
|> cancel_pending_removals(map_id, current_set)
|> schedule_removals(map_id, newly_left)
# Process newly left characters - schedule them for removal after grace period
# Calculate the final character IDs (current + still pending removal)
pending_for_map = get_pending_removals_for_map(state, map_id)
final_character_ids = MapSet.union(current_set, pending_for_map) |> MapSet.to_list()
# Update cache with final character IDs (includes grace period logic)
WandererApp.Cache.insert("map_#{map_id}:presence_character_ids", final_character_ids)
# Only update presence_data if the character IDs actually changed
if final_character_ids != previous_tracked_character_ids do
WandererApp.Cache.insert("map_#{map_id}:presence_data", presence_data)
end
WandererApp.Cache.insert("map_#{map_id}:presence_updated", true)
{:noreply, state}
end
@impl true
def handle_info({:grace_period_expired, map_id, character_id}, state) do
Logger.debug(fn -> "Grace period expired for character #{character_id} on map #{map_id}" end)
# Remove from pending removals and timers
state =
state
|> remove_pending_removal(map_id, character_id)
|> remove_after_grace_period(map_id, character_id)
{:noreply, state}
end
@impl true
def handle_info(:check_remove_queue, state) do
Process.send_after(self(), :check_remove_queue, @check_remove_queue_interval)
remove_from_cache_after_grace_period(state)
{:noreply, %{state | to_remove: []}}
end
defp cancel_pending_removals(state, map_id, character_ids) do
Enum.reduce(character_ids, state, fn character_id, acc_state ->
case get_timer_ref(acc_state, map_id, character_id) do
nil ->
acc_state
timer_ref ->
Logger.debug(fn ->
"Cancelling grace period for character #{character_id} on map #{map_id} (rejoined)"
end)
Process.cancel_timer(timer_ref)
remove_pending_removal(acc_state, map_id, character_id)
end
end)
end
defp schedule_removals(state, map_id, character_ids) do
Enum.reduce(character_ids, state, fn character_id, acc_state ->
# Only schedule if not already pending
case get_timer_ref(acc_state, map_id, character_id) do
nil ->
Logger.debug(fn ->
"Scheduling grace period for character #{character_id} on map #{map_id}"
end)
timer_ref =
Process.send_after(
self(),
{:grace_period_expired, map_id, character_id},
@grace_period_ms
)
add_pending_removal(acc_state, map_id, character_id, timer_ref)
_ ->
acc_state
end
end)
end
defp add_pending_removal(state, map_id, character_id, timer_ref) do
pending_key = {map_id, character_id}
%{
state
| pending_removals: Map.put(state.pending_removals, pending_key, true),
timers: Map.put(state.timers, pending_key, timer_ref)
}
end
defp remove_pending_removal(state, map_id, character_id) do
pending_key = {map_id, character_id}
%{
state
| pending_removals: Map.delete(state.pending_removals, pending_key),
timers: Map.delete(state.timers, pending_key)
}
end
defp get_timer_ref(state, map_id, character_id) do
Map.get(state.timers, {map_id, character_id})
end
defp get_previous_character_ids(map_id) do
case WandererApp.Cache.get("map_#{map_id}:presence_character_ids") do
nil -> []
character_ids -> character_ids
end
end
defp get_pending_removals_for_map(state, map_id) do
state.pending_removals
|> Enum.filter(fn {{pending_map_id, _character_id}, _} -> pending_map_id == map_id end)
|> Enum.map(fn {{_map_id, character_id}, _} -> character_id end)
|> MapSet.new()
end
defp remove_after_grace_period(%{to_remove: to_remove} = state, map_id, character_id_to_remove) do
%{
state
| to_remove:
(to_remove ++ [{map_id, character_id_to_remove}])
|> Enum.uniq_by(fn {map_id, character_id} -> map_id <> character_id end)
}
end
defp remove_from_cache_after_grace_period(%{to_remove: to_remove} = state) do
# Get current presence data to recalculate without the expired character
to_remove
|> Enum.each(fn {map_id, character_id_to_remove} ->
case WandererApp.Cache.get("map_#{map_id}:presence_data") do
nil ->
:ok
presence_data ->
# Recalculate tracked character IDs from current presence data
updated_presence_data =
presence_data
|> Enum.filter(fn %{character_id: character_id} ->
character_id != character_id_to_remove
end)
presence_tracked_character_ids =
updated_presence_data
|> Enum.filter(fn %{tracked: tracked} ->
tracked
end)
|> Enum.map(fn %{character_id: character_id} -> character_id end)
WandererApp.Cache.insert("map_#{map_id}:presence_data", updated_presence_data)
# Update both caches
WandererApp.Cache.insert(
"map_#{map_id}:presence_character_ids",
presence_tracked_character_ids
)
WandererApp.Cache.insert("map_#{map_id}:presence_updated", true)
Logger.debug(fn ->
"Updated cache after grace period for map #{map_id}, tracked characters: #{inspect(presence_tracked_character_ids)}"
end)
end
end)
end
end

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.77.5"
@version "1.77.7"
def project do
[