Compare commits

...

13 Commits

Author SHA1 Message Date
CI
83801c9063 chore: release version v1.56.1
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-03-18 09:34:32 +00:00
guarzo
0f34350c58 fix: update activity api (#284) 2025-03-18 11:51:29 +04:00
guarzo
1c4c0f0715 fix: qol updates for dev (#283) 2025-03-18 11:50:33 +04:00
Dmitry Popov
3825fc831a refactor: removal of legacy event
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-03-17 22:51:29 +01:00
guarzo
654670cbc8 refactor: removal of legacy event (#277)
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-03-18 00:11:32 +04:00
CI
947570072c chore: release version v1.56.0 2025-03-17 18:25:38 +00:00
Dmitry Popov
01b6b45380 fix: character activity hide error 2025-03-17 18:54:22 +01:00
guarzo
b9dc1f8357 fix: character added to map on follow (#272) 2025-03-17 19:42:42 +04:00
guarzo
b4bd810c9d refactor: updates to track and follow (#270) 2025-03-17 17:36:08 +04:00
guarzo
490b037920 refactor: simplify track and follow (#265)
Some checks are pending
Build / 🚀 Deploy to test env (fly.io) (push) Waiting to run
Build / Manual Approval (push) Blocked by required conditions
Build / 🛠 Build (1.17, 18.x, 27) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/amd64) (push) Blocked by required conditions
Build / 🛠 Build Docker Images (linux/arm64) (push) Blocked by required conditions
Build / merge (push) Blocked by required conditions
Build / 🏷 Create Release (push) Blocked by required conditions
2025-03-17 16:08:45 +04:00
guarzo
cdff5458bc feat: add static wh info (#262)
* feat: add static wh info
2025-03-17 16:03:57 +04:00
guarzo
09314a09e9 feat [doc]: new bot release (#234) 2025-03-17 11:43:27 +04:00
guarzo
49ea8edb27 feat (api): add character activity api (#263)
* feat (api): add character activity api
2025-03-17 11:36:45 +04:00
32 changed files with 19449 additions and 547 deletions

View File

@@ -1,6 +1,27 @@
FROM elixir:1.17-otp-27
RUN apt install -yq curl gnupg
# Install OS packages and Node.js (via nodesource),
# plus inotify-tools and yarn
RUN apt-get update && apt-get install -y --no-install-recommends \
sudo \
curl \
make \
git \
bash \
build-essential \
ca-certificates \
jq \
vim \
net-tools \
procps \
# Optionally add any other tools you need, e.g. vim, wget...
&& curl -sL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y --no-install-recommends nodejs inotify-tools \
&& npm install -g yarn \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN apt --fix-broken install
RUN mix local.hex --force

View File

@@ -1,20 +1,30 @@
{
"name": "wanderer-dev",
"dockerComposeFile": ["./docker-compose.yml"],
"extensions": [
"jakebecker.elixir-ls",
"JakeBecker.elixir-ls",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"customizations": {
"vscode": {
"extensions": [
"jakebecker.elixir-ls",
"JakeBecker.elixir-ls",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"editor.formatOnSave": true,
"search.exclude": {
"**/doc": true
},
"elixirLS.dialyzerEnabled": false
}
}
},
"service": "wanderer",
"workspaceFolder": "/app",
"shutdownAction": "stopCompose",
"settings": {
"editor.formatOnSave": true,
"search.exclude": {
"**/doc": true
},
"elixirLS.dialyzerEnabled": false
}
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"networkArgs": ["--add-host=host.docker.internal:host-gateway"]
}
},
"forwardPorts": [4444]
}

View File

@@ -14,15 +14,15 @@ services:
wanderer:
environment:
PORT: 8000
PORT: 4444
DB_HOST: db
WEB_APP_URL: "http://localhost:8000"
WEB_APP_URL: "http://localhost:4444"
ERL_AFLAGS: "-kernel shell_history enabled"
build:
context: .
dockerfile: Dockerfile
ports:
- 8000:8000
- 4444:4444
volumes:
- ..:/app:delegated
- ~/.gitconfig:/root/.gitconfig

View File

@@ -2,6 +2,38 @@
<!-- changelog -->
## [v1.56.1](https://github.com/wanderer-industries/wanderer/compare/v1.56.0...v1.56.1) (2025-03-18)
### Bug Fixes:
* update activity api (#284)
* qol updates for dev (#283)
## [v1.56.0](https://github.com/wanderer-industries/wanderer/compare/v1.55.2...v1.56.0) (2025-03-17)
### Features:
* add static wh info (#262)
* add static wh info
* api: add character activity api (#263)
* api: add character activity api
### Bug Fixes:
* character activity hide error
* character added to map on follow (#272)
## [v1.55.2](https://github.com/wanderer-industries/wanderer/compare/v1.55.1...v1.55.2) (2025-03-16)

View File

@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { Dialog } from 'primereact/dialog';
import { VirtualScroller } from 'primereact/virtualscroller';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { TrackingCharacter } from './types';
import { OutCommand } from '@/hooks/Mapper/types/mapHandlers';
import { TrackingCharacterWrapper } from './TrackingCharacterWrapper';
import { TrackingCharacter } from './types';
interface TrackAndFollowProps {
visible: boolean;
@@ -12,80 +12,50 @@ interface TrackAndFollowProps {
}
export const TrackAndFollow = ({ visible, onHide }: TrackAndFollowProps) => {
const [trackedCharacters, setTrackedCharacters] = useState<string[]>([]);
const [followedCharacter, setFollowedCharacter] = useState<string | null>(null);
const { outCommand, data } = useMapRootState();
const { trackingCharactersData } = data;
const characters = useMemo(() => trackingCharactersData || [], [trackingCharactersData]);
useEffect(() => {
if (trackingCharactersData) {
const newTrackedCharacters = trackingCharactersData.filter(tc => tc.tracked).map(tc => tc.character.eve_id);
setTrackedCharacters(newTrackedCharacters);
const followedChar = trackingCharactersData.find(tc => tc.followed);
if (followedChar?.character?.eve_id !== followedCharacter) {
setFollowedCharacter(followedChar?.character?.eve_id || null);
}
}
}, [followedCharacter, trackingCharactersData]);
const handleTrackToggle = (characterId: string) => {
const isCurrentlyTracked = trackedCharacters.includes(characterId);
if (isCurrentlyTracked) {
setTrackedCharacters(prev => prev.filter(id => id !== characterId));
} else {
setTrackedCharacters(prev => [...prev, characterId]);
}
outCommand({
type: OutCommand.toggleTrack,
data: { 'character-id': characterId },
});
};
const handleFollowToggle = async (characterEveId: string) => {
const isCurrentlyFollowed = followedCharacter === characterEveId;
const isCurrentlyTracked = trackedCharacters.includes(characterEveId);
// If not followed and not tracked, we need to track it first
if (!isCurrentlyFollowed && !isCurrentlyTracked) {
setTrackedCharacters(prev => [...prev, characterEveId]);
// Send track command first
await outCommand({
type: OutCommand.toggleTrack,
data: { 'character-id': characterEveId },
});
// Then send follow command after a short delay
setTimeout(() => {
outCommand({
type: OutCommand.toggleFollow,
data: { 'character-id': characterEveId },
const handleTrackToggle = useCallback(
async (characterId: string) => {
try {
await outCommand({
type: OutCommand.toggleTrack,
data: { character_id: characterId },
});
}, 100);
} else {
// Otherwise just toggle follow
await outCommand({
type: OutCommand.toggleFollow,
data: { 'character-id': characterEveId },
});
}
};
} catch (error) {
console.error('Error toggling track:', error);
}
},
[outCommand],
);
const handleFollowToggle = useCallback(
async (characterId: string) => {
try {
await outCommand({
type: OutCommand.toggleFollow,
data: { character_id: characterId },
});
} catch (error) {
console.error('Error toggling follow:', error);
}
},
[outCommand],
);
const rowTemplate = (tc: TrackingCharacter) => {
const characterEveId = tc.character.eve_id;
return (
<TrackingCharacterWrapper
key={tc.character.eve_id}
key={characterEveId}
character={tc.character}
isTracked={trackedCharacters.includes(tc.character.eve_id)}
isFollowed={followedCharacter === tc.character.eve_id}
onTrackToggle={() => handleTrackToggle(tc.character.eve_id)}
onFollowToggle={() => handleFollowToggle(tc.character.eve_id)}
isTracked={tc.tracked}
isFollowed={tc.followed}
onTrackToggle={() => handleTrackToggle(characterEveId)}
onFollowToggle={() => handleFollowToggle(characterEveId)}
/>
);
};

View File

@@ -1,7 +1,9 @@
import { WdCheckbox } from '@/hooks/Mapper/components/ui-kit/WdCheckbox/WdCheckbox';
import WdRadioButton from '@/hooks/Mapper/components/ui-kit/WdRadioButton';
import { CharacterCard, TooltipPosition, WdTooltipWrapper } from '../../../ui-kit';
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit';
import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit/WdTooltip/WdTooltip';
import { CharacterCard } from '@/hooks/Mapper/components/ui-kit';
import { CharacterTypeRaw } from '@/hooks/Mapper/types';
import WdRadioButton from '@/hooks/Mapper/components/ui-kit/WdRadioButton';
interface TrackingCharacterWrapperProps {
character: CharacterTypeRaw;
@@ -26,7 +28,7 @@ export const TrackingCharacterWrapper = ({
<div className="flex justify-center items-center p-0.5 text-center">
<WdTooltipWrapper content="Track this character on the map" position={TooltipPosition.top}>
<div className="flex justify-center items-center w-full">
<WdCheckbox id={trackCheckboxId} label="" value={isTracked} onChange={() => onTrackToggle()} />
<WdCheckbox id={trackCheckboxId} label="" value={isTracked} onChange={onTrackToggle} />
</div>
</WdTooltipWrapper>
</div>

View File

@@ -18,13 +18,7 @@ export const useCharacterActivityHandlers = () => {
...state,
showCharacterActivity: false,
}));
// Send the command to the server
outCommand({
type: OutCommand.hideActivity,
data: {},
});
}, [outCommand, update]);
}, [update]);
/**
* Handle showing the character activity dialog
@@ -56,7 +50,7 @@ export const useCharacterActivityHandlers = () => {
// Update local state with the activity data
update(state => ({
...state,
characterActivityData: activityData.activity,
characterActivityData: activityData,
showCharacterActivity: true,
}));
},

View File

@@ -13,18 +13,12 @@ export const useTrackAndFollowHandlers = () => {
* Handle hiding the track and follow dialog
*/
const handleHideTracking = useCallback(() => {
// Send the command to the server first
outCommand({
type: OutCommand.hideTracking,
data: {},
});
// Then update local state to hide the dialog
update(state => ({
...state,
showTrackAndFollow: false,
}));
}, [outCommand, update]);
}, [update]);
/**
* Handle showing the track and follow dialog
@@ -101,7 +95,6 @@ export const useTrackAndFollowHandlers = () => {
[outCommand],
);
/**
* Handle user settings updates
*/

View File

@@ -45,16 +45,9 @@ export const useCommandsActivity = () => {
}));
}, []);
const hideTracking = useCallback(() => {
ref.current.update((state: MapRootData) => ({
...state,
showTrackAndFollow: false,
}));
}, []);
const userSettingsUpdated = useCallback((data: CommandUserSettingsUpdated) => {
emitMapEvent({ name: Commands.userSettingsUpdated, data });
}, []);
return { characterActivityData, trackingCharactersData, userSettingsUpdated, hideActivity, hideTracking };
return { characterActivityData, trackingCharactersData, userSettingsUpdated, hideActivity };
};

View File

@@ -241,7 +241,6 @@ export enum OutCommand {
// Only UI commands
openSettings = 'open_settings',
showActivity = 'show_activity',
hideTracking = 'hide_tracking',
showTracking = 'show_tracking',
getUserSettings = 'get_user_settings',
updateUserSettings = 'update_user_settings',

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -112,6 +112,31 @@ defmodule WandererApp.CachedInfo do
end
end
def get_wormhole_classes() do
case WandererApp.Cache.lookup(:wormhole_classes) do
{:ok, nil} ->
wormhole_classes = WandererApp.EveDataService.load_wormhole_classes()
cache_items(wormhole_classes, :wormhole_classes)
{:ok, wormhole_classes}
{:ok, wormhole_classes} ->
{:ok, wormhole_classes}
end
end
def get_wormhole_classes!() do
case get_wormhole_classes() do
{:ok, wormhole_classes} ->
wormhole_classes
error ->
Logger.error("Error loading wormhole classes: #{inspect(error)}")
error
end
end
def get_effects() do
case WandererApp.Cache.lookup(:effects) do
{:ok, nil} ->

View File

@@ -0,0 +1,374 @@
defmodule WandererApp.Character.TrackingUtils do
@moduledoc """
Utility functions for handling character tracking and following operations.
"""
require Logger
alias WandererApp.MapCharacterSettingsRepo
@doc """
Toggles the tracking state for a character on a map.
Returns the updated tracking data for all characters with access to the map.
"""
def toggle_track(map_id, character_id, current_user_id, caller_pid \\ nil, only_tracked_characters \\ false) do
with current_user when not is_nil(current_user) <- WandererApp.User.load(current_user_id),
{:ok, character} <- WandererApp.Character.find_character_by_eve_id(current_user, character_id),
{:ok, map} <- WandererApp.Api.Map.by_id(map_id),
map <- Ash.load!(map, :acls) do
# Check if the character is currently tracked before toggling
{:ok, existing_settings} = MapCharacterSettingsRepo.get_by_map(map_id, character.id)
was_tracked = existing_settings && existing_settings.tracked
# Toggle the tracking state
with {:ok, _updated_settings} <- do_toggle_character_tracking(character, map_id, caller_pid) do
# Get updated tracking data
{:ok, tracking_data} = build_tracking_data(map_id, current_user_id)
# Determine which event to send based on tracking mode and previous state
event = case {only_tracked_characters, was_tracked} do
{true, true} -> :not_all_characters_tracked # Untracking in tracked-only mode
_ -> %{event: :refresh_user_characters} # All other cases
end
{:ok, tracking_data, event}
else
error ->
Logger.error("Failed to toggle tracking: #{inspect(error)}")
{:error, "Failed to toggle tracking"}
end
else
nil ->
Logger.error("User not found when toggling track")
{:error, "User not found"}
error ->
Logger.error("Failed to toggle track: #{inspect(error)}")
{:error, "Failed to toggle track"}
end
end
@doc """
Toggles the follow state for a character on a map.
Returns the updated tracking data for all characters with access to the map.
"""
def toggle_follow(map_id, character_id, current_user_id, caller_pid \\ nil) do
# Get all settings before the operation to see the followed state
{:ok, all_settings_before} = MapCharacterSettingsRepo.get_all_by_map(map_id)
# Check if the clicked character is already followed
is_already_followed =
all_settings_before
|> Enum.any?(fn setting ->
setting.followed && "#{setting.character_id}" == "#{character_id}"
end)
with current_user when not is_nil(current_user) <- WandererApp.User.load(current_user_id),
{:ok, clicked_char} <- WandererApp.Character.find_character_by_eve_id(current_user, character_id),
{:ok, _updated_settings} <- do_toggle_character_follow(map_id, clicked_char, is_already_followed, caller_pid) do
# Get updated tracking data
{:ok, tracking_data} = build_tracking_data(map_id, current_user_id)
# Always send refresh_user_characters for follow operations
{:ok, tracking_data, %{event: :refresh_user_characters}}
else
nil ->
Logger.error("User not found when toggling follow")
{:error, "User not found"}
error ->
Logger.error("Failed to toggle follow: #{inspect(error)}")
{:error, "Failed to toggle follow"}
end
end
@doc """
Builds tracking data for all characters with access to a map.
"""
def build_tracking_data(map_id, current_user_id) do
with current_user when not is_nil(current_user) <- WandererApp.User.load(current_user_id),
{:ok, map} <- WandererApp.Api.Map.by_id(map_id),
map <- Ash.load!(map, :acls),
{:ok, character_settings} <- MapCharacterSettingsRepo.get_all_by_map(map_id),
{:ok, %{characters: characters_with_access}} <-
WandererApp.Maps.load_characters(map, character_settings, current_user.id) do
# Map characters to tracking data
tracking_data = build_character_tracking_data(characters_with_access, character_settings)
# Check for inconsistent state
check_tracking_consistency(tracking_data)
{:ok, tracking_data}
else
nil ->
Logger.warning("User not found when building tracking data", %{user_id: current_user_id})
{:error, "User not found"}
error ->
Logger.error("Error building tracking data: #{inspect(error)}")
{:error, "Failed to build tracking data"}
end
end
# Helper to build tracking data for each character
defp build_character_tracking_data(characters, character_settings) do
Enum.map(characters, fn char ->
setting = Enum.find(character_settings, &(&1.character_id == char.id))
%{
character: char |> WandererAppWeb.MapEventHandler.map_ui_character_stat(),
tracked: setting && setting.tracked || false,
followed: setting && setting.followed || false
}
end)
end
# Helper to check for inconsistent tracking state
defp check_tracking_consistency(tracking_data) do
followed_in_data = Enum.find(tracking_data, &(&1.followed))
followed_but_not_tracked = followed_in_data && !Enum.find(tracking_data, &(&1.followed && &1.tracked))
if followed_but_not_tracked do
Logger.warning("Inconsistent state detected: Character is followed but not tracked", %{
character_id: followed_in_data.character.eve_id
})
end
end
# Private implementation of toggle character tracking
defp do_toggle_character_tracking(character, map_id, caller_pid) do
with false <- is_nil(caller_pid),
{:ok, existing_settings} <- MapCharacterSettingsRepo.get_by_map(map_id, character.id) do
case existing_settings.tracked do
# Untracking flow
true ->
with {:ok, updated_settings} <- untrack_character_settings(existing_settings),
:ok <- untrack_characters([character], map_id, caller_pid),
:ok <- remove_characters([character], map_id) do
{:ok, updated_settings}
end
# Tracking flow
false ->
with {:ok, updated_settings} <- MapCharacterSettingsRepo.track(existing_settings),
:ok <- track_characters([character], map_id, true, caller_pid),
:ok <- add_characters([character], map_id, true) do
{:ok, updated_settings}
end
end
else
true ->
Logger.error("caller_pid is required for toggling character tracking")
{:error, "caller_pid is required"}
{:error, :not_found} ->
# Create new settings with tracking enabled
create_character_settings(character.id, map_id, true, false, caller_pid)
error -> error
end
end
# Helper to untrack character settings, handling the followed state
defp untrack_character_settings(settings) do
case settings.followed do
true ->
# First unfollow, then untrack
with {:ok, unfollowed_settings} <- MapCharacterSettingsRepo.unfollow(settings) do
MapCharacterSettingsRepo.untrack(unfollowed_settings)
end
false ->
# Just untrack
MapCharacterSettingsRepo.untrack(settings)
end
end
# Private implementation of toggle character follow
defp do_toggle_character_follow(map_id, clicked_char, is_already_followed, caller_pid) do
with false <- is_nil(caller_pid),
{:ok, clicked_char_settings} <- MapCharacterSettingsRepo.get_by_map(map_id, clicked_char.id) do
case {is_already_followed, clicked_char_settings.tracked} do
# Case 1: Already followed and not tracked - unfollow and remove
{true, false} ->
with {:ok, _} <- MapCharacterSettingsRepo.unfollow(clicked_char_settings),
:ok <- remove_characters([clicked_char], map_id) do
{:ok, clicked_char_settings}
end
# Case 2: Already followed and tracked - just unfollow
{true, true} ->
MapCharacterSettingsRepo.unfollow(clicked_char_settings)
# Case 3: Not followed - ensure tracked and followed
{false, _} ->
ensure_character_tracked_and_followed(map_id, clicked_char, clicked_char_settings, caller_pid)
end
else
true ->
Logger.error("caller_pid is required for toggling character following")
{:error, "caller_pid is required"}
{:error, :not_found} ->
# Create new settings with both tracking and following enabled
create_character_settings(clicked_char.id, map_id, true, true, caller_pid, clicked_char)
end
end
# Consolidated helper function to ensure a character is tracked when followed
defp ensure_character_tracked_and_followed(map_id, character, settings, caller_pid) do
with false <- is_nil(caller_pid) do
# Toggle the followed state
followed = !settings.followed
case {followed, settings.tracked} do
# Case 1: Following and not tracked - need to track and follow
{true, false} ->
# Unfollow all others first
:ok = maybe_unfollow_others(map_id, character.id, true)
# Track and follow
with {:ok, tracked_settings} <- MapCharacterSettingsRepo.track(settings),
{:ok, updated_settings} <- update_follow(tracked_settings, true),
:ok <- track_characters([character], map_id, true, caller_pid),
:ok <- add_characters([character], map_id, true) do
{:ok, updated_settings}
end
# Case 2: Following and already tracked - just follow
{true, true} ->
# Unfollow all others first
:ok = maybe_unfollow_others(map_id, character.id, true)
# Update follow status
update_follow(settings, true)
# Case 3: Unfollowing - just update follow status
{false, _} ->
update_follow(settings, false)
end
else
true ->
Logger.error("caller_pid is required for ensuring character is tracked and followed")
{:error, "caller_pid is required"}
end
end
# Helper function to create character settings with specified tracking and following states
defp create_character_settings(character_id, map_id, tracked, followed, caller_pid, character \\ nil) do
with false <- is_nil(caller_pid) do
# Unfollow others if needed
case followed do
true -> :ok = maybe_unfollow_others(map_id, character_id, true)
false -> :ok
end
# Create the settings
{:ok, settings} = MapCharacterSettingsRepo.create(%{
character_id: character_id,
map_id: map_id,
tracked: tracked,
followed: followed
})
# Handle tracking based on character presence and tracking flag
case {character, tracked} do
{nil, _} ->
# No character provided, just return settings
{:ok, settings}
{_, false} ->
# Character provided but not tracking
{:ok, settings}
{_, true} ->
# Character provided and tracking enabled
with :ok <- track_characters([character], map_id, true, caller_pid),
:ok <- add_characters([character], map_id, true) do
{:ok, settings}
end
end
else
true ->
Logger.error("caller_pid is required for creating character settings")
{:error, "caller_pid is required"}
end
end
defp maybe_unfollow_others(_map_id, _char_id, false), do: :ok
defp maybe_unfollow_others(map_id, char_id, true) do
# Unfollow all other characters when setting a character as followed
with {:ok, all_settings} <- MapCharacterSettingsRepo.get_all_by_map(map_id) do
all_settings
|> Enum.filter(&(&1.character_id != char_id && &1.followed))
|> Enum.each(&MapCharacterSettingsRepo.unfollow/1)
:ok
end
end
defp update_follow(settings, true), do: MapCharacterSettingsRepo.follow(settings)
defp update_follow(settings, false), do: MapCharacterSettingsRepo.unfollow(settings)
# Helper functions for character tracking
def track_characters(_, _, false, _), do: :ok
def track_characters([], _map_id, _is_track_character?, _), do: :ok
def track_characters([character | characters], map_id, true, caller_pid) do
with :ok <- track_character(character, map_id, caller_pid) do
track_characters(characters, map_id, true, caller_pid)
end
end
def track_character(%{id: character_id, eve_id: eve_id, corporation_id: corporation_id, alliance_id: alliance_id}, map_id, caller_pid) do
with false <- is_nil(caller_pid) do
WandererAppWeb.Presence.track(caller_pid, map_id, character_id, %{})
cache_key = "#{inspect(caller_pid)}_map_#{map_id}:character_#{character_id}:tracked"
case WandererApp.Cache.lookup!(cache_key, false) do
true ->
:ok
_ ->
:ok = Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{eve_id}")
:ok = WandererApp.Cache.put(cache_key, true)
end
:ok = WandererApp.Character.TrackerManager.start_tracking(character_id)
else
true ->
Logger.error("caller_pid is required for tracking characters")
{:error, "caller_pid is required"}
end
end
def untrack_characters(characters, map_id, caller_pid) do
with false <- is_nil(caller_pid) do
characters
|> Enum.each(fn character ->
WandererAppWeb.Presence.untrack(caller_pid, map_id, character.id)
WandererApp.Cache.put("#{inspect(caller_pid)}_map_#{map_id}:character_#{character.id}:tracked", false)
:ok = Phoenix.PubSub.unsubscribe(WandererApp.PubSub, "character:#{character.eve_id}")
end)
:ok
else
true ->
Logger.error("caller_pid is required for untracking characters")
{:error, "caller_pid is required"}
end
end
def add_characters([], _map_id, _track_character), do: :ok
def add_characters([character | characters], map_id, track_character) do
:ok = WandererApp.Map.Server.add_character(map_id, character, track_character)
add_characters(characters, map_id, track_character)
end
def remove_characters([], _map_id), do: :ok
def remove_characters([character | characters], map_id) do
:ok = WandererApp.Map.Server.remove_character(map_id, character.id)
remove_characters(characters, map_id)
end
end

View File

@@ -15,13 +15,14 @@ defmodule WandererAppWeb.CharactersAPIController do
items: %OpenApiSpex.Schema{
type: :object,
properties: %{
id: %OpenApiSpex.Schema{type: :string},
eve_id: %OpenApiSpex.Schema{type: :string},
name: %OpenApiSpex.Schema{type: :string},
corporation_name: %OpenApiSpex.Schema{type: :string},
alliance_name: %OpenApiSpex.Schema{type: :string}
corporation_id: %OpenApiSpex.Schema{type: :string},
corporation_ticker: %OpenApiSpex.Schema{type: :string},
alliance_id: %OpenApiSpex.Schema{type: :string},
alliance_ticker: %OpenApiSpex.Schema{type: :string}
},
required: ["id", "eve_id", "name"]
required: ["eve_id", "name"]
}
}
},
@@ -47,13 +48,7 @@ defmodule WandererAppWeb.CharactersAPIController do
{:ok, characters} ->
result =
characters
|> Enum.map(&%{
id: &1.id,
eve_id: &1.eve_id,
name: &1.name,
corporation_name: &1.corporation_name,
alliance_name: &1.alliance_name
})
|> Enum.map(&WandererAppWeb.MapEventHandler.map_ui_character_stat/1)
json(conn, %{data: result})

View File

@@ -4,6 +4,7 @@ defmodule WandererAppWeb.CommonAPIController do
alias WandererApp.CachedInfo
alias WandererAppWeb.UtilAPIController, as: Util
alias WandererApp.EveDataService
@system_static_response_schema %OpenApiSpex.Schema{
type: :object,
@@ -26,6 +27,32 @@ defmodule WandererAppWeb.CommonAPIController do
effect_name: %OpenApiSpex.Schema{type: :string},
effect_power: %OpenApiSpex.Schema{type: :integer},
statics: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}},
static_details: %OpenApiSpex.Schema{
type: :array,
items: %OpenApiSpex.Schema{
type: :object,
properties: %{
name: %OpenApiSpex.Schema{type: :string},
destination: %OpenApiSpex.Schema{
type: :object,
properties: %{
id: %OpenApiSpex.Schema{type: :string},
name: %OpenApiSpex.Schema{type: :string},
short_name: %OpenApiSpex.Schema{type: :string}
}
},
properties: %OpenApiSpex.Schema{
type: :object,
properties: %{
lifetime: %OpenApiSpex.Schema{type: :string},
max_mass: %OpenApiSpex.Schema{type: :integer},
max_jump_mass: %OpenApiSpex.Schema{type: :integer},
mass_regeneration: %OpenApiSpex.Schema{type: :integer}
}
}
}
}
},
wandering: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}},
triglavian_invasion_status: %OpenApiSpex.Schema{type: :string},
sun_type_id: %OpenApiSpex.Schema{type: :integer}
@@ -64,8 +91,14 @@ defmodule WandererAppWeb.CommonAPIController do
{:ok, solar_system_id} <- Util.parse_int(solar_system_str) do
case CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system} ->
# Get basic system data
data = static_system_to_json(system)
json(conn, %{data: data})
# Enhance with wormhole type information if statics exist
enhanced_data = enhance_with_static_details(data)
# Return the enhanced data
json(conn, %{data: enhanced_data})
{:error, :not_found} ->
conn
@@ -80,6 +113,11 @@ defmodule WandererAppWeb.CommonAPIController do
end
end
@doc """
Converts a system map to a JSON-friendly format.
Takes only the fields that are needed for the API response.
"""
defp static_system_to_json(system) do
system
|> Map.take([
@@ -103,4 +141,97 @@ defmodule WandererAppWeb.CommonAPIController do
:sun_type_id
])
end
@doc """
Enhances system data with wormhole type information.
If the system has static wormholes, adds detailed information about each static.
Otherwise, returns the original data unchanged.
"""
defp enhance_with_static_details(data) do
if data[:statics] && length(data[:statics]) > 0 do
# Add the enhanced static details to the response
Map.put(data, :static_details, get_static_details(data[:statics]))
else
# No statics, return the original data
data
end
end
@doc """
Gets detailed information for each static wormhole.
Uses the CachedInfo to get both wormhole type data and wormhole class data.
"""
defp get_static_details(statics) do
# Get wormhole data from CachedInfo
{:ok, wormhole_types} = CachedInfo.get_wormhole_types()
wormhole_classes = CachedInfo.get_wormhole_classes!()
# Create a map of wormhole classes by ID for quick lookup
classes_by_id = Enum.reduce(wormhole_classes, %{}, fn class, acc ->
Map.put(acc, class.id, class)
end)
# Find detailed information for each static
Enum.map(statics, fn static_name ->
# Find the wormhole type by name
wh_type = Enum.find(wormhole_types, fn type -> type.name == static_name end)
if wh_type do
create_wormhole_details(wh_type, classes_by_id)
else
create_fallback_wormhole_details(static_name)
end
end)
end
@doc """
Creates detailed wormhole information when the wormhole type is found.
Includes information about the destination and properties of the wormhole.
Ensures that destination.id is always a string to match the OpenAPI schema.
"""
defp create_wormhole_details(wh_type, classes_by_id) do
# Get destination class info
dest_class = Map.get(classes_by_id, wh_type.dest)
# Create enhanced static info
%{
name: wh_type.name,
destination: %{
id: to_string(wh_type.dest),
name: (if dest_class, do: dest_class.title, else: wh_type.dest),
short_name: (if dest_class, do: dest_class.short_name, else: wh_type.dest)
},
properties: %{
lifetime: wh_type.lifetime,
max_mass: wh_type.total_mass,
max_jump_mass: wh_type.max_mass_per_jump,
mass_regeneration: wh_type.mass_regen
}
}
end
@doc """
Creates fallback information when a wormhole type is not found.
Provides a placeholder structure with nil values for unknown wormhole types.
"""
defp create_fallback_wormhole_details(static_name) do
%{
name: static_name,
destination: %{
id: nil,
name: "Unknown",
short_name: "?"
},
properties: %{
lifetime: nil,
max_mass: nil,
max_jump_mass: nil,
mass_regeneration: nil
}
}
end
end

View File

@@ -65,19 +65,14 @@ defmodule WandererAppWeb.MapAPIController do
@character_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
id: %OpenApiSpex.Schema{type: :string},
eve_id: %OpenApiSpex.Schema{type: :string},
name: %OpenApiSpex.Schema{type: :string},
corporation_id: %OpenApiSpex.Schema{type: :string},
corporation_name: %OpenApiSpex.Schema{type: :string},
corporation_ticker: %OpenApiSpex.Schema{type: :string},
alliance_id: %OpenApiSpex.Schema{type: :string},
alliance_name: %OpenApiSpex.Schema{type: :string},
alliance_ticker: %OpenApiSpex.Schema{type: :string},
inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time},
updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time}
alliance_ticker: %OpenApiSpex.Schema{type: :string}
},
required: ["id", "eve_id", "name"]
required: ["eve_id", "name"]
}
@tracked_char_schema %OpenApiSpex.Schema{
@@ -174,6 +169,31 @@ defmodule WandererAppWeb.MapAPIController do
required: ["data"]
}
# For operation :character_activity
@character_activity_item_schema %OpenApiSpex.Schema{
type: :object,
description: "Character activity data",
properties: %{
character: @character_schema,
passages: %OpenApiSpex.Schema{type: :integer, description: "Number of passages through systems"},
connections: %OpenApiSpex.Schema{type: :integer, description: "Number of connections created"},
signatures: %OpenApiSpex.Schema{type: :integer, description: "Number of signatures added"},
timestamp: %OpenApiSpex.Schema{type: :string, format: :date_time, description: "Timestamp of the activity"}
},
required: ["character", "passages", "connections", "signatures"]
}
@character_activity_response_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
data: %OpenApiSpex.Schema{
type: :array,
items: @character_activity_item_schema
}
},
required: ["data"]
}
# -----------------------------------------------------------------
# MAP endpoints
# -----------------------------------------------------------------
@@ -622,6 +642,107 @@ defmodule WandererAppWeb.MapAPIController do
end
end
@doc """
GET /api/map/character_activity
Returns character activity data for a map.
Requires either `?map_id=<UUID>` or `?slug=<map-slug>`.
Example:
GET /api/map/character_activity?map_id=<uuid>
GET /api/map/character_activity?slug=<map-slug>
"""
@spec character_activity(Plug.Conn.t(), map()) :: Plug.Conn.t()
operation :character_activity,
summary: "Get Character Activity",
description: "Returns character activity data for a map. Requires either 'map_id' or 'slug' as a query parameter to identify the map.",
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided",
type: :string,
required: false,
example: ""
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided",
type: :string,
required: false,
example: "map-name"
]
],
responses: [
ok: {
"Character activity data",
"application/json",
@character_activity_response_schema
},
bad_request: {"Error", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
required: ["error"],
example: %{
"error" => "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter"
}
}}
]
def character_activity(conn, params) do
with {:ok, map_id} <- Util.fetch_map_id(params) do
# Get raw activity data directly from the Map module instead of the Activity processor
raw_activity = WandererApp.Map.get_character_activity(map_id)
# Group activities by user_id and summarize
summarized_result =
if raw_activity == [] do
# Return empty list if there's no data
[]
else
raw_activity
|> Enum.group_by(fn activity ->
# Get user_id from the character
activity.character.user_id
end)
|> Enum.map(fn {_user_id, user_activities} ->
# Get the most active or followed character for this user
representative_activity =
user_activities
|> Enum.max_by(fn activity ->
activity.passages + activity.connections + activity.signatures
end)
# Sum up all activities for this user
total_passages = Enum.sum(Enum.map(user_activities, & &1.passages))
total_connections = Enum.sum(Enum.map(user_activities, & &1.connections))
total_signatures = Enum.sum(Enum.map(user_activities, & &1.signatures))
# Return summarized activity with the mapped character
%{
character: character_to_json(representative_activity.character),
passages: total_passages,
connections: total_connections,
signatures: total_signatures,
timestamp: representative_activity.timestamp
}
end)
end
json(conn, %{data: summarized_result})
else
{:error, msg} when is_binary(msg) ->
conn
|> put_status(:bad_request)
|> json(%{error: msg})
{:error, reason} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Could not fetch character activity: #{inspect(reason)}"})
end
end
# If hours_str is present and valid, parse it. Otherwise return nil (no filter).
defp parse_hours_ago(nil), do: nil
@@ -830,18 +951,6 @@ defmodule WandererAppWeb.MapAPIController do
end
defp character_to_json(ch) do
Map.take(ch, [
:id,
:eve_id,
:name,
:corporation_id,
:corporation_name,
:corporation_ticker,
:alliance_id,
:alliance_name,
:alliance_ticker,
:inserted_at,
:updated_at
])
WandererAppWeb.MapEventHandler.map_ui_character_stat(ch)
end
end

View File

@@ -73,11 +73,11 @@ defmodule WandererAppWeb.CharactersTrackingLive do
@impl true
def handle_event("toggle_track_" <> character_id, _, socket) do
handle_event("toggle_track", %{"character-id" => character_id}, socket)
handle_event("toggle_track", %{"character_id" => character_id}, socket)
end
@impl true
def handle_event("toggle_track", %{"character-id" => character_id}, socket) do
def handle_event("toggle_track", %{"character_id" => character_id}, socket) do
selected_map = socket.assigns.selected_map
character_settings = socket.assigns.character_settings

View File

@@ -45,7 +45,7 @@
type="checkbox"
class="checkbox"
phx-click="toggle_track"
phx-value-character-id={character.id}
phx-value-character_id={character.id}
id={"character-track-#{character.id}"}
checked={character.tracked}
/>

View File

@@ -117,7 +117,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
def handle_ui_event(
"toggle_track",
%{"character-id" => character_eve_id},
%{"character_id" => character_eve_id},
%{
assigns: %{
map_id: map_id,
@@ -126,54 +126,22 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
}
} = socket
) do
# First, get all existing settings to preserve states
{:ok, all_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
case WandererApp.Character.TrackingUtils.toggle_track(map_id, character_eve_id, current_user.id, self(), only_tracked_characters) do
{:ok, tracking_data, event} ->
# Send the appropriate event based on the result from toggle_track
Process.send_after(self(), event, 10)
# Save the followed character ID before making any changes
followed_character_id =
all_settings
|> Enum.find(& &1.followed)
|> case do
nil -> nil
setting -> setting.character_id
end
# Send the updated tracking data to the client
{:noreply,
socket
|> MapEventHandler.push_map_event(
"tracking_characters_data",
%{characters: tracking_data}
)}
# Find the character we're toggling
with {:ok, character} <-
WandererApp.Character.find_character_by_eve_id(current_user, character_eve_id),
{:ok, _updated_settings} <-
toggle_character_tracking(character, map_id, only_tracked_characters) do
# Get the map with ACLs
{:ok, map} = WandererApp.Api.Map.by_id(map_id)
map = Ash.load!(map, :acls)
# If there was a followed character before, check if it's still followed
# Only check if we're not toggling the followed character itself
if followed_character_id && followed_character_id != character.id do
# Get the current settings for the followed character
case WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, followed_character_id) do
{:ok, current_settings} ->
# If it's not followed anymore, follow it again
if !current_settings.followed do
{:ok, _} = WandererApp.MapCharacterSettingsRepo.follow(current_settings)
end
_ ->
:ok
end
end
{:ok, tracking_data} = build_tracking_data(map_id, current_user)
{:noreply,
socket
|> MapEventHandler.push_map_event(
"tracking_characters_data",
%{characters: tracking_data}
)}
else
_ ->
{:noreply, socket}
{:error, reason} ->
Logger.error("Failed to toggle track: #{inspect(reason)}")
{:noreply, socket |> put_flash(:error, "Failed to toggle character tracking")}
end
end
@@ -183,48 +151,38 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
%{assigns: %{map_id: map_id, current_user: current_user}} = socket
) do
# Create tracking data for characters with access to the map
{:ok, tracking_data} = build_tracking_data(map_id, current_user)
case WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user.id) do
{:ok, tracking_data} ->
{:noreply,
socket
|> MapEventHandler.push_map_event(
"tracking_characters_data",
%{characters: tracking_data}
)}
{:noreply,
socket
|> MapEventHandler.push_map_event(
"show_tracking",
%{}
)
|> MapEventHandler.push_map_event(
"tracking_characters_data",
%{characters: tracking_data}
)}
{:error, reason} ->
Logger.error("Failed to load tracking data: #{inspect(reason)}")
{:noreply, socket |> put_flash(:error, "Failed to load tracking data")}
end
end
def handle_ui_event(
"toggle_follow",
%{"character-id" => clicked_char_id},
%{"character_id" => clicked_char_id},
%{assigns: %{current_user: current_user, map_id: map_id}} = socket
) do
# Get all settings before the operation to see the followed state
{:ok, all_settings_before} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
followed_before = all_settings_before |> Enum.find(& &1.followed)
case WandererApp.Character.TrackingUtils.toggle_follow(map_id, clicked_char_id, current_user.id, self()) do
{:ok, tracking_data, event} ->
# Send the appropriate event based on the result from toggle_follow
Process.send_after(self(), event, 10)
# Check if the clicked character is already followed
is_already_followed =
followed_before && "#{followed_before.character_id}" == "#{clicked_char_id}"
{:noreply,
socket
|> MapEventHandler.push_map_event("tracking_characters_data", %{characters: tracking_data})}
# Use find_character_by_eve_id from WandererApp.Character
with {:ok, clicked_char} <-
WandererApp.Character.find_character_by_eve_id(current_user, clicked_char_id),
{:ok, _updated_settings} <-
toggle_character_follow(map_id, clicked_char, is_already_followed) do
# Build tracking data
{:ok, tracking_data} = build_tracking_data(map_id, current_user)
{:noreply,
socket
|> MapEventHandler.push_map_event("tracking_characters_data", %{characters: tracking_data})}
else
error ->
Logger.error("Failed to toggle follow: #{inspect(error)}")
{:noreply, socket}
{:error, reason} ->
Logger.error("Failed to toggle follow: #{inspect(reason)}")
{:noreply, socket |> put_flash(:error, "Failed to toggle character following")}
end
end
@@ -251,133 +209,6 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
|> Map.put_new(:ship, WandererApp.Character.get_ship(character))
|> Map.put_new(:location, get_location(character))
def add_characters([], _map_id, _track_character), do: :ok
def add_characters([character | characters], map_id, track_character) do
map_id
|> WandererApp.Map.Server.add_character(character, track_character)
add_characters(characters, map_id, track_character)
end
def remove_characters([], _map_id), do: :ok
def remove_characters([character | characters], map_id) do
map_id
|> WandererApp.Map.Server.remove_character(character.id)
remove_characters(characters, map_id)
end
def untrack_characters(characters, map_id) do
characters
|> Enum.each(fn character ->
WandererAppWeb.Presence.untrack(self(), map_id, character.id)
WandererApp.Cache.put(
"#{inspect(self())}_map_#{map_id}:character_#{character.id}:tracked",
false
)
:ok =
Phoenix.PubSub.unsubscribe(
WandererApp.PubSub,
"character:#{character.eve_id}"
)
end)
end
def track_characters(_, _, false), do: :ok
def track_characters([], _map_id, _is_track_character?), do: :ok
def track_characters(
[character | characters],
map_id,
true
) do
track_character(character, map_id)
track_characters(characters, map_id, true)
end
def track_character(
%{
id: character_id,
eve_id: eve_id,
corporation_id: corporation_id,
alliance_id: alliance_id
},
map_id
) do
WandererAppWeb.Presence.track(self(), map_id, character_id, %{})
case WandererApp.Cache.lookup!(
"#{inspect(self())}_map_#{map_id}:character_#{character_id}:tracked",
false
) do
true ->
:ok
_ ->
:ok =
Phoenix.PubSub.subscribe(
WandererApp.PubSub,
"character:#{eve_id}"
)
:ok =
WandererApp.Cache.put(
"#{inspect(self())}_map_#{map_id}:character_#{character_id}:tracked",
true
)
end
case WandererApp.Cache.lookup(
"#{inspect(self())}_map_#{map_id}:corporation_#{corporation_id}:tracked",
false
) do
{:ok, true} ->
:ok
{:ok, false} ->
:ok =
Phoenix.PubSub.subscribe(
WandererApp.PubSub,
"corporation:#{corporation_id}"
)
:ok =
WandererApp.Cache.put(
"#{inspect(self())}_map_#{map_id}:corporation_#{corporation_id}:tracked",
true
)
end
case WandererApp.Cache.lookup(
"#{inspect(self())}_map_#{map_id}:alliance_#{alliance_id}:tracked",
false
) do
{:ok, true} ->
:ok
{:ok, false} ->
:ok =
Phoenix.PubSub.subscribe(
WandererApp.PubSub,
"alliance:#{alliance_id}"
)
:ok =
WandererApp.Cache.put(
"#{inspect(self())}_map_#{map_id}:alliance_#{alliance_id}:tracked",
true
)
end
:ok = WandererApp.Character.TrackerManager.start_tracking(character_id)
end
defp get_location(character),
do: %{solar_system_id: character.solar_system_id, structure_id: character.structure_id}
@@ -461,8 +292,8 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
end
defp handle_tracking_event({:track_characters, map_characters, track_character}, socket, map_id) do
:ok = track_characters(map_characters, map_id, track_character)
:ok = add_characters(map_characters, map_id, track_character)
:ok = WandererApp.Character.TrackingUtils.track_characters(map_characters, map_id, track_character, self())
:ok = WandererApp.Character.TrackingUtils.add_characters(map_characters, map_id, track_character)
socket
end
@@ -555,165 +386,4 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
:character_fleet -> handle_fleet_result(socket, result)
end
end
defp toggle_character_follow(map_id, clicked_char, is_already_followed) do
with {:ok, clicked_char_settings} <-
WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, clicked_char.id) do
if is_already_followed do
# If already followed, just unfollow without affecting other characters
WandererApp.MapCharacterSettingsRepo.unfollow(clicked_char_settings)
else
# Normal follow toggle
update_follow_status(map_id, clicked_char, clicked_char_settings)
end
else
{:error, :not_found} ->
# Character not found in settings, create new settings
update_follow_status(map_id, clicked_char, nil)
end
end
defp update_follow_status(map_id, clicked_char, nil) do
# Create new settings with tracked=true and followed=true
# If we're following this character, unfollow all others first
:ok = maybe_unfollow_others(map_id, clicked_char.id, true)
result =
WandererApp.MapCharacterSettingsRepo.create(%{
character_id: clicked_char.id,
map_id: map_id,
tracked: true,
followed: true
})
result
end
defp update_follow_status(map_id, clicked_char, clicked_char_settings) do
# Toggle the followed state
followed = !clicked_char_settings.followed
# Only unfollow other characters if we're explicitly following this character
# This prevents unfollowing other characters when just tracking a character
if followed do
# We're following this character, so unfollow all others
:ok = maybe_unfollow_others(map_id, clicked_char.id, followed)
end
# If we're following, make sure the character is also tracked
:ok = maybe_track_character(clicked_char_settings, followed)
# Update the follow status
{:ok, settings} = update_follow(clicked_char_settings, followed)
{:ok, settings}
end
defp maybe_unfollow_others(_map_id, _char_id, false), do: :ok
defp maybe_unfollow_others(map_id, char_id, true) do
# This function should only be called when explicitly following a character,
# not when tracking a character. It unfollows all other characters when
# setting a character as followed.
{:ok, all_settings} = WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)
# Unfollow other characters
all_settings
|> Enum.filter(&(&1.character_id != char_id && &1.followed))
|> Enum.each(fn setting ->
WandererApp.MapCharacterSettingsRepo.unfollow(setting)
end)
:ok
end
defp maybe_track_character(_settings, false), do: :ok
defp maybe_track_character(settings, true) do
if not settings.tracked do
{:ok, _} = WandererApp.MapCharacterSettingsRepo.track(settings)
end
:ok
end
defp update_follow(settings, true), do: WandererApp.MapCharacterSettingsRepo.follow(settings)
defp update_follow(settings, false), do: WandererApp.MapCharacterSettingsRepo.unfollow(settings)
defp build_tracking_data(map_id, current_user) do
with {:ok, map} <- WandererApp.Api.Map.by_id(map_id),
map <- Ash.load!(map, :acls),
{:ok, character_settings} <- WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id),
{:ok, %{characters: characters_with_access}} <-
WandererApp.Maps.load_characters(map, character_settings, current_user.id) do
tracking_data =
Enum.map(characters_with_access, fn char ->
setting = Enum.find(character_settings, &(&1.character_id == char.id))
tracked = if setting, do: setting.tracked, else: false
# Important: Preserve the followed state
followed = if setting, do: setting.followed, else: false
%{
character: char |> MapEventHandler.map_ui_character_stat(),
tracked: tracked,
followed: followed
}
end)
{:ok, tracking_data}
end
end
# Helper function to toggle character tracking
defp toggle_character_tracking(character, map_id, only_tracked_characters) do
case WandererApp.MapCharacterSettingsRepo.get_by_map(map_id, character.id) do
{:ok, existing_settings} ->
if existing_settings.tracked do
# Untrack the character
{:ok, updated_settings} =
WandererApp.MapCharacterSettingsRepo.untrack(existing_settings)
:ok = untrack_characters([character], map_id)
:ok = remove_characters([character], map_id)
if only_tracked_characters do
Process.send_after(self(), :not_all_characters_tracked, 10)
end
# If the character was followed, we need to unfollow it too
# But we should NOT unfollow other characters
if existing_settings.followed do
{:ok, final_settings} =
WandererApp.MapCharacterSettingsRepo.unfollow(updated_settings)
{:ok, final_settings}
else
{:ok, updated_settings}
end
else
# Track the character
{:ok, updated_settings} =
WandererApp.MapCharacterSettingsRepo.track(existing_settings)
:ok = track_characters([character], map_id, true)
:ok = add_characters([character], map_id, true)
Process.send_after(self(), %{event: :refresh_user_characters}, 10)
{:ok, updated_settings}
end
{:error, :not_found} ->
# Create new settings
result =
WandererApp.MapCharacterSettingsRepo.create(%{
character_id: character.id,
map_id: map_id,
tracked: true,
followed: false
})
result
end
end
end

View File

@@ -56,14 +56,14 @@ defmodule WandererAppWeb.MapCoreEventHandler do
case track_character do
false ->
:ok = MapCharactersEventHandler.untrack_characters(map_characters, map_id)
:ok = MapCharactersEventHandler.remove_characters(map_characters, map_id)
:ok = WandererApp.Character.TrackingUtils.untrack_characters(map_characters, map_id, self())
:ok = WandererApp.Character.TrackingUtils.remove_characters(map_characters, map_id)
_ ->
:ok = MapCharactersEventHandler.track_characters(map_characters, map_id, true)
:ok = WandererApp.Character.TrackingUtils.track_characters(map_characters, map_id, true, self())
:ok =
MapCharactersEventHandler.add_characters(map_characters, map_id, track_character)
WandererApp.Character.TrackingUtils.add_characters(map_characters, map_id, track_character)
end
socket

View File

@@ -29,7 +29,6 @@ defmodule WandererAppWeb.MapEventHandler do
@map_characters_ui_events [
"toggle_track",
"toggle_follow",
"hide_tracking",
"show_tracking",
"getCharacterInfo"
]
@@ -304,13 +303,13 @@ defmodule WandererAppWeb.MapEventHandler do
} = socket,
type,
body
),
do:
socket
|> Phoenix.LiveView.Utils.push_event("map_event", %{
type: type,
body: body
})
) do
socket
|> Phoenix.LiveView.Utils.push_event("map_event", %{
type: type,
body: body
})
end
def push_map_event(socket, _type, _body), do: socket

View File

@@ -210,6 +210,7 @@ defmodule WandererAppWeb.Router do
get "/system", MapAPIController, :show_system
get "/characters", MapAPIController, :tracked_characters_with_info
get "/structure-timers", MapAPIController, :show_structure_timers
get "/character-activity", MapAPIController, :character_activity
get "/acls", MapAccessListAPIController, :index
post "/acls", MapAccessListAPIController, :create
end

View File

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

View File

@@ -19,6 +19,7 @@ With these APIs, you can:
- Retrieve map data, including systems and their properties
- Access system static information
- Track character locations and activities
- View character activity metrics (passages, connections, signatures)
- Monitor kill activity in systems
- Manage Access Control Lists (ACLs) for permissions
- Add, update, and remove ACL members
@@ -245,17 +246,12 @@ curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
{
"id": "<REDACTED_ID>",
"character": {
"id": "<REDACTED_ID>",
"eve_id": "<REDACTED_EVE_ID>",
"name": "<REDACTED_NAME>",
"inserted_at": "2025-01-01T05:24:18.461721Z",
"updated_at": "2025-01-03T07:45:52.294052Z",
"alliance_id": "<REDACTED>",
"alliance_name": "<REDACTED>",
"alliance_ticker": "<REDACTED>",
"corporation_id": "<REDACTED>",
"corporation_name": "<REDACTED>",
"corporation_ticker": "<REDACTED>",
"eve_id": "<REDACTED>"
"corporation_id": "<REDACTED_CORP_ID>",
"corporation_ticker": "<REDACTED_CORP_TICKER>",
"alliance_id": "<REDACTED_ALLIANCE_ID>",
"alliance_ticker": "<REDACTED_ALLIANCE_TICKER>"
},
"tracked": true,
"map_id": "<REDACTED_ID>"
@@ -337,7 +333,51 @@ curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
}
```
### 6. Structure Timers
### 6. Character Activity
```bash
GET /api/map/character-activity?map_id=<UUID>
GET /api/map/character-activity?slug=<map-slug>
```
- **Description:** Retrieves character activity data for a map, including passages, connections, and signatures.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_id` (optional if `slug` is provided) — the UUID of the map.
- `slug` (optional if `map_id` is provided) — the slug identifier of the map.
#### Example Request
```bash
curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
"https://wanderer.example.com/api/map/character-activity?slug=some-slug"
```
#### Example Response
```json
{
"data": [
{
"character": {
"eve_id": "<REDACTED_EVE_ID>",
"name": "<REDACTED_NAME>",
"corporation_id": "<REDACTED_CORP_ID>",
"corporation_ticker": "<REDACTED_CORP_TICKER>",
"alliance_id": "<REDACTED_ALLIANCE_ID>",
"alliance_ticker": "<REDACTED_ALLIANCE_TICKER>"
},
"passages": 42,
"connections": 15,
"signatures": 23,
"timestamp": "2025-03-01T14:30:22Z"
},
...
]
}
```
### 7. Structure Timers
```bash
GET /api/map/structure-timers?map_id=<UUID>
@@ -377,18 +417,20 @@ curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
{
"data": [
{
"id": "b374d9e6-47a7-4e20-85ad-d608809827b5",
"name": "Some Character",
"eve_id": "2122825111",
"corporation_name": "School of Applied Knowledge",
"alliance_name": null
"name": "Some Character",
"corporation_id": "1000044",
"corporation_ticker": "SAOK",
"alliance_id": null,
"alliance_ticker": null
},
{
"id": "6963bee6-eaa1-40e2-8200-4bc2fcbd7350",
"name": "Other Character",
"eve_id": "2122019111",
"corporation_name": "Some Corporation",
"alliance_name": null
"name": "Other Character",
"corporation_id": "98140648",
"corporation_ticker": "CORP",
"alliance_id": "99013806",
"alliance_ticker": "ALLY"
},
...
]
@@ -828,6 +870,7 @@ This guide provides a comprehensive overview of Wanderer's API capabilities. Wit
3. **Access system information** with or without authentication
4. **Manage Access Control Lists (ACLs)** for permissions
5. **Add, update, and remove ACL members** with different roles
6. **View character activity metrics** including passages, connections, and signatures
For the most up-to-date and interactive documentation, we recommend using the Swagger UI at `/swaggerui` which allows you to explore and test endpoints directly from your browser.

View File

@@ -0,0 +1,366 @@
%{
title: "Get Real-Time Notifications with Wanderer Notifier",
author: "Wanderer Team",
cover_image_uri: "/images/news/03-18-bots/dashboard.png",
tags: ~w(notifier discord notifications docker user-guide),
description: "Download and run Wanderer Notifier to receive real-time notifications in your Discord channel. Learn how to get started with our Docker image and discover the different alerts you'll receive."
}
---
# Get Real-Time Notifications with Wanderer Notifier
Wanderer Notifier delivers real-time alerts directly to your Discord channel, ensuring you never miss critical in-game events. Whether it's a significant kill, a newly tracked character, or a fresh system discovery, our notifier keeps you informed with rich, detailed notifications.
In the fast-paced universe of EVE Online, timely information can mean the difference between success and failure. When a hostile fleet enters your territory, when a high-value target appears in your hunting grounds, or when a new wormhole connection opens up valuable opportunities - knowing immediately gives you the edge. Wanderer Notifier bridges this information gap, bringing critical intel directly to your Discord where your team is already coordinating.
## Table of Contents
- [Prerequisites](#prerequisites)
- [How to Get Started](#how-to-get-started)
- [Quick Install Option](#quick-install-option)
- [Manual Setup](#manual-setup)
- [Notification Types](#notification-types)
- [Kill Notifications](#kill-notifications)
- [Character Tracking Notifications](#character-tracking-notifications)
- [System Notifications](#system-notifications)
- [Map Subscription Features & Limitations](#map-subscription-features--limitations)
- [Free Version Features](#free-version-features)
- [Premium Map Subscription Enhancements](#premium-map-subscription-enhancements)
- [How to Subscribe](#how-to-subscribe)
- [Feature Comparison](#feature-comparison)
- [Web Dashboard](#web-dashboard)
- [Configuration Options](#configuration-options)
- [Troubleshooting](#troubleshooting)
- [Updating Wanderer Notifier](#updating-wanderer-notifier)
- [Conclusion](#conclusion)
## Prerequisites
Before setting up Wanderer Notifier, ensure you have the following:
- A Discord server where you have administrator permissions
- Docker and Docker Compose installed on your system
- Basic knowledge of terminal/command line operations
- Your Wanderer map URL and API token
- A Discord bot token (see our [guide on creating a Discord bot](https://gist.github.com/guarzo/a4d238b932b6a168ad1c5f0375c4a561))
## How to Get Started
There are two ways to install Wanderer Notifier: a **Quick Install** option using a one-liner, or a **Manual Setup** for those who prefer step-by-step control.
### Quick Install Option
For a streamlined installation that creates the necessary directory and files automatically, run:
```bash
curl -fsSL https://gist.githubusercontent.com/guarzo/3f05f3c57005c3cf3585869212caecfe/raw/wanderer-notifier-setup.sh | bash
```
Once the script finishes, update the `wanderer-notifier/.env` file with your configuration values, then proceed to [Step 4](#4-run-it).
### Manual Setup
If you'd rather set up everything manually, follow these steps:
#### 1. Download the Docker Image
Pull the latest Docker image:
```bash
docker pull guarzo/wanderer-notifier:latest
```
#### 2. Configure Your Environment
Create a `.env` file in your working directory with the following content. Replace the placeholder values with your actual credentials:
```dotenv
# Required Configuration
DISCORD_BOT_TOKEN=your_discord_bot_token
DISCORD_CHANNEL_ID=your_discord_channel_id
MAP_URL_WITH_NAME="https://wanderer.ltd/<yourmap>"
MAP_TOKEN=your_map_api_token
# Map Subscription Configuration (for enhanced features)
# Note: Premium features are enabled with your map subscription
LICENSE_KEY=your_map_license_key # Provided with your map subscription
# Notification Control (all enabled by default)
# ENABLE_KILL_NOTIFICATIONS=true
# ENABLE_CHARACTER_TRACKING=true
# ENABLE_CHARACTER_NOTIFICATIONS=true
# ENABLE_SYSTEM_NOTIFICATIONS=true
# TRACK_ALL_SYSTEMS=false
```
> **Note:** If you don't have a Discord bot yet, follow our [guide on creating a Discord bot](https://gist.github.com/guarzo/a4d238b932b6a168ad1c5f0375c4a561) or search the web for more information.
#### 3. Create the Docker Compose Configuration
Create a file named `docker-compose.yml` with the following content:
```yaml
services:
wanderer_notifier:
image: guarzo/wanderer-notifier:latest
container_name: wanderer_notifier
restart: unless-stopped
environment:
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID}
- MAP_URL_WITH_NAME=${MAP_URL_WITH_NAME}
- MAP_TOKEN=${MAP_TOKEN}
- LICENSE_KEY=${LICENSE_KEY}
ports:
- 4000:4000
volumes:
- wanderer_data:/app/data
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:${PORT:-4000}/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
wanderer_data:
```
> **Note:** If you used the quick install option, these files have already been created for you.
#### 4. Run It
Start the service with Docker Compose:
```bash
docker-compose up -d
```
Your notifier is now up and running, delivering alerts to your Discord channel automatically!
---
## Notification Types
Wanderer Notifier supports three main notification types, each tailored based on your map subscription status.
### Kill Notifications
When a kill occurs in a tracked system or involves a tracked character:
- **With Premium Map Subscription:**
Receives a rich embed that includes:
- Ship thumbnail image
- Detailed information about both victim and attacker
- Links to zKillboard profiles
- Ship type details
- ISK value of the kill
- Corporation logos
- A clickable link on the final blow character to zKillboard
![Premium Kill Notification Example](/images/news/03-18-bots/paid-kill.png)
- **With Free Map:**
Displays a basic text notification containing:
- Victim name
- Ship type lost
- System name
![Free Kill Notification Example](/images/news/03-18-bots/free-kill.png)
### Character Tracking Notifications
When a new character is added to your tracked list:
- **With Premium Map Subscription:**
You get a rich embed featuring:
- Character portrait
- Corporation details
- Direct link to the zKillboard profile
- Formatted timestamp
![Premium Character Notification Example](/images/news/03-18-bots/paid-character.png)
- **With Free Map:**
Receives a simple text notification that includes:
- Character name
- Corporation name (if available)
![Free Character Notification Example](/images/news/03-18-bots/free-character.png)
### System Notifications
When a new system is discovered or added to your map:
- **With Premium Map Subscription:**
Shows a rich embed with:
- System name (including aliases/temporary names)
- System type icon
- Region information or wormhole statics
- Security status
- Recent kills in the system
- Links to zKillboard and Dotlan
![Premium System Notification Example](/images/news/03-18-bots/paid-system.png)
- **With Free Map:**
Provides a basic text notification including:
- Original system name (for wormholes)
- System name (for k-space)
![Free System Notification Example](/images/news/03-18-bots/free-system.png)
---
## Map Subscription Features & Limitations
Wanderer Notifier offers enhanced functionality with a premium map subscription while still providing robust features for free maps.
### Free Version Features
- **Core Notifications:** Basic text notifications for systems and characters.
- **Web Dashboard:** View system status and subscription information.
- **Unlimited Tracking:** Track an unlimited number of systems and characters.
- **Notification History:** 24-hour retention of notification history.
### Premium Map Subscription Enhancements
- **Rich Notifications:** Enhanced embeds with images, links, and detailed data.
- **Interactive Elements:** Clickable links to zKillboard profiles and additional resources.
- **Enhanced System Information:** Comprehensive data including region details, security status, and wormhole statics.
- **Recent Activity:** Access to recent kill data in newly mapped systems.
- **Upcoming Features:** Daily reporting on tracked character activity, structure notifications, ACL notifications, and Slack notifications.
### How to Subscribe
To unlock the enhanced features of Wanderer Notifier:
1. Visit our [Map Subscriptions page](/map-subscriptions) to learn about subscription options
2. Subscribe to any premium map tier to receive your map subscription key
3. Add your map subscription key to the LICENSE_KEY field in your `.env` file
4. Restart the notifier to apply your subscription benefits
For more details on map subscription tiers and pricing, see our [complete guide to map subscriptions](/map-subscriptions).
### Feature Comparison
| Feature | Free Map | Premium Map Subscription |
|--------------------------|----------|--------------------------|
| Kill Tracking | Unlimited| Unlimited |
| System Tracking | Unlimited| Unlimited |
| Character Tracking | Unlimited| Unlimited |
| Notification Format | Basic Text| Rich Embeds |
---
## Web Dashboard
Wanderer Notifier includes a web dashboard that provides real-time insights into your notification system:
- **Access:** Visit `http://localhost:4000` to view the dashboard.
- **System Status:** Monitor system details, subscription information, and notification statistics.
- **Resource Monitoring:** Keep an eye on resource usage and feature availability.
- **Notification Testing:** Test notifications directly from the dashboard.
Premium map subscribers also gain access to detailed statistics and advanced visualization tools.
![Dashboard](/images/news/03-18-bots/dashboard.png)
---
## Configuration Options
Customize your notification experience with several configuration options available through environment variables.
### Notification Control Variables
- **ENABLE_KILL_NOTIFICATIONS:** Enable/disable kill notifications (default: true).
- **ENABLE_CHARACTER_TRACKING:** Enable/disable character tracking (default: true).
- **ENABLE_CHARACTER_NOTIFICATIONS:** Enable/disable notifications when new characters are added (default: true).
- **ENABLE_SYSTEM_NOTIFICATIONS:** Enable/disable system notifications (default: true).
> **Note:**
> - **Character Tracking:** Determines whether the application monitors characters.
> - **Character Notifications:** Controls whether you receive Discord alerts when new characters are added.
To disable a notification type, set the corresponding variable to `false` or `0` in your `.env` file:
```dotenv
# Example: Disable kill notifications while keeping other notifications enabled
ENABLE_KILL_NOTIFICATIONS=false
```
---
## Troubleshooting
If you encounter issues with Wanderer Notifier, here are solutions to common problems:
### No Notifications Appearing
1. **Check Bot Permissions:** Ensure your bot has the "Send Messages" and "Embed Links" permissions in the Discord channel.
2. **Verify Channel ID:** Double-check your DISCORD_CHANNEL_ID in the .env file.
3. **Check Container Logs:** Run `docker logs wanderer_notifier` to see if there are any error messages.
4. **Test API Connection:** Visit `http://localhost:4000/health` to verify the service is running.
### Connection Issues
1. **Network Configuration:** Ensure port 4000 is not blocked by your firewall.
2. **Docker Status:** Run `docker ps` to verify the container is running.
3. **Restart Service:** Try `docker-compose restart` to refresh the connection.
### Subscription Not Recognized
1. **Check Map Token:** Ensure your MAP_TOKEN is correct and associated with your map.
2. **Verify LICENSE_KEY:** Make sure you've entered the correct map subscription key in your .env file.
3. **Verify Status:** Check the dashboard at `http://localhost:4000` to see subscription status.
4. **Restart After Subscribing:** If you've recently subscribed, restart the notifier with `docker-compose restart`.
For additional support, join our [Discord community](https://discord.gg/wanderer) or email support@wanderer.ltd.
## Updating Wanderer Notifier
To ensure you have the latest features and security updates, periodically update your Wanderer Notifier installation:
### Automatic Updates
The Docker image is configured to check for updates daily. To manually trigger an update:
```bash
# Navigate to your wanderer-notifier directory
cd wanderer-notifier
# Pull the latest image
docker-compose pull
# Restart the container with the new image
docker-compose up -d
```
### Update Notifications
When significant updates are available, you'll receive a notification in your Discord channel. These updates may include:
- New notification types
- Enhanced visualization features
- Security improvements
- Bug fixes
### Preserving Your Configuration
Updates preserve your existing configuration and data. Your `.env` file and tracked entities will remain intact through the update process.
## Conclusion
Wanderer Notifier is engineered to keep you informed of crucial in-game events effortlessly. The free version provides unlimited tracking with basic notifications, while premium map subscribers receive rich, detailed alerts with enhanced features.
By following either the quick install or manual setup process, you'll have the notifier running in no time—delivering real-time alerts directly to your Discord channel so you can focus on what matters most in your gameplay.
For further support or questions, please contact the Wanderer Team.
Stay vigilant and enjoy your real-time alerts!