Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled

This commit is contained in:
Dmitry Popov
2025-11-17 00:09:27 +01:00
68 changed files with 4406 additions and 1105 deletions

View File

@@ -2,6 +2,87 @@
<!-- changelog -->
## [v1.84.26](https://github.com/wanderer-industries/wanderer/compare/v1.84.25...v1.84.26) (2025-11-16)
### Bug Fixes:
* core: disable character tracker pausing
## [v1.84.25](https://github.com/wanderer-industries/wanderer/compare/v1.84.24...v1.84.25) (2025-11-16)
### Bug Fixes:
* core: used upsert for adding map systems
## [v1.84.24](https://github.com/wanderer-industries/wanderer/compare/v1.84.23...v1.84.24) (2025-11-15)
### Bug Fixes:
* Map: Fixed problem related with error if settings was removed and mapper crashed. Fixed settings reset.
## [v1.84.23](https://github.com/wanderer-industries/wanderer/compare/v1.84.22...v1.84.23) (2025-11-15)
### Bug Fixes:
* core: fixed map pings cancel errors
## [v1.84.22](https://github.com/wanderer-industries/wanderer/compare/v1.84.21...v1.84.22) (2025-11-15)
### Bug Fixes:
* core: fixed map initialization
## [v1.84.21](https://github.com/wanderer-industries/wanderer/compare/v1.84.20...v1.84.21) (2025-11-15)
### Bug Fixes:
* core: fixed map characters adding
## [v1.84.20](https://github.com/wanderer-industries/wanderer/compare/v1.84.19...v1.84.20) (2025-11-15)
### Bug Fixes:
* core: fixed map start issues
## [v1.84.19](https://github.com/wanderer-industries/wanderer/compare/v1.84.18...v1.84.19) (2025-11-14)
### Bug Fixes:
* core: fixed map start issues
## [v1.84.18](https://github.com/wanderer-industries/wanderer/compare/v1.84.17...v1.84.18) (2025-11-14)
### Bug Fixes:
* core: added gracefull map poll recovery from saved state. added map slug unique checks
## [v1.84.17](https://github.com/wanderer-industries/wanderer/compare/v1.84.16...v1.84.17) (2025-11-14)

View File

@@ -30,7 +30,7 @@ format f:
mix format
test t:
mix test
MIX_ENV=test mix test
coverage cover co:
mix test --cover
@@ -45,4 +45,3 @@ versions v:
@cat .tool-versions
@cat Aptfile
@echo

View File

@@ -73,7 +73,9 @@ body > div:first-of-type {
}
.maps_bg {
background-image: url('../images/maps_bg.webp');
/* OLD image */
/* background-image: url('../images/maps_bg.webp'); */
background-image: url('https://wanderer-industries.github.io/wanderer-assets/images/eve-screen-catalyst-expansion-bg.jpg');
background-size: cover;
background-position: center;
width: 100%;

View File

@@ -51,20 +51,8 @@ export const Characters = ({ data }: CharactersProps) => {
['border-lime-600/70']: character.online,
},
)}
title={character.tracking_paused ? `${character.name} - Tracking Paused (click to resume)` : character.name}
title={character.name}
>
{character.tracking_paused && (
<>
<span
className={clsx(
'absolute flex flex-col p-[2px] top-[0px] left-[0px] w-[35px] h-[35px]',
'text-yellow-500 text-[9px] z-10 bg-gray-800/40',
'pi',
PrimeIcons.PAUSE,
)}
/>
</>
)}
{mainCharacterEveId === character.eve_id && (
<span
className={clsx(

View File

@@ -1,6 +1,6 @@
@use "sass:color";
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
@import '@/hooks/Mapper/components/map/styles/solar-system-node';
@use '@/hooks/Mapper/components/map/styles/solar-system-node' as v;
@keyframes move-stripes {
from {
@@ -26,8 +26,8 @@
background-color: var(--rf-node-bg-color, #202020) !important;
color: var(--rf-text-color, #ffffff);
box-shadow: 0 0 5px rgba($dark-bg, 0.5);
border: 1px solid color.adjust($pastel-blue, $lightness: -10%);
box-shadow: 0 0 5px rgba(v.$dark-bg, 0.5);
border: 1px solid color.adjust(v.$pastel-blue, $lightness: -10%);
border-radius: 5px;
position: relative;
z-index: 3;
@@ -99,7 +99,7 @@
}
&.selected {
border-color: $pastel-pink;
border-color: v.$pastel-pink;
box-shadow: 0 0 10px #9a1af1c2;
}
@@ -113,11 +113,11 @@
bottom: 0;
z-index: -1;
border-color: $neon-color-1;
border-color: v.$neon-color-1;
background: repeating-linear-gradient(
45deg,
$neon-color-3 0px,
$neon-color-3 8px,
v.$neon-color-3 0px,
v.$neon-color-3 8px,
transparent 8px,
transparent 21px
);
@@ -146,7 +146,7 @@
border: 1px solid var(--eve-solar-system-status-color-lookingFor-dark15);
background-image: linear-gradient(275deg, #45ff8f2f, #457fff2f);
&.selected {
border-color: $pastel-pink;
border-color: v.$pastel-pink;
}
}
@@ -347,13 +347,13 @@
.Handle {
min-width: initial;
min-height: initial;
border: 1px solid $pastel-blue;
border: 1px solid v.$pastel-blue;
width: 5px;
height: 5px;
pointer-events: auto;
&.selected {
border-color: $pastel-pink;
border-color: v.$pastel-pink;
}
&.HandleTop {

View File

@@ -14,8 +14,27 @@ export const useCommandsCharacters = () => {
const ref = useRef({ update });
ref.current = { update };
const charactersUpdated = useCallback((characters: CommandCharactersUpdated) => {
ref.current.update(() => ({ characters: characters.slice() }));
const charactersUpdated = useCallback((updatedCharacters: CommandCharactersUpdated) => {
ref.current.update(state => {
const existing = state.characters ?? [];
// Put updatedCharacters into a map keyed by ID
const updatedMap = new Map(updatedCharacters.map(c => [c.eve_id, c]));
// 1. Update existing characters when possible
const merged = existing.map(character => {
const updated = updatedMap.get(character.eve_id);
if (updated) {
updatedMap.delete(character.eve_id); // Mark as processed
return { ...character, ...updated };
}
return character;
});
// 2. Any remaining items in updatedMap are NEW characters → add them
const newCharacters = Array.from(updatedMap.values());
return { characters: [...merged, ...newCharacters] };
});
}, []);
const characterAdded = useCallback((value: CommandCharacterAdded) => {

View File

@@ -1,6 +1,6 @@
@use "sass:color";
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
@import '@/hooks/Mapper/components/map/styles/solar-system-node';
@use '@/hooks/Mapper/components/map/styles/solar-system-node' as v;
:root {
--rf-has-user-characters: #ffc75d;
@@ -108,7 +108,7 @@
}
&.selected {
border-color: $pastel-pink;
border-color: v.$pastel-pink;
box-shadow: 0 0 10px #9a1af1c2;
}
@@ -122,11 +122,11 @@
bottom: 0;
z-index: -1;
border-color: $neon-color-1;
border-color: v.$neon-color-1;
background: repeating-linear-gradient(
45deg,
$neon-color-3 0px,
$neon-color-3 8px,
v.$neon-color-3 0px,
v.$neon-color-3 8px,
transparent 8px,
transparent 21px
);
@@ -152,7 +152,7 @@
&.eve-system-status-lookingFor {
background-image: linear-gradient(275deg, #45ff8f2f, #457fff2f);
&.selected {
border-color: $pastel-pink;
border-color: v.$pastel-pink;
}
}

View File

@@ -1,5 +1,4 @@
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useCallback, useRef } from 'react';
import {
CommandCharacterAdded,
CommandCharacterRemoved,
@@ -7,6 +6,7 @@ import {
CommandCharacterUpdated,
CommandPresentCharacters,
} from '@/hooks/Mapper/types';
import { useCallback, useRef } from 'react';
export const useCommandsCharacters = () => {
const { update } = useMapRootState();
@@ -14,8 +14,27 @@ export const useCommandsCharacters = () => {
const ref = useRef({ update });
ref.current = { update };
const charactersUpdated = useCallback((characters: CommandCharactersUpdated) => {
ref.current.update(() => ({ characters: characters.slice() }));
const charactersUpdated = useCallback((updatedCharacters: CommandCharactersUpdated) => {
ref.current.update(state => {
const existing = state.characters ?? [];
// Put updatedCharacters into a map keyed by ID
const updatedMap = new Map(updatedCharacters.map(c => [c.eve_id, c]));
// 1. Update existing characters when possible
const merged = existing.map(character => {
const updated = updatedMap.get(character.eve_id);
if (updated) {
updatedMap.delete(character.eve_id); // Mark as processed
return { ...character, ...updated };
}
return character;
});
// 2. Any remaining items in updatedMap are NEW characters → add them
const newCharacters = Array.from(updatedMap.values());
return { characters: [...merged, ...newCharacters] };
});
}, []);
const characterAdded = useCallback((value: CommandCharacterAdded) => {

View File

@@ -33,7 +33,6 @@ export type CharacterTypeRaw = {
corporation_id: number;
corporation_name: string;
corporation_ticker: string;
tracking_paused: boolean;
};
export interface TrackingCharacter {

View File

@@ -12,11 +12,11 @@ const animateBg = function (bgCanvas) {
*/
const randomInRange = (max, min) => Math.floor(Math.random() * (max - min + 1)) + min;
const BASE_SIZE = 1;
const VELOCITY_INC = 1.01;
const VELOCITY_INC = 1.002;
const VELOCITY_INIT_INC = 0.525;
const JUMP_VELOCITY_INC = 0.55;
const JUMP_SIZE_INC = 1.15;
const SIZE_INC = 1.01;
const SIZE_INC = 1.002;
const RAD = Math.PI / 180;
const WARP_COLORS = [
[197, 239, 247],

View File

@@ -1,8 +1,25 @@
defmodule WandererApp.Api.Changes.SlugifyName do
@moduledoc """
Ensures map slugs are unique by:
1. Slugifying the provided slug/name
2. Checking for existing slugs (optimization)
3. Finding next available slug with numeric suffix if needed
4. Relying on database unique constraint as final arbiter
Race Condition Mitigation:
- Optimistic check reduces DB roundtrips for most cases
- Database unique index ensures no duplicates slip through
- Proper error messages for constraint violations
- Telemetry events for monitoring conflicts
"""
use Ash.Resource.Change
alias Ash.Changeset
require Ash.Query
require Logger
# Maximum number of attempts to find a unique slug
@max_attempts 100
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
@@ -26,7 +43,7 @@ defmodule WandererApp.Api.Changes.SlugifyName do
# Get the current record ID if this is an update operation
current_id = Changeset.get_attribute(changeset, :id)
# Check if the base slug is available
# Check if the base slug is available (optimization to avoid numeric suffixes when possible)
if slug_available?(base_slug, current_id) do
base_slug
else
@@ -35,16 +52,44 @@ defmodule WandererApp.Api.Changes.SlugifyName do
end
end
defp find_available_slug(base_slug, current_id, n) do
defp find_available_slug(base_slug, current_id, n) when n <= @max_attempts do
candidate_slug = "#{base_slug}-#{n}"
if slug_available?(candidate_slug, current_id) do
# Emit telemetry when we had to use a suffix (indicates potential conflict)
:telemetry.execute(
[:wanderer_app, :map, :slug_suffix_used],
%{suffix_number: n},
%{base_slug: base_slug, final_slug: candidate_slug}
)
candidate_slug
else
find_available_slug(base_slug, current_id, n + 1)
end
end
defp find_available_slug(base_slug, _current_id, n) when n > @max_attempts do
# Fallback: use timestamp suffix if we've tried too many numeric suffixes
# This handles edge cases where many maps have similar names
timestamp = System.system_time(:millisecond)
fallback_slug = "#{base_slug}-#{timestamp}"
Logger.warning(
"Slug generation exceeded #{@max_attempts} attempts for '#{base_slug}', using timestamp fallback",
base_slug: base_slug,
fallback_slug: fallback_slug
)
:telemetry.execute(
[:wanderer_app, :map, :slug_fallback_used],
%{attempts: n},
%{base_slug: base_slug, fallback_slug: fallback_slug}
)
fallback_slug
end
defp slug_available?(slug, current_id) do
query =
WandererApp.Api.Map
@@ -60,9 +105,20 @@ defmodule WandererApp.Api.Changes.SlugifyName do
|> Ash.Query.limit(1)
case Ash.read(query) do
{:ok, []} -> true
{:ok, _} -> false
{:error, _} -> false
{:ok, []} ->
true
{:ok, _existing} ->
false
{:error, error} ->
# Log error but be conservative - assume slug is not available
Logger.warning("Error checking slug availability",
slug: slug,
error: inspect(error)
)
false
end
end
end

View File

@@ -31,7 +31,7 @@ defmodule WandererApp.Api.Map do
routes do
base("/maps")
get(:by_slug, route: "/:slug")
index :read
# index :read
post(:new)
patch(:update)
delete(:destroy)

View File

@@ -67,6 +67,7 @@ defmodule WandererApp.Api.MapSystem do
code_interface do
define(:create, action: :create)
define(:upsert, action: :upsert)
define(:destroy, action: :destroy)
define(:by_id,
@@ -129,6 +130,31 @@ defmodule WandererApp.Api.MapSystem do
defaults [:create, :update, :destroy]
create :upsert do
primary? false
upsert? true
upsert_identity :map_solar_system_id
# Update these fields on conflict
upsert_fields [
:position_x,
:position_y,
:visible,
:name
]
accept [
:map_id,
:solar_system_id,
:name,
:position_x,
:position_y,
:visible,
:locked,
:status
]
end
read :read do
primary?(true)

View File

@@ -73,6 +73,54 @@ defmodule WandererApp.Cache do
def filter_by_attr_in(type, attr, includes), do: type |> get() |> filter_in(attr, includes)
@doc """
Batch lookup multiple keys from cache.
Returns a map of key => value pairs, with `default` used for missing keys.
"""
def lookup_all(keys, default \\ nil) when is_list(keys) do
# Get all values from cache
values = get_all(keys)
# Build result map with defaults for missing keys
result =
keys
|> Enum.map(fn key ->
value = Map.get(values, key, default)
{key, value}
end)
|> Map.new()
{:ok, result}
end
@doc """
Batch insert multiple key-value pairs into cache.
Accepts a map of key => value pairs or a list of {key, value} tuples.
Skips nil values (deletes the key instead).
"""
def insert_all(entries, opts \\ [])
def insert_all(entries, opts) when is_map(entries) do
# Filter out nil values and delete those keys
{to_delete, to_insert} =
entries
|> Enum.split_with(fn {_key, value} -> is_nil(value) end)
# Delete keys with nil values
Enum.each(to_delete, fn {key, _} -> delete(key) end)
# Insert non-nil values
unless Enum.empty?(to_insert) do
put_all(to_insert, opts)
end
:ok
end
def insert_all(entries, opts) when is_list(entries) do
insert_all(Map.new(entries), opts)
end
defp find(list, %{} = attrs, match: match) do
list
|> Enum.find(fn item ->

View File

@@ -4,6 +4,8 @@ defmodule WandererApp.Character do
require Logger
alias WandererApp.Cache
@read_character_wallet_scope "esi-wallet.read_character_wallet.v1"
@read_corp_wallet_scope "esi-wallet.read_corporation_wallets.v1"
@@ -16,6 +18,9 @@ defmodule WandererApp.Character do
ship_item_id: nil
}
@present_on_map_ttl :timer.seconds(10)
@not_present_on_map_ttl :timer.minutes(2)
def get_by_eve_id(character_eve_id) when is_binary(character_eve_id) do
WandererApp.Api.Character.by_eve_id(character_eve_id)
end
@@ -41,7 +46,7 @@ defmodule WandererApp.Character do
def get_character!(character_id) do
case get_character(character_id) do
{:ok, character} ->
{:ok, character} when not is_nil(character) ->
character
_ ->
@@ -50,16 +55,10 @@ defmodule WandererApp.Character do
end
end
def get_map_character(map_id, character_id, opts \\ []) do
def get_map_character(map_id, character_id) do
case get_character(character_id) do
{:ok, character} ->
# If we are forcing the character to not be present, we merge the character state with map settings
character_is_present =
if opts |> Keyword.get(:not_present, false) do
false
else
WandererApp.Character.TrackerManager.Impl.character_is_present(map_id, character_id)
end
{:ok, character} when not is_nil(character) ->
character_is_present = character_is_present?(map_id, character_id)
{:ok,
character
@@ -187,6 +186,10 @@ defmodule WandererApp.Character do
{:ok, result} ->
{:ok, result |> prepare_search_results()}
{:error, error} ->
Logger.warning("#{__MODULE__} failed search: #{inspect(error)}")
{:ok, []}
error ->
Logger.warning("#{__MODULE__} failed search: #{inspect(error)}")
{:ok, []}
@@ -263,22 +266,26 @@ defmodule WandererApp.Character do
end
end
defp maybe_merge_map_character_settings(%{id: character_id} = character, _map_id, true) do
{:ok, tracking_paused} =
WandererApp.Cache.lookup("character:#{character_id}:tracking_paused", false)
@decorate cacheable(
cache: Cache,
key: "character-present-#{map_id}-#{character_id}",
opts: [ttl: @present_on_map_ttl]
)
defp character_is_present?(map_id, character_id),
do: WandererApp.Character.TrackerManager.Impl.character_is_present(map_id, character_id)
character
|> Map.merge(%{tracking_paused: tracking_paused})
end
defp maybe_merge_map_character_settings(character, _map_id, true), do: character
@decorate cacheable(
cache: Cache,
key: "not-present-map-character-#{map_id}-#{character_id}",
opts: [ttl: @not_present_on_map_ttl]
)
defp maybe_merge_map_character_settings(
%{id: character_id} = character,
map_id,
_character_is_present
false
) do
{:ok, tracking_paused} =
WandererApp.Cache.lookup("character:#{character_id}:tracking_paused", false)
WandererApp.MapCharacterSettingsRepo.get(map_id, character_id)
|> case do
{:ok, settings} when not is_nil(settings) ->
@@ -296,7 +303,7 @@ defmodule WandererApp.Character do
character
|> Map.merge(@default_character_tracking_data)
end
|> Map.merge(%{online: false, tracking_paused: tracking_paused})
|> Map.merge(%{online: false})
end
defp prepare_search_results(result) do

View File

@@ -36,11 +36,9 @@ defmodule WandererApp.Character.Tracker do
status: binary()
}
@pause_tracking_timeout :timer.minutes(60 * 10)
@offline_timeout :timer.minutes(5)
@online_error_timeout :timer.minutes(10)
@ship_error_timeout :timer.minutes(10)
@location_error_timeout :timer.minutes(10)
@location_error_timeout :timer.seconds(30)
@location_error_threshold 3
@online_forbidden_ttl :timer.seconds(7)
@offline_check_delay_ttl :timer.seconds(15)
@online_limit_ttl :timer.seconds(7)
@@ -93,81 +91,16 @@ defmodule WandererApp.Character.Tracker do
end
end
def check_online_errors(character_id),
do: check_tracking_errors(character_id, "online", @online_error_timeout)
def check_ship_errors(character_id),
do: check_tracking_errors(character_id, "ship", @ship_error_timeout)
def check_location_errors(character_id),
do: check_tracking_errors(character_id, "location", @location_error_timeout)
defp check_tracking_errors(character_id, type, timeout) do
WandererApp.Cache.lookup!("character:#{character_id}:#{type}_error_time")
|> case do
nil ->
:skip
error_time ->
duration = DateTime.diff(DateTime.utc_now(), error_time, :millisecond)
if duration >= timeout do
pause_tracking(character_id)
WandererApp.Cache.delete("character:#{character_id}:#{type}_error_time")
:ok
else
:skip
end
end
defp increment_location_error_count(character_id) do
cache_key = "character:#{character_id}:location_error_count"
current_count = WandererApp.Cache.lookup!(cache_key) || 0
new_count = current_count + 1
WandererApp.Cache.put(cache_key, new_count)
new_count
end
defp pause_tracking(character_id) do
if WandererApp.Character.can_pause_tracking?(character_id) &&
not WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused") do
# Log character tracking statistics before pausing
Logger.debug(fn ->
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
"CHARACTER_TRACKING_PAUSED: Character tracking paused due to sustained errors: #{inspect(character_id: character_id,
active_maps: length(character_state.active_maps),
is_online: character_state.is_online,
tracking_duration_minutes: get_tracking_duration_minutes(character_id))}"
end)
WandererApp.Cache.delete("character:#{character_id}:online_forbidden")
WandererApp.Cache.delete("character:#{character_id}:online_error_time")
WandererApp.Cache.delete("character:#{character_id}:ship_error_time")
WandererApp.Cache.delete("character:#{character_id}:location_error_time")
WandererApp.Character.update_character(character_id, %{online: false})
WandererApp.Character.update_character_state(character_id, %{
is_online: false
})
# Original log kept for backward compatibility
Logger.warning("[CharacterTracker] paused for #{character_id}")
WandererApp.Cache.put(
"character:#{character_id}:tracking_paused",
true,
ttl: @pause_tracking_timeout
)
{:ok, %{solar_system_id: solar_system_id}} =
WandererApp.Character.get_character(character_id)
{:ok, %{active_maps: active_maps}} =
WandererApp.Character.get_character_state(character_id)
active_maps
|> Enum.each(fn map_id ->
WandererApp.Cache.put(
"map:#{map_id}:character:#{character_id}:start_solar_system_id",
solar_system_id
)
end)
end
defp reset_location_error_count(character_id) do
WandererApp.Cache.delete("character:#{character_id}:location_error_count")
end
def update_settings(character_id, track_settings) do
@@ -194,8 +127,7 @@ defmodule WandererApp.Character.Tracker do
case WandererApp.Character.get_character(character_id) do
{:ok, %{eve_id: eve_id, access_token: access_token, tracking_pool: tracking_pool}}
when not is_nil(access_token) ->
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden")
|> case do
true ->
{:error, :skipped}
@@ -227,6 +159,7 @@ defmodule WandererApp.Character.Tracker do
if online.online == true && online.online != is_online do
WandererApp.Cache.delete("character:#{character_id}:ship_error_time")
WandererApp.Cache.delete("character:#{character_id}:location_error_time")
WandererApp.Cache.delete("character:#{character_id}:location_error_count")
WandererApp.Cache.delete("character:#{character_id}:info_forbidden")
WandererApp.Cache.delete("character:#{character_id}:ship_forbidden")
WandererApp.Cache.delete("character:#{character_id}:location_forbidden")
@@ -357,8 +290,7 @@ defmodule WandererApp.Character.Tracker do
defp get_reset_timeout(_headers, default_timeout), do: default_timeout
def update_info(character_id) do
(WandererApp.Cache.has_key?("character:#{character_id}:info_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
WandererApp.Cache.has_key?("character:#{character_id}:info_forbidden")
|> case do
true ->
{:error, :skipped}
@@ -442,8 +374,7 @@ defmodule WandererApp.Character.Tracker do
{:ok, %{eve_id: eve_id, access_token: access_token, tracking_pool: tracking_pool}}
when not is_nil(access_token) ->
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:ship_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
WandererApp.Cache.has_key?("character:#{character_id}:ship_forbidden"))
|> case do
true ->
{:error, :skipped}
@@ -552,7 +483,7 @@ defmodule WandererApp.Character.Tracker do
case WandererApp.Character.get_character(character_id) do
{:ok, %{eve_id: eve_id, access_token: access_token, tracking_pool: tracking_pool}}
when not is_nil(access_token) ->
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused")
WandererApp.Cache.has_key?("character:#{character_id}:location_forbidden")
|> case do
true ->
{:error, :skipped}
@@ -565,19 +496,33 @@ defmodule WandererApp.Character.Tracker do
character_id: character_id
) do
{:ok, location} when is_map(location) and not is_struct(location) ->
reset_location_error_count(character_id)
WandererApp.Cache.delete("character:#{character_id}:location_error_time")
character_state
|> maybe_update_location(location)
:ok
{:error, error} when error in [:forbidden, :not_found, :timeout] ->
error_count = increment_location_error_count(character_id)
Logger.warning("ESI_ERROR: Character location tracking failed",
character_id: character_id,
tracking_pool: tracking_pool,
error_type: error,
error_count: error_count,
endpoint: "character_location"
)
if error_count >= @location_error_threshold do
WandererApp.Cache.put(
"character:#{character_id}:location_forbidden",
true,
ttl: @location_error_timeout
)
end
if is_nil(
WandererApp.Cache.lookup!("character:#{character_id}:location_error_time")
) do
@@ -601,13 +546,24 @@ defmodule WandererApp.Character.Tracker do
{:error, :error_limited}
{:error, error} ->
error_count = increment_location_error_count(character_id)
Logger.error("ESI_ERROR: Character location tracking failed: #{inspect(error)}",
character_id: character_id,
tracking_pool: tracking_pool,
error_type: error,
error_count: error_count,
endpoint: "character_location"
)
if error_count >= @location_error_threshold do
WandererApp.Cache.put(
"character:#{character_id}:location_forbidden",
true,
ttl: @location_error_timeout
)
end
if is_nil(
WandererApp.Cache.lookup!("character:#{character_id}:location_error_time")
) do
@@ -620,13 +576,24 @@ defmodule WandererApp.Character.Tracker do
{:error, :skipped}
_ ->
error_count = increment_location_error_count(character_id)
Logger.error("ESI_ERROR: Character location tracking failed - wrong response",
character_id: character_id,
tracking_pool: tracking_pool,
error_type: "wrong_response",
error_count: error_count,
endpoint: "character_location"
)
if error_count >= @location_error_threshold do
WandererApp.Cache.put(
"character:#{character_id}:location_forbidden",
true,
ttl: @location_error_timeout
)
end
if is_nil(
WandererApp.Cache.lookup!("character:#{character_id}:location_error_time")
) do
@@ -662,8 +629,7 @@ defmodule WandererApp.Character.Tracker do
|> case do
true ->
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:wallet_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
WandererApp.Cache.has_key?("character:#{character_id}:wallet_forbidden"))
|> case do
true ->
{:error, :skipped}
@@ -782,8 +748,7 @@ defmodule WandererApp.Character.Tracker do
alliance_id
)
when old_alliance_id != alliance_id do
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden")
|> case do
true ->
state
@@ -829,8 +794,7 @@ defmodule WandererApp.Character.Tracker do
)
when old_corporation_id != corporation_id do
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:corporation_info_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
WandererApp.Cache.has_key?("character:#{character_id}:corporation_info_forbidden"))
|> case do
true ->
state

View File

@@ -19,9 +19,6 @@ defmodule WandererApp.Character.TrackerPool do
@update_location_interval :timer.seconds(1)
@update_online_interval :timer.seconds(30)
@check_offline_characters_interval :timer.minutes(5)
@check_online_errors_interval :timer.minutes(1)
@check_ship_errors_interval :timer.minutes(1)
@check_location_errors_interval :timer.minutes(1)
@update_ship_interval :timer.seconds(2)
@update_info_interval :timer.minutes(2)
@update_wallet_interval :timer.minutes(10)
@@ -110,13 +107,10 @@ defmodule WandererApp.Character.TrackerPool do
)
Process.send_after(self(), :update_online, 100)
Process.send_after(self(), :check_online_errors, :timer.seconds(60))
Process.send_after(self(), :check_ship_errors, :timer.seconds(90))
Process.send_after(self(), :check_location_errors, :timer.seconds(120))
Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
Process.send_after(self(), :update_location, 300)
Process.send_after(self(), :update_ship, 500)
Process.send_after(self(), :update_info, 1500)
Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
if WandererApp.Env.wallet_tracking_enabled?() do
Process.send_after(self(), :update_wallet, 1000)
@@ -250,126 +244,6 @@ defmodule WandererApp.Character.TrackerPool do
{:noreply, state}
end
def handle_info(
:check_online_errors,
%{
characters: characters
} =
state
) do
Process.send_after(self(), :check_online_errors, @check_online_errors_interval)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.TaskWrapper.start_link(
WandererApp.Character.Tracker,
:check_online_errors,
[
character_id
]
)
end,
timeout: :timer.seconds(15),
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task
)
|> Enum.each(fn
{:ok, _result} -> :ok
error -> @logger.error("Error in check_online_errors: #{inspect(error)}")
end)
rescue
e ->
Logger.error("""
[Tracker Pool] check_online_errors => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
end
def handle_info(
:check_ship_errors,
%{
characters: characters
} =
state
) do
Process.send_after(self(), :check_ship_errors, @check_ship_errors_interval)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.TaskWrapper.start_link(
WandererApp.Character.Tracker,
:check_ship_errors,
[
character_id
]
)
end,
timeout: :timer.seconds(15),
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task
)
|> Enum.each(fn
{:ok, _result} -> :ok
error -> @logger.error("Error in check_ship_errors: #{inspect(error)}")
end)
rescue
e ->
Logger.error("""
[Tracker Pool] check_ship_errors => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
end
def handle_info(
:check_location_errors,
%{
characters: characters
} =
state
) do
Process.send_after(self(), :check_location_errors, @check_location_errors_interval)
try do
characters
|> Task.async_stream(
fn character_id ->
WandererApp.TaskWrapper.start_link(
WandererApp.Character.Tracker,
:check_location_errors,
[
character_id
]
)
end,
timeout: :timer.seconds(15),
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task
)
|> Enum.each(fn
{:ok, _result} -> :ok
error -> @logger.error("Error in check_location_errors: #{inspect(error)}")
end)
rescue
e ->
Logger.error("""
[Tracker Pool] check_location_errors => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
end
{:noreply, state}
end
def handle_info(
:update_location,
%{

View File

@@ -72,20 +72,12 @@ defmodule WandererApp.ExternalEvents do
# Check if MapEventRelay is alive before sending
if Process.whereis(MapEventRelay) do
try do
# Use call with timeout instead of cast for better error handling
GenServer.call(MapEventRelay, {:deliver_event, event}, 5000)
:ok
catch
:exit, {:timeout, _} ->
Logger.error("Timeout delivering event to MapEventRelay for map #{map_id}")
{:error, :timeout}
:exit, reason ->
Logger.error("Failed to deliver event to MapEventRelay: #{inspect(reason)}")
{:error, reason}
end
# Use cast for async delivery to avoid blocking the caller
# This is critical for performance in hot paths (character updates)
GenServer.cast(MapEventRelay, {:deliver_event, event})
:ok
else
Logger.warning("MapEventRelay not available for event delivery (map: #{map_id})")
{:error, :relay_not_available}
end
else

View File

@@ -20,6 +20,7 @@ defmodule WandererApp.ExternalEvents.Event do
| :character_added
| :character_removed
| :character_updated
| :characters_updated
| :map_kill
| :acl_member_added
| :acl_member_removed
@@ -42,50 +43,6 @@ defmodule WandererApp.ExternalEvents.Event do
defstruct [:id, :map_id, :type, :payload, :timestamp]
@doc """
Creates a new external event with ULID for ordering.
Validates that the event_type is supported before creating the event.
"""
@spec new(String.t(), event_type(), map()) :: t() | {:error, :invalid_event_type}
def new(map_id, event_type, payload) when is_binary(map_id) and is_map(payload) do
if valid_event_type?(event_type) do
%__MODULE__{
id: Ecto.ULID.generate(System.system_time(:millisecond)),
map_id: map_id,
type: event_type,
payload: payload,
timestamp: DateTime.utc_now()
}
else
raise ArgumentError,
"Invalid event type: #{inspect(event_type)}. Must be one of: #{supported_event_types() |> Enum.map(&to_string/1) |> Enum.join(", ")}"
end
end
@doc """
Converts an event to JSON format for delivery.
"""
@spec to_json(t()) :: map()
def to_json(%__MODULE__{} = event) do
%{
"id" => event.id,
"type" => to_string(event.type),
"map_id" => event.map_id,
"timestamp" => DateTime.to_iso8601(event.timestamp),
"payload" => serialize_payload(event.payload)
}
end
# Convert Ash structs and other complex types to plain maps
defp serialize_payload(payload) when is_struct(payload) do
serialize_payload(payload, MapSet.new())
end
defp serialize_payload(payload) when is_map(payload) do
serialize_payload(payload, MapSet.new())
end
# Define allowlisted fields for different struct types
@system_fields [
:id,
@@ -133,6 +90,73 @@ defmodule WandererApp.ExternalEvents.Event do
]
@signature_fields [:id, :signature_id, :name, :type, :group]
@supported_event_types [
:add_system,
:deleted_system,
:system_renamed,
:system_metadata_changed,
:signatures_updated,
:signature_added,
:signature_removed,
:connection_added,
:connection_removed,
:connection_updated,
:character_added,
:character_removed,
:character_updated,
:characters_updated,
:map_kill,
:acl_member_added,
:acl_member_removed,
:acl_member_updated,
:rally_point_added,
:rally_point_removed
]
@doc """
Creates a new external event with ULID for ordering.
Validates that the event_type is supported before creating the event.
"""
@spec new(String.t(), event_type(), map()) :: t() | {:error, :invalid_event_type}
def new(map_id, event_type, payload) when is_binary(map_id) and is_map(payload) do
if valid_event_type?(event_type) do
%__MODULE__{
id: Ecto.ULID.generate(System.system_time(:millisecond)),
map_id: map_id,
type: event_type,
payload: payload,
timestamp: DateTime.utc_now()
}
else
raise ArgumentError,
"Invalid event type: #{inspect(event_type)}. Must be one of: #{supported_event_types() |> Enum.map(&to_string/1) |> Enum.join(", ")}"
end
end
@doc """
Converts an event to JSON format for delivery.
"""
@spec to_json(t()) :: map()
def to_json(%__MODULE__{} = event) do
%{
"id" => event.id,
"type" => to_string(event.type),
"map_id" => event.map_id,
"timestamp" => DateTime.to_iso8601(event.timestamp),
"payload" => serialize_payload(event.payload)
}
end
# Convert Ash structs and other complex types to plain maps
defp serialize_payload(payload) when is_struct(payload) do
serialize_payload(payload, MapSet.new())
end
defp serialize_payload(payload) when is_map(payload) do
serialize_payload(payload, MapSet.new())
end
# Overloaded versions with visited tracking
defp serialize_payload(payload, visited) when is_struct(payload) do
# Check for circular reference
@@ -193,29 +217,7 @@ defmodule WandererApp.ExternalEvents.Event do
Returns all supported event types.
"""
@spec supported_event_types() :: [event_type()]
def supported_event_types do
[
:add_system,
:deleted_system,
:system_renamed,
:system_metadata_changed,
:signatures_updated,
:signature_added,
:signature_removed,
:connection_added,
:connection_removed,
:connection_updated,
:character_added,
:character_removed,
:character_updated,
:map_kill,
:acl_member_added,
:acl_member_removed,
:acl_member_updated,
:rally_point_added,
:rally_point_removed
]
end
def supported_event_types, do: @supported_event_types
@doc """
Validates an event type.

View File

@@ -82,16 +82,9 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do
@impl true
def handle_call({:deliver_event, %Event{} = event}, _from, state) do
# Log ACL events at info level for debugging
if event.type in [:acl_member_added, :acl_member_removed, :acl_member_updated] do
Logger.debug(fn ->
"MapEventRelay received :deliver_event (call) for map #{event.map_id}, type: #{event.type}"
end)
else
Logger.debug(fn ->
"MapEventRelay received :deliver_event (call) for map #{event.map_id}, type: #{event.type}"
end)
end
Logger.debug(fn ->
"MapEventRelay received :deliver_event (call) for map #{event.map_id}, type: #{event.type}"
end)
new_state = deliver_single_event(event, state)
{:reply, :ok, new_state}

View File

@@ -53,8 +53,8 @@ defmodule WandererApp.Map do
{:ok, map} ->
map
_ ->
Logger.error(fn -> "Failed to get map #{map_id}" end)
error ->
Logger.error("Failed to get map #{map_id}: #{inspect(error)}")
%{}
end
end
@@ -183,9 +183,31 @@ defmodule WandererApp.Map do
def add_characters!(map, []), do: map
def add_characters!(%{map_id: map_id} = map, [character | rest]) do
add_character(map_id, character)
add_characters!(map, rest)
def add_characters!(%{map_id: map_id} = map, characters) when is_list(characters) do
# Get current characters list once
current_characters = Map.get(map, :characters, [])
characters_ids =
characters
|> Enum.map(fn %{id: char_id} -> char_id end)
# Filter out characters that already exist
new_character_ids =
characters_ids
|> Enum.reject(fn char_id -> char_id in current_characters end)
# If all characters already exist, return early
if new_character_ids == [] do
map
else
case update_map(map_id, %{characters: new_character_ids ++ current_characters}) do
{:commit, map} ->
map
_ ->
map
end
end
end
def add_character(
@@ -198,61 +220,10 @@ defmodule WandererApp.Map do
case not (characters |> Enum.member?(character_id)) do
true ->
WandererApp.Character.get_map_character(map_id, character_id)
|> case do
{:ok,
%{
alliance_id: alliance_id,
corporation_id: corporation_id,
solar_system_id: solar_system_id,
structure_id: structure_id,
station_id: station_id,
ship: ship_type_id,
ship_name: ship_name
}} ->
map_id
|> update_map(%{characters: [character_id | characters]})
map_id
|> update_map(%{characters: [character_id | characters]})
# WandererApp.Cache.insert(
# "map:#{map_id}:character:#{character_id}:alliance_id",
# alliance_id
# )
# WandererApp.Cache.insert(
# "map:#{map_id}:character:#{character_id}:corporation_id",
# corporation_id
# )
# WandererApp.Cache.insert(
# "map:#{map_id}:character:#{character_id}:solar_system_id",
# solar_system_id
# )
# WandererApp.Cache.insert(
# "map:#{map_id}:character:#{character_id}:structure_id",
# structure_id
# )
# WandererApp.Cache.insert(
# "map:#{map_id}:character:#{character_id}:station_id",
# station_id
# )
# WandererApp.Cache.insert(
# "map:#{map_id}:character:#{character_id}:ship_type_id",
# ship_type_id
# )
# WandererApp.Cache.insert(
# "map:#{map_id}:character:#{character_id}:ship_name",
# ship_name
# )
:ok
error ->
error
end
:ok
_ ->
{:error, :already_exists}

View File

@@ -4,7 +4,7 @@ defmodule WandererApp.Map.MapPool do
require Logger
alias WandererApp.Map.Server
alias WandererApp.Map.{MapPoolState, Server}
defstruct [
:map_ids,
@@ -15,7 +15,7 @@ defmodule WandererApp.Map.MapPool do
@cache :map_pool_cache
@registry :map_pool_registry
@unique_registry :unique_map_pool_registry
@map_pool_limit 20
@map_pool_limit 10
@garbage_collection_interval :timer.hours(4)
@systems_cleanup_timeout :timer.minutes(30)
@@ -26,7 +26,17 @@ defmodule WandererApp.Map.MapPool do
def new(), do: __struct__()
def new(args), do: __struct__(args)
def start_link(map_ids) do
# Accept both {uuid, map_ids} tuple (from supervisor restart) and just map_ids (legacy)
def start_link({uuid, map_ids}) when is_binary(uuid) and is_list(map_ids) do
GenServer.start_link(
@name,
{uuid, map_ids},
name: Module.concat(__MODULE__, uuid)
)
end
# For backward compatibility - generate UUID if only map_ids provided
def start_link(map_ids) when is_list(map_ids) do
uuid = UUID.uuid1()
GenServer.start_link(
@@ -38,13 +48,42 @@ defmodule WandererApp.Map.MapPool do
@impl true
def init({uuid, map_ids}) do
{:ok, _} = Registry.register(@unique_registry, Module.concat(__MODULE__, uuid), map_ids)
# Check for crash recovery - if we have previous state in ETS, merge it with new map_ids
{final_map_ids, recovery_info} =
case MapPoolState.get_pool_state(uuid) do
{:ok, recovered_map_ids} ->
# Merge and deduplicate map IDs
merged = Enum.uniq(recovered_map_ids ++ map_ids)
recovery_count = length(recovered_map_ids)
Logger.info(
"[Map Pool #{uuid}] Crash recovery detected: recovering #{recovery_count} maps",
pool_uuid: uuid,
recovered_maps: recovered_map_ids,
new_maps: map_ids,
total_maps: length(merged)
)
# Emit telemetry for crash recovery
:telemetry.execute(
[:wanderer_app, :map_pool, :recovery, :start],
%{recovered_map_count: recovery_count, total_map_count: length(merged)},
%{pool_uuid: uuid}
)
{merged, %{recovered: true, count: recovery_count}}
{:error, :not_found} ->
# Normal startup, no previous state to recover
{map_ids, %{recovered: false}}
end
# Register with empty list - maps will be added as they're started in handle_continue
{:ok, _} = Registry.register(@unique_registry, Module.concat(__MODULE__, uuid), [])
{:ok, _} = Registry.register(@registry, __MODULE__, uuid)
map_ids
|> Enum.each(fn id ->
Cachex.put(@cache, id, uuid)
end)
# Don't pre-populate cache - will be populated as maps start in handle_continue
# This prevents duplicates when recovering
state =
%{
@@ -53,32 +92,99 @@ defmodule WandererApp.Map.MapPool do
}
|> new()
{:ok, state, {:continue, {:start, map_ids}}}
{:ok, state, {:continue, {:start, {final_map_ids, recovery_info}}}}
end
@impl true
def terminate(_reason, _state) do
def terminate(reason, %{uuid: uuid} = _state) do
# On graceful shutdown, clean up ETS state
# On crash, keep ETS state for recovery
case reason do
:normal ->
Logger.debug("[Map Pool #{uuid}] Graceful shutdown, cleaning up ETS state")
MapPoolState.delete_pool_state(uuid)
:shutdown ->
Logger.debug("[Map Pool #{uuid}] Graceful shutdown, cleaning up ETS state")
MapPoolState.delete_pool_state(uuid)
{:shutdown, _} ->
Logger.debug("[Map Pool #{uuid}] Graceful shutdown, cleaning up ETS state")
MapPoolState.delete_pool_state(uuid)
_ ->
Logger.warning(
"[Map Pool #{uuid}] Abnormal termination (#{inspect(reason)}), keeping ETS state for recovery"
)
# Keep ETS state for crash recovery
:ok
end
:ok
end
@impl true
def handle_continue({:start, map_ids}, state) do
def handle_continue({:start, {map_ids, recovery_info}}, state) do
Logger.info("#{@name} started")
# Track recovery statistics
start_time = System.monotonic_time(:millisecond)
initial_count = length(map_ids)
# Start maps synchronously and accumulate state changes
new_state =
{new_state, failed_maps} =
map_ids
|> Enum.reduce(state, fn map_id, current_state ->
|> Enum.reduce({state, []}, fn map_id, {current_state, failed} ->
case do_start_map(map_id, current_state) do
{:ok, updated_state} ->
updated_state
{updated_state, failed}
{:error, reason} ->
Logger.error("[Map Pool] Failed to start map #{map_id}: #{reason}")
current_state
# Emit telemetry for individual map recovery failure
if recovery_info.recovered do
:telemetry.execute(
[:wanderer_app, :map_pool, :recovery, :map_failed],
%{map_id: map_id},
%{pool_uuid: state.uuid, reason: reason}
)
end
{current_state, [map_id | failed]}
end
end)
# Calculate final statistics
end_time = System.monotonic_time(:millisecond)
duration_ms = end_time - start_time
successful_count = length(new_state.map_ids)
failed_count = length(failed_maps)
# Log and emit telemetry for recovery completion
if recovery_info.recovered do
Logger.info(
"[Map Pool #{state.uuid}] Crash recovery completed: #{successful_count}/#{initial_count} maps recovered in #{duration_ms}ms",
pool_uuid: state.uuid,
recovered_count: successful_count,
failed_count: failed_count,
total_count: initial_count,
duration_ms: duration_ms,
failed_maps: failed_maps
)
:telemetry.execute(
[:wanderer_app, :map_pool, :recovery, :complete],
%{
recovered_count: successful_count,
failed_count: failed_count,
duration_ms: duration_ms
},
%{pool_uuid: state.uuid}
)
end
# Schedule periodic tasks
Process.send_after(self(), :backup_state, @backup_state_timeout)
Process.send_after(self(), :cleanup_systems, 15_000)
@@ -91,6 +197,55 @@ defmodule WandererApp.Map.MapPool do
{:noreply, new_state}
end
@impl true
def handle_continue({:init_map, map_id}, %{uuid: uuid} = state) do
# Perform the actual map initialization asynchronously
# This runs after the GenServer.call has already returned
start_time = System.monotonic_time(:millisecond)
try do
# Initialize the map state and start the map server using extracted helper
do_initialize_map_server(map_id)
duration = System.monotonic_time(:millisecond) - start_time
Logger.info("[Map Pool #{uuid}] Map #{map_id} initialized successfully in #{duration}ms")
# Emit telemetry for slow initializations
if duration > 5_000 do
Logger.warning("[Map Pool #{uuid}] Slow map initialization: #{map_id} took #{duration}ms")
:telemetry.execute(
[:wanderer_app, :map_pool, :slow_init],
%{duration_ms: duration},
%{map_id: map_id, pool_uuid: uuid}
)
end
{:noreply, state}
rescue
e ->
duration = System.monotonic_time(:millisecond) - start_time
Logger.error("""
[Map Pool #{uuid}] Failed to initialize map #{map_id} after #{duration}ms: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
""")
# Rollback: Remove from state, registry, cache, and ETS using extracted helper
new_state = do_unregister_map(map_id, uuid, state)
# Emit telemetry for failed initialization
:telemetry.execute(
[:wanderer_app, :map_pool, :init_failed],
%{duration_ms: duration},
%{map_id: map_id, pool_uuid: uuid, reason: Exception.message(e)}
)
{:noreply, new_state}
end
end
@impl true
def handle_cast(:stop, state), do: {:stop, :normal, state}
@@ -111,13 +266,38 @@ defmodule WandererApp.Map.MapPool do
{:reply, :ok, state}
else
case do_start_map(map_id, state) do
{:ok, new_state} ->
{:reply, :ok, new_state}
# Check if map is already started or being initialized
if map_id in map_ids do
Logger.debug("[Map Pool #{uuid}] Map #{map_id} already in pool")
{:reply, {:ok, :already_started}, state}
else
# Pre-register the map in registry and cache to claim ownership
# This prevents race conditions where multiple pools try to start the same map
registry_result =
Registry.update_value(@unique_registry, Module.concat(__MODULE__, uuid), fn r_map_ids ->
[map_id | r_map_ids]
end)
{:error, _reason} ->
# Error already logged in do_start_map
{:reply, :ok, state}
case registry_result do
{_new_value, _old_value} ->
# Add to cache
Cachex.put(@cache, map_id, uuid)
# Add to state
new_state = %{state | map_ids: [map_id | map_ids]}
# Persist state to ETS
MapPoolState.save_pool_state(uuid, new_state.map_ids)
Logger.debug("[Map Pool #{uuid}] Map #{map_id} queued for async initialization")
# Return immediately and initialize asynchronously
{:reply, {:ok, :initializing}, new_state, {:continue, {:init_map, map_id}}}
:error ->
Logger.error("[Map Pool #{uuid}] Failed to register map #{map_id} in registry")
{:reply, {:error, :registration_failed}, state}
end
end
end
end
@@ -165,22 +345,25 @@ defmodule WandererApp.Map.MapPool do
# Step 2: Add to cache
case Cachex.put(@cache, map_id, uuid) do
{:ok, _} ->
completed_operations = [:cache | completed_operations]
:ok
{:error, reason} ->
raise "Failed to add to cache: #{inspect(reason)}"
end
# Step 3: Start the map server
map_id
|> WandererApp.Map.get_map_state!()
|> Server.Impl.start_map()
completed_operations = [:cache | completed_operations]
# Step 3: Start the map server using extracted helper
do_initialize_map_server(map_id)
completed_operations = [:map_server | completed_operations]
# Step 4: Update GenServer state (last, as this is in-memory and fast)
new_state = %{state | map_ids: [map_id | map_ids]}
# Step 5: Persist state to ETS for crash recovery
MapPoolState.save_pool_state(uuid, new_state.map_ids)
Logger.debug("[Map Pool] Successfully started map #{map_id} in pool #{uuid}")
{:ok, new_state}
rescue
@@ -263,12 +446,14 @@ defmodule WandererApp.Map.MapPool do
# Step 2: Delete from cache
case Cachex.del(@cache, map_id) do
{:ok, _} ->
completed_operations = [:cache | completed_operations]
:ok
{:error, reason} ->
raise "Failed to delete from cache: #{inspect(reason)}"
end
completed_operations = [:cache | completed_operations]
# Step 3: Stop the map server (clean up all map resources)
map_id
|> Server.Impl.stop_map()
@@ -278,6 +463,9 @@ defmodule WandererApp.Map.MapPool do
# Step 4: Update GenServer state (last, as this is in-memory and fast)
new_state = %{state | map_ids: map_ids |> Enum.reject(fn id -> id == map_id end)}
# Step 5: Persist state to ETS for crash recovery
MapPoolState.save_pool_state(uuid, new_state.map_ids)
Logger.debug("[Map Pool] Successfully stopped map #{map_id} from pool #{uuid}")
{:ok, new_state}
rescue
@@ -294,6 +482,35 @@ defmodule WandererApp.Map.MapPool do
end
end
# Helper function to initialize the map server (no state management)
# This extracts the common map initialization logic used in both
# synchronous (do_start_map) and asynchronous ({:init_map, map_id}) paths
defp do_initialize_map_server(map_id) do
map_id
|> WandererApp.Map.get_map_state!()
|> Server.Impl.start_map()
end
# Helper function to unregister a map from all tracking
# Used for rollback when map initialization fails in the async path
defp do_unregister_map(map_id, uuid, state) do
# Remove from registry
Registry.update_value(@unique_registry, Module.concat(__MODULE__, uuid), fn r_map_ids ->
Enum.reject(r_map_ids, &(&1 == map_id))
end)
# Remove from cache
Cachex.del(@cache, map_id)
# Update state
new_state = %{state | map_ids: Enum.reject(state.map_ids, &(&1 == map_id))}
# Update ETS
MapPoolState.save_pool_state(uuid, new_state.map_ids)
new_state
end
defp rollback_stop_map_operations(map_id, uuid, completed_operations) do
Logger.warning("[Map Pool] Attempting to rollback stop_map operations for #{map_id}")
@@ -335,10 +552,14 @@ defmodule WandererApp.Map.MapPool do
def handle_call(:error, _, state), do: {:stop, :error, :ok, state}
@impl true
def handle_info(:backup_state, %{map_ids: map_ids} = state) do
def handle_info(:backup_state, %{map_ids: map_ids, uuid: uuid} = state) do
Process.send_after(self(), :backup_state, @backup_state_timeout)
try do
# Persist pool state to ETS
MapPoolState.save_pool_state(uuid, map_ids)
# Backup individual map states to database
map_ids
|> Task.async_stream(
fn map_id ->
@@ -534,6 +755,57 @@ defmodule WandererApp.Map.MapPool do
{:noreply, state}
end
def handle_info(:map_deleted, %{map_ids: map_ids} = state) do
# When a map is deleted, stop all maps in this pool that are deleted
# This is a graceful shutdown triggered by user action
Logger.info("[Map Pool #{state.uuid}] Received map_deleted event, stopping affected maps")
# Check which of our maps were deleted and stop them
new_state =
map_ids
|> Enum.reduce(state, fn map_id, current_state ->
# Check if the map still exists in the database
case WandererApp.MapRepo.get(map_id) do
{:ok, %{deleted: true}} ->
Logger.info("[Map Pool #{state.uuid}] Map #{map_id} was deleted, stopping it")
case do_stop_map(map_id, current_state) do
{:ok, updated_state} ->
updated_state
{:error, reason} ->
Logger.error(
"[Map Pool #{state.uuid}] Failed to stop deleted map #{map_id}: #{reason}"
)
current_state
end
{:ok, _map} ->
# Map still exists and is not deleted
current_state
{:error, _} ->
# Map doesn't exist, should stop it
Logger.info("[Map Pool #{state.uuid}] Map #{map_id} not found, stopping it")
case do_stop_map(map_id, current_state) do
{:ok, updated_state} ->
updated_state
{:error, reason} ->
Logger.error(
"[Map Pool #{state.uuid}] Failed to stop missing map #{map_id}: #{reason}"
)
current_state
end
end
end)
{:noreply, new_state}
end
def handle_info(event, state) do
try do
Server.Impl.handle_event(event)

View File

@@ -7,7 +7,8 @@ defmodule WandererApp.Map.MapPoolDynamicSupervisor do
@cache :map_pool_cache
@registry :map_pool_registry
@unique_registry :unique_map_pool_registry
@map_pool_limit 20
@map_pool_limit 10
@genserver_call_timeout :timer.minutes(2)
@name __MODULE__
@@ -30,7 +31,32 @@ defmodule WandererApp.Map.MapPoolDynamicSupervisor do
start_child([map_id], pools |> Enum.count())
pid ->
GenServer.call(pid, {:start_map, map_id})
result = GenServer.call(pid, {:start_map, map_id}, @genserver_call_timeout)
case result do
{:ok, :initializing} ->
Logger.debug(
"[Map Pool Supervisor] Map #{map_id} queued for async initialization"
)
result
{:ok, :already_started} ->
Logger.debug("[Map Pool Supervisor] Map #{map_id} already started")
result
:ok ->
# Legacy synchronous response (from crash recovery path)
Logger.debug("[Map Pool Supervisor] Map #{map_id} started synchronously")
result
other ->
Logger.warning(
"[Map Pool Supervisor] Unexpected response for map #{map_id}: #{inspect(other)}"
)
other
end
end
end
end
@@ -59,7 +85,7 @@ defmodule WandererApp.Map.MapPoolDynamicSupervisor do
find_pool_by_scanning_registry(map_id)
[{pool_pid, _}] ->
GenServer.call(pool_pid, {:stop_map, map_id})
GenServer.call(pool_pid, {:stop_map, map_id}, @genserver_call_timeout)
end
{:error, reason} ->
@@ -102,7 +128,7 @@ defmodule WandererApp.Map.MapPoolDynamicSupervisor do
# Update the cache to fix the inconsistency
Cachex.put(@cache, map_id, pool_uuid)
GenServer.call(pool_pid, {:stop_map, map_id})
GenServer.call(pool_pid, {:stop_map, map_id}, @genserver_call_timeout)
nil ->
Logger.debug("Map #{map_id} not found in any pool registry")
@@ -140,9 +166,13 @@ defmodule WandererApp.Map.MapPoolDynamicSupervisor do
end
defp start_child(map_ids, pools_count) do
case DynamicSupervisor.start_child(@name, {WandererApp.Map.MapPool, map_ids}) do
# Generate UUID for the new pool - this will be used for crash recovery
uuid = UUID.uuid1()
# Pass both UUID and map_ids to the pool for crash recovery support
case DynamicSupervisor.start_child(@name, {WandererApp.Map.MapPool, {uuid, map_ids}}) do
{:ok, pid} ->
Logger.info("Starting map pool, total map_pools: #{pools_count + 1}")
Logger.info("Starting map pool #{uuid}, total map_pools: #{pools_count + 1}")
{:ok, pid}
{:error, {:already_started, pid}} ->

View File

@@ -0,0 +1,190 @@
defmodule WandererApp.Map.MapPoolState do
@moduledoc """
Helper module for persisting MapPool state to ETS for crash recovery.
This module provides functions to save and retrieve MapPool state from an ETS table.
The state survives GenServer crashes but is lost on node restart, which ensures
automatic recovery from crashes while avoiding stale state on system restart.
## ETS Table Ownership
The ETS table `:map_pool_state_table` is owned by the MapPoolSupervisor,
ensuring it survives individual MapPool process crashes.
## State Format
State is stored as tuples: `{pool_uuid, map_ids, last_updated_timestamp}`
where:
- `pool_uuid` is the unique identifier for the pool (key)
- `map_ids` is a list of map IDs managed by this pool
- `last_updated_timestamp` is the Unix timestamp of the last update
"""
require Logger
@table_name :map_pool_state_table
@stale_threshold_hours 24
@doc """
Initializes the ETS table for storing MapPool state.
This should be called by the MapPoolSupervisor during initialization.
The table is created as:
- `:set` - Each pool UUID has exactly one entry
- `:public` - Any process can read/write
- `:named_table` - Can be accessed by name
Returns the table reference or raises if table already exists.
"""
@spec init_table() :: :ets.table()
def init_table do
:ets.new(@table_name, [:set, :public, :named_table])
end
@doc """
Saves the current state of a MapPool to ETS.
## Parameters
- `uuid` - The unique identifier for the pool
- `map_ids` - List of map IDs currently managed by this pool
## Examples
iex> MapPoolState.save_pool_state("pool-123", [1, 2, 3])
:ok
"""
@spec save_pool_state(String.t(), [integer()]) :: :ok
def save_pool_state(uuid, map_ids) when is_binary(uuid) and is_list(map_ids) do
timestamp = System.system_time(:second)
true = :ets.insert(@table_name, {uuid, map_ids, timestamp})
Logger.debug("Saved MapPool state for #{uuid}: #{length(map_ids)} maps",
pool_uuid: uuid,
map_count: length(map_ids)
)
:ok
end
@doc """
Retrieves the saved state for a MapPool from ETS.
## Parameters
- `uuid` - The unique identifier for the pool
## Returns
- `{:ok, map_ids}` if state exists
- `{:error, :not_found}` if no state exists for this UUID
## Examples
iex> MapPoolState.get_pool_state("pool-123")
{:ok, [1, 2, 3]}
iex> MapPoolState.get_pool_state("non-existent")
{:error, :not_found}
"""
@spec get_pool_state(String.t()) :: {:ok, [integer()]} | {:error, :not_found}
def get_pool_state(uuid) when is_binary(uuid) do
case :ets.lookup(@table_name, uuid) do
[{^uuid, map_ids, _timestamp}] ->
{:ok, map_ids}
[] ->
{:error, :not_found}
end
end
@doc """
Deletes the state for a MapPool from ETS.
This should be called when a pool is gracefully shut down.
## Parameters
- `uuid` - The unique identifier for the pool
## Examples
iex> MapPoolState.delete_pool_state("pool-123")
:ok
"""
@spec delete_pool_state(String.t()) :: :ok
def delete_pool_state(uuid) when is_binary(uuid) do
true = :ets.delete(@table_name, uuid)
Logger.debug("Deleted MapPool state for #{uuid}", pool_uuid: uuid)
:ok
end
@doc """
Removes stale entries from the ETS table.
Entries are considered stale if they haven't been updated in the last
#{@stale_threshold_hours} hours. This helps prevent the table from growing
unbounded due to pool UUIDs that are no longer in use.
Returns the number of entries deleted.
## Examples
iex> MapPoolState.cleanup_stale_entries()
{:ok, 3}
"""
@spec cleanup_stale_entries() :: {:ok, non_neg_integer()}
def cleanup_stale_entries do
stale_threshold = System.system_time(:second) - @stale_threshold_hours * 3600
match_spec = [
{
{:"$1", :"$2", :"$3"},
[{:<, :"$3", stale_threshold}],
[:"$1"]
}
]
stale_uuids = :ets.select(@table_name, match_spec)
Enum.each(stale_uuids, fn uuid ->
:ets.delete(@table_name, uuid)
Logger.info("Cleaned up stale MapPool state for #{uuid}",
pool_uuid: uuid,
reason: :stale
)
end)
{:ok, length(stale_uuids)}
end
@doc """
Returns all pool states currently stored in ETS.
Useful for debugging and monitoring.
## Examples
iex> MapPoolState.list_all_states()
[
{"pool-123", [1, 2, 3], 1699564800},
{"pool-456", [4, 5], 1699564900}
]
"""
@spec list_all_states() :: [{String.t(), [integer()], integer()}]
def list_all_states do
:ets.tab2list(@table_name)
end
@doc """
Returns the count of pool states currently stored in ETS.
## Examples
iex> MapPoolState.count_states()
5
"""
@spec count_states() :: non_neg_integer()
def count_states do
:ets.info(@table_name, :size)
end
end

View File

@@ -2,6 +2,8 @@ defmodule WandererApp.Map.MapPoolSupervisor do
@moduledoc false
use Supervisor
alias WandererApp.Map.MapPoolState
@name __MODULE__
@registry :map_pool_registry
@unique_registry :unique_map_pool_registry
@@ -11,6 +13,10 @@ defmodule WandererApp.Map.MapPoolSupervisor do
end
def init(_args) do
# Initialize ETS table for MapPool state persistence
# This table survives individual MapPool crashes but is lost on node restart
MapPoolState.init_table()
children = [
{Registry, [keys: :unique, name: @unique_registry]},
{Registry, [keys: :duplicate, name: @registry]},

View File

@@ -155,6 +155,13 @@ defmodule WandererApp.Map.Server.CharactersImpl do
Task.start_link(fn ->
with :ok <- WandererApp.Map.remove_character(map_id, character_id),
{:ok, character} <- WandererApp.Character.get_map_character(map_id, character_id) do
# Clean up character-specific cache entries
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:start_solar_system_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:solar_system_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:station_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:structure_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:location_updated_at")
Impl.broadcast!(map_id, :character_removed, character)
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
@@ -193,98 +200,104 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
end
# Calculate optimal concurrency based on character count
# Scales from base concurrency (32 on 8-core) up to 128 for 300+ characters
defp calculate_max_concurrency(character_count) do
base_concurrency = System.schedulers_online() * 4
cond do
character_count < 100 -> base_concurrency
character_count < 200 -> base_concurrency * 2
character_count < 300 -> base_concurrency * 3
true -> base_concurrency * 4
end
end
def update_characters(map_id) do
start_time = System.monotonic_time(:microsecond)
try do
{:ok, presence_character_ids} =
WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
presence_character_ids
|> Task.async_stream(
fn character_id ->
character_updates =
maybe_update_online(map_id, character_id) ++
maybe_update_tracking_status(map_id, character_id) ++
maybe_update_location(map_id, character_id) ++
maybe_update_ship(map_id, character_id) ++
maybe_update_alliance(map_id, character_id) ++
maybe_update_corporation(map_id, character_id)
character_count = length(presence_character_ids)
character_updates
|> Enum.filter(fn update -> update != :skip end)
|> Enum.map(fn update ->
update
|> case do
{:character_location, location_info, old_location_info} ->
{:ok, map_state} = WandererApp.Map.get_map_state(map_id)
update_location(
map_state,
character_id,
location_info,
old_location_info
)
:broadcast
{:character_ship, _info} ->
:broadcast
{:character_online, _info} ->
:broadcast
{:character_tracking, _info} ->
:broadcast
{:character_alliance, _info} ->
WandererApp.Cache.insert_or_update(
"map_#{map_id}:invalidate_character_ids",
[character_id],
fn ids ->
[character_id | ids] |> Enum.uniq()
end
)
:broadcast
{:character_corporation, _info} ->
WandererApp.Cache.insert_or_update(
"map_#{map_id}:invalidate_character_ids",
[character_id],
fn ids ->
[character_id | ids] |> Enum.uniq()
end
)
:broadcast
_ ->
:skip
end
end)
|> Enum.filter(fn update -> update != :skip end)
|> Enum.uniq()
|> Enum.each(fn update ->
case update do
:broadcast ->
update_character(map_id, character_id)
_ ->
:ok
end
end)
:ok
end,
timeout: :timer.seconds(15),
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task
# Emit telemetry for tracking update cycle start
:telemetry.execute(
[:wanderer_app, :map, :update_characters, :start],
%{character_count: character_count, system_time: System.system_time()},
%{map_id: map_id}
)
|> Enum.each(fn
{:ok, _result} -> :ok
{:error, reason} -> Logger.error("Error in update_characters: #{inspect(reason)}")
end)
# Calculate dynamic concurrency based on character count
max_concurrency = calculate_max_concurrency(character_count)
updated_characters =
presence_character_ids
|> Task.async_stream(
fn character_id ->
# Use batch cache operations for all character tracking data
process_character_updates_batched(map_id, character_id)
end,
timeout: :timer.seconds(15),
max_concurrency: max_concurrency,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, {:updated, character}}, acc ->
[character | acc]
{:ok, _result}, acc ->
acc
{:error, reason}, acc ->
Logger.error("Error in update_characters: #{inspect(reason)}")
acc
end)
unless Enum.empty?(updated_characters) do
# Broadcast to internal channels
Impl.broadcast!(map_id, :characters_updated, %{
characters: updated_characters,
timestamp: DateTime.utc_now()
})
# Broadcast to external event system (webhooks/WebSocket)
WandererApp.ExternalEvents.broadcast(map_id, :characters_updated, %{
characters: updated_characters,
timestamp: DateTime.utc_now()
})
end
# Emit telemetry for successful completion
duration = System.monotonic_time(:microsecond) - start_time
:telemetry.execute(
[:wanderer_app, :map, :update_characters, :complete],
%{
duration: duration,
character_count: character_count,
updated_count: length(updated_characters),
system_time: System.system_time()
},
%{map_id: map_id}
)
:ok
rescue
e ->
# Emit telemetry for error case
duration = System.monotonic_time(:microsecond) - start_time
:telemetry.execute(
[:wanderer_app, :map, :update_characters, :error],
%{
duration: duration,
system_time: System.system_time()
},
%{map_id: map_id, error: Exception.message(e)}
)
Logger.error("""
[Map Server] update_characters => exception: #{Exception.message(e)}
#{Exception.format_stacktrace(__STACKTRACE__)}
@@ -292,8 +305,372 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
end
defp update_character(map_id, character_id) do
{:ok, character} = WandererApp.Character.get_map_character(map_id, character_id)
defp calculate_character_state_hash(character) do
# Hash all trackable fields for quick comparison
:erlang.phash2(%{
online: character.online,
ship: character.ship,
ship_name: character.ship_name,
ship_item_id: character.ship_item_id,
solar_system_id: character.solar_system_id,
station_id: character.station_id,
structure_id: character.structure_id,
alliance_id: character.alliance_id,
corporation_id: character.corporation_id
})
end
defp process_character_updates_batched(map_id, character_id) do
# Step 1: Get current character data for hash comparison
case WandererApp.Character.get_character(character_id) do
{:ok, character} ->
new_hash = calculate_character_state_hash(character)
state_hash_key = "map:#{map_id}:character:#{character_id}:state_hash"
{:ok, old_hash} = WandererApp.Cache.lookup(state_hash_key, nil)
if new_hash == old_hash do
# No changes detected - skip expensive processing (70-90% of cases)
:no_change
else
# Changes detected - proceed with full processing
process_character_changes(map_id, character_id, character, state_hash_key, new_hash)
end
{:error, _error} ->
:ok
end
end
# Process character changes when hash indicates updates
defp process_character_changes(map_id, character_id, character, state_hash_key, new_hash) do
# Step 1: Batch read all cached values for this character
cache_keys = [
"map:#{map_id}:character:#{character_id}:online",
"map:#{map_id}:character:#{character_id}:ship_type_id",
"map:#{map_id}:character:#{character_id}:ship_name",
"map:#{map_id}:character:#{character_id}:solar_system_id",
"map:#{map_id}:character:#{character_id}:station_id",
"map:#{map_id}:character:#{character_id}:structure_id",
"map:#{map_id}:character:#{character_id}:location_updated_at",
"map:#{map_id}:character:#{character_id}:alliance_id",
"map:#{map_id}:character:#{character_id}:corporation_id"
]
{:ok, cached_values} = WandererApp.Cache.lookup_all(cache_keys)
# Step 2: Calculate all updates
{character_updates, cache_updates} =
calculate_character_updates(map_id, character_id, character, cached_values)
# Step 3: Update the state hash in cache
cache_updates = Map.put(cache_updates, state_hash_key, new_hash)
# Step 4: Batch write all cache updates
unless Enum.empty?(cache_updates) do
WandererApp.Cache.insert_all(cache_updates)
end
# Step 5: Process update events
has_updates =
character_updates
|> Enum.filter(fn update -> update != :skip end)
|> Enum.map(fn update ->
case update do
{:character_location, location_info, old_location_info} ->
start_time = System.monotonic_time(:microsecond)
:telemetry.execute(
[:wanderer_app, :character, :location_update, :start],
%{system_time: System.system_time()},
%{
character_id: character_id,
map_id: map_id,
from_system: old_location_info.solar_system_id,
to_system: location_info.solar_system_id
}
)
{:ok, map_state} = WandererApp.Map.get_map_state(map_id)
update_location(
map_state,
character_id,
location_info,
old_location_info
)
duration = System.monotonic_time(:microsecond) - start_time
:telemetry.execute(
[:wanderer_app, :character, :location_update, :complete],
%{duration: duration, system_time: System.system_time()},
%{
character_id: character_id,
map_id: map_id,
from_system: old_location_info.solar_system_id,
to_system: location_info.solar_system_id
}
)
:has_update
{:character_ship, _info} ->
:has_update
{:character_online, _info} ->
:has_update
{:character_tracking, _info} ->
:has_update
{:character_alliance, _info} ->
WandererApp.Cache.insert_or_update(
"map_#{map_id}:invalidate_character_ids",
[character_id],
fn ids ->
[character_id | ids] |> Enum.uniq()
end
)
:has_update
{:character_corporation, _info} ->
WandererApp.Cache.insert_or_update(
"map_#{map_id}:invalidate_character_ids",
[character_id],
fn ids ->
[character_id | ids] |> Enum.uniq()
end
)
:has_update
_ ->
:skip
end
end)
|> Enum.any?(fn result -> result == :has_update end)
if has_updates do
case WandererApp.Character.get_map_character(map_id, character_id) do
{:ok, character} ->
{:updated, character}
{:error, _} ->
:ok
end
else
:ok
end
end
# Calculate all character updates in a single pass
defp calculate_character_updates(map_id, character_id, character, cached_values) do
updates = []
cache_updates = %{}
# Check each type of update using specialized functions
{updates, cache_updates} =
check_online_update(map_id, character_id, character, cached_values, updates, cache_updates)
{updates, cache_updates} =
check_ship_update(map_id, character_id, character, cached_values, updates, cache_updates)
{updates, cache_updates} =
check_location_update(
map_id,
character_id,
character,
cached_values,
updates,
cache_updates
)
{updates, cache_updates} =
check_alliance_update(
map_id,
character_id,
character,
cached_values,
updates,
cache_updates
)
{updates, cache_updates} =
check_corporation_update(
map_id,
character_id,
character,
cached_values,
updates,
cache_updates
)
{updates, cache_updates}
end
# Check for online status changes
defp check_online_update(map_id, character_id, character, cached_values, updates, cache_updates) do
online_key = "map:#{map_id}:character:#{character_id}:online"
old_online = Map.get(cached_values, online_key)
if character.online != old_online do
{
[{:character_online, %{online: character.online}} | updates],
Map.put(cache_updates, online_key, character.online)
}
else
{updates, cache_updates}
end
end
# Check for ship changes
defp check_ship_update(map_id, character_id, character, cached_values, updates, cache_updates) do
ship_type_key = "map:#{map_id}:character:#{character_id}:ship_type_id"
ship_name_key = "map:#{map_id}:character:#{character_id}:ship_name"
old_ship_type_id = Map.get(cached_values, ship_type_key)
old_ship_name = Map.get(cached_values, ship_name_key)
if character.ship != old_ship_type_id or character.ship_name != old_ship_name do
{
[
{:character_ship,
%{
ship: character.ship,
ship_name: character.ship_name,
ship_item_id: character.ship_item_id
}}
| updates
],
cache_updates
|> Map.put(ship_type_key, character.ship)
|> Map.put(ship_name_key, character.ship_name)
}
else
{updates, cache_updates}
end
end
# Check for location changes with race condition detection
defp check_location_update(
map_id,
character_id,
character,
cached_values,
updates,
cache_updates
) do
solar_system_key = "map:#{map_id}:character:#{character_id}:solar_system_id"
station_key = "map:#{map_id}:character:#{character_id}:station_id"
structure_key = "map:#{map_id}:character:#{character_id}:structure_id"
location_timestamp_key = "map:#{map_id}:character:#{character_id}:location_updated_at"
old_solar_system_id = Map.get(cached_values, solar_system_key)
old_station_id = Map.get(cached_values, station_key)
old_structure_id = Map.get(cached_values, structure_key)
old_timestamp = Map.get(cached_values, location_timestamp_key)
if character.solar_system_id != old_solar_system_id ||
character.structure_id != old_structure_id ||
character.station_id != old_station_id do
# Race condition detection
{:ok, current_cached_timestamp} =
WandererApp.Cache.lookup(location_timestamp_key)
race_detected =
!is_nil(old_timestamp) && !is_nil(current_cached_timestamp) &&
old_timestamp != current_cached_timestamp
if race_detected do
Logger.warning(
"[CharacterTracking] Race condition detected for character #{character_id} on map #{map_id}: " <>
"cache was modified between read (#{inspect(old_timestamp)}) and write (#{inspect(current_cached_timestamp)})"
)
:telemetry.execute(
[:wanderer_app, :character, :location_update, :race_condition],
%{system_time: System.system_time()},
%{
character_id: character_id,
map_id: map_id,
old_system: old_solar_system_id,
new_system: character.solar_system_id,
old_timestamp: old_timestamp,
current_timestamp: current_cached_timestamp
}
)
end
now = DateTime.utc_now()
{
[
{:character_location,
%{
solar_system_id: character.solar_system_id,
structure_id: character.structure_id,
station_id: character.station_id
}, %{solar_system_id: old_solar_system_id}}
| updates
],
cache_updates
|> Map.put(solar_system_key, character.solar_system_id)
|> Map.put(station_key, character.station_id)
|> Map.put(structure_key, character.structure_id)
|> Map.put(location_timestamp_key, now)
}
else
{updates, cache_updates}
end
end
# Check for alliance changes
defp check_alliance_update(
map_id,
character_id,
character,
cached_values,
updates,
cache_updates
) do
alliance_key = "map:#{map_id}:character:#{character_id}:alliance_id"
old_alliance_id = Map.get(cached_values, alliance_key)
if character.alliance_id != old_alliance_id do
{
[{:character_alliance, %{alliance_id: character.alliance_id}} | updates],
Map.put(cache_updates, alliance_key, character.alliance_id)
}
else
{updates, cache_updates}
end
end
# Check for corporation changes
defp check_corporation_update(
map_id,
character_id,
character,
cached_values,
updates,
cache_updates
) do
corporation_key = "map:#{map_id}:character:#{character_id}:corporation_id"
old_corporation_id = Map.get(cached_values, corporation_key)
if character.corporation_id != old_corporation_id do
{
[{:character_corporation, %{corporation_id: character.corporation_id}} | updates],
Map.put(cache_updates, corporation_key, character.corporation_id)
}
else
{updates, cache_updates}
end
end
# Updated to accept character struct directly to avoid redundant queries
defp update_character(map_id, %{id: _character_id} = character) do
Impl.broadcast!(map_id, :character_updated, character)
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
@@ -308,16 +685,31 @@ defmodule WandererApp.Map.Server.CharactersImpl do
old_location
) do
start_solar_system_id =
WandererApp.Cache.take("map:#{map_id}:character:#{character_id}:start_solar_system_id")
case WandererApp.Cache.lookup(
"map:#{map_id}:character:#{character_id}:start_solar_system_id"
) do
{:ok, value} -> value
:error -> nil
end
case is_nil(old_location.solar_system_id) &&
is_nil(start_solar_system_id) &&
ConnectionsImpl.can_add_location(scope, location.solar_system_id) do
true ->
:ok = SystemsImpl.maybe_add_system(map_id, location, nil, map_opts)
case SystemsImpl.maybe_add_system(map_id, location, nil, map_opts) do
:ok ->
:ok
{:error, error} ->
Logger.error(
"[CharacterTracking] Failed to add initial system #{location.solar_system_id} for character #{character_id} on map #{map_id}: #{inspect(error)}"
)
:ok
end
_ ->
if is_nil(start_solar_system_id) || start_solar_system_id == old_location.solar_system_id do
if is_nil(start_solar_system_id) || location.solar_system_id != start_solar_system_id do
ConnectionsImpl.is_connection_valid(
scope,
old_location.solar_system_id,
@@ -325,24 +717,50 @@ defmodule WandererApp.Map.Server.CharactersImpl do
)
|> case do
true ->
:ok =
SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts)
# Add new location system
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts) do
:ok ->
:ok
:ok =
SystemsImpl.maybe_add_system(map_id, old_location, location, map_opts)
if is_character_in_space?(location) do
:ok =
ConnectionsImpl.maybe_add_connection(
map_id,
location,
old_location,
character_id,
false,
nil
{:error, error} ->
Logger.error(
"[CharacterTracking] Failed to add new location system #{location.solar_system_id} for character #{character_id} on map #{map_id}: #{inspect(error)}"
)
end
# Add old location system (in case it wasn't on map)
case SystemsImpl.maybe_add_system(map_id, old_location, location, map_opts) do
:ok ->
:ok
{:error, error} ->
Logger.error(
"[CharacterTracking] Failed to add old location system #{old_location.solar_system_id} for character #{character_id} on map #{map_id}: #{inspect(error)}"
)
end
# Add connection if character is in space
if is_character_in_space?(location) do
case ConnectionsImpl.maybe_add_connection(
map_id,
location,
old_location,
character_id,
false,
nil
) do
:ok ->
:ok
{:error, error} ->
Logger.error(
"[CharacterTracking] Failed to add connection for character #{character_id} on map #{map_id}: #{inspect(error)}"
)
:ok
end
end
_ ->
:ok
end
@@ -390,12 +808,10 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
defp track_character(map_id, character_id) do
{:ok, %{solar_system_id: solar_system_id} = map_character} =
WandererApp.Character.get_map_character(map_id, character_id, not_present: true)
{:ok, %{solar_system_id: solar_system_id} = character} =
WandererApp.Character.get_character(character_id)
WandererApp.Cache.delete("character:#{character_id}:tracking_paused")
add_character(map_id, map_character, true)
add_character(map_id, character, true)
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{
map_id: map_id,
@@ -406,181 +822,4 @@ defmodule WandererApp.Map.Server.CharactersImpl do
solar_system_id: solar_system_id
})
end
defp maybe_update_online(map_id, character_id) do
with {:ok, old_online} <-
WandererApp.Cache.lookup("map:#{map_id}:character:#{character_id}:online"),
{:ok, %{online: online}} <-
WandererApp.Character.get_character(character_id) do
case old_online != online do
true ->
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:online",
online
)
[{:character_online, %{online: online}}]
_ ->
[:skip]
end
else
error ->
Logger.error("Failed to update online: #{inspect(error, pretty: true)}")
[:skip]
end
end
defp maybe_update_tracking_status(map_id, character_id) do
with {:ok, old_tracking_paused} <-
WandererApp.Cache.lookup(
"map:#{map_id}:character:#{character_id}:tracking_paused",
false
),
{:ok, tracking_paused} <-
WandererApp.Cache.lookup("character:#{character_id}:tracking_paused", false) do
case old_tracking_paused != tracking_paused do
true ->
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:tracking_paused",
tracking_paused
)
[{:character_tracking, %{tracking_paused: tracking_paused}}]
_ ->
[:skip]
end
else
error ->
Logger.error("Failed to update character_tracking: #{inspect(error, pretty: true)}")
[:skip]
end
end
defp maybe_update_ship(map_id, character_id) do
with {:ok, old_ship_type_id} <-
WandererApp.Cache.lookup("map:#{map_id}:character:#{character_id}:ship_type_id"),
{:ok, old_ship_name} <-
WandererApp.Cache.lookup("map:#{map_id}:character:#{character_id}:ship_name"),
{:ok, %{ship: ship_type_id, ship_name: ship_name, ship_item_id: ship_item_id}} <-
WandererApp.Character.get_character(character_id) do
case old_ship_type_id != ship_type_id or
old_ship_name != ship_name do
true ->
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:ship_type_id",
ship_type_id
)
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:ship_name",
ship_name
)
[
{:character_ship,
%{ship: ship_type_id, ship_name: ship_name, ship_item_id: ship_item_id}}
]
_ ->
[:skip]
end
else
error ->
Logger.error("Failed to update ship: #{inspect(error, pretty: true)}")
[:skip]
end
end
defp maybe_update_location(map_id, character_id) do
{:ok, old_solar_system_id} =
WandererApp.Cache.lookup("map:#{map_id}:character:#{character_id}:solar_system_id")
{:ok, old_station_id} =
WandererApp.Cache.lookup("map:#{map_id}:character:#{character_id}:station_id")
{:ok, old_structure_id} =
WandererApp.Cache.lookup("map:#{map_id}:character:#{character_id}:structure_id")
{:ok, %{solar_system_id: solar_system_id, structure_id: structure_id, station_id: station_id}} =
WandererApp.Character.get_character(character_id)
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:solar_system_id",
solar_system_id
)
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:station_id",
station_id
)
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:structure_id",
structure_id
)
if solar_system_id != old_solar_system_id || structure_id != old_structure_id ||
station_id != old_station_id do
[
{:character_location,
%{
solar_system_id: solar_system_id,
structure_id: structure_id,
station_id: station_id
}, %{solar_system_id: old_solar_system_id}}
]
else
[:skip]
end
end
defp maybe_update_alliance(map_id, character_id) do
with {:ok, old_alliance_id} <-
WandererApp.Cache.lookup("map:#{map_id}:character:#{character_id}:alliance_id"),
{:ok, %{alliance_id: alliance_id}} <-
WandererApp.Character.get_character(character_id) do
case old_alliance_id != alliance_id do
true ->
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:alliance_id",
alliance_id
)
[{:character_alliance, %{alliance_id: alliance_id}}]
_ ->
[:skip]
end
else
error ->
Logger.error("Failed to update alliance: #{inspect(error, pretty: true)}")
[:skip]
end
end
defp maybe_update_corporation(map_id, character_id) do
with {:ok, old_corporation_id} <-
WandererApp.Cache.lookup("map:#{map_id}:character:#{character_id}:corporation_id"),
{:ok, %{corporation_id: corporation_id}} <-
WandererApp.Character.get_character(character_id) do
case old_corporation_id != corporation_id do
true ->
WandererApp.Cache.insert(
"map:#{map_id}:character:#{character_id}:corporation_id",
corporation_id
)
[{:character_corporation, %{corporation_id: corporation_id}}]
_ ->
[:skip]
end
else
error ->
Logger.error("Failed to update corporation: #{inspect(error, pretty: true)}")
[:skip]
end
end
end

View File

@@ -401,7 +401,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
)
else
error ->
Logger.error("Failed to update_linked_signature_time_status: #{inspect(error)}")
Logger.warning("Failed to update_linked_signature_time_status: #{inspect(error)}")
end
end

View File

@@ -25,6 +25,7 @@ defmodule WandererApp.Map.Server.Impl do
]
@pubsub_client Application.compile_env(:wanderer_app, :pubsub_client)
@ddrt Application.compile_env(:wanderer_app, :ddrt)
@connections_cleanup_timeout :timer.minutes(1)
@@ -45,19 +46,77 @@ defmodule WandererApp.Map.Server.Impl do
}
|> new()
with {:ok, map} <-
WandererApp.MapRepo.get(map_id, [
:owner,
:characters,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
]),
{:ok, systems} <- WandererApp.MapSystemRepo.get_visible_by_map(map_id),
{:ok, connections} <- WandererApp.MapConnectionRepo.get_by_map(map_id),
{:ok, subscription_settings} <-
WandererApp.Map.SubscriptionManager.get_active_map_subscription(map_id) do
# Parallelize database queries for faster initialization
start_time = System.monotonic_time(:millisecond)
tasks = [
Task.async(fn ->
{:map,
WandererApp.MapRepo.get(map_id, [
:owner,
:characters,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
])}
end),
Task.async(fn ->
{:systems, WandererApp.MapSystemRepo.get_visible_by_map(map_id)}
end),
Task.async(fn ->
{:connections, WandererApp.MapConnectionRepo.get_by_map(map_id)}
end),
Task.async(fn ->
{:subscription, WandererApp.Map.SubscriptionManager.get_active_map_subscription(map_id)}
end)
]
results = Task.await_many(tasks, :timer.seconds(15))
duration = System.monotonic_time(:millisecond) - start_time
# Emit telemetry for slow initializations
if duration > 5_000 do
Logger.warning("[Map Server] Slow map state initialization: #{map_id} took #{duration}ms")
:telemetry.execute(
[:wanderer_app, :map, :slow_init],
%{duration_ms: duration},
%{map_id: map_id}
)
end
# Extract results
map_result =
Enum.find_value(results, fn
{:map, result} -> result
_ -> nil
end)
systems_result =
Enum.find_value(results, fn
{:systems, result} -> result
_ -> nil
end)
connections_result =
Enum.find_value(results, fn
{:connections, result} -> result
_ -> nil
end)
subscription_result =
Enum.find_value(results, fn
{:subscription, result} -> result
_ -> nil
end)
# Process results
with {:ok, map} <- map_result,
{:ok, systems} <- systems_result,
{:ok, connections} <- connections_result,
{:ok, subscription_settings} <- subscription_result do
initial_state
|> init_map(
map,
@@ -88,7 +147,6 @@ defmodule WandererApp.Map.Server.Impl do
"maps:#{map_id}"
)
WandererApp.Map.CacheRTree.init_tree("rtree_#{map_id}", %{width: 150, verbose: false})
Process.send_after(self(), {:update_characters, map_id}, @update_characters_timeout)
Process.send_after(
@@ -358,6 +416,13 @@ defmodule WandererApp.Map.Server.Impl do
update_options(map_id, options)
end
def handle_event(:map_deleted) do
# Map has been deleted - this event is handled by MapPool to stop the server
# and by MapLive to redirect users. Nothing to do here.
Logger.debug("Map deletion event received, will be handled by MapPool")
:ok
end
def handle_event({ref, _result}) when is_reference(ref) do
Process.demonitor(ref, [:flush])
end
@@ -452,6 +517,8 @@ defmodule WandererApp.Map.Server.Impl do
) do
{:ok, options} = WandererApp.MapRepo.options_to_form_data(initial_map)
@ddrt.init_tree("rtree_#{map_id}", %{width: 150, verbose: false})
map =
initial_map
|> WandererApp.Map.new()

View File

@@ -431,6 +431,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
def maybe_add_system(map_id, location, old_location, map_opts)
when not is_nil(location) do
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :start],
%{system_time: System.system_time()},
%{
map_id: map_id,
solar_system_id: location.solar_system_id,
from_system: old_location && old_location.solar_system_id
}
)
case WandererApp.Map.check_location(map_id, location) do
{:ok, location} ->
rtree_name = "rtree_#{map_id}"
@@ -481,13 +491,25 @@ defmodule WandererApp.Map.Server.SystemsImpl do
position_y: updated_system.position_y
})
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :complete],
%{system_time: System.system_time()},
%{
map_id: map_id,
solar_system_id: updated_system.solar_system_id,
system_id: updated_system.id,
operation: :update_existing
}
)
:ok
_ ->
{:ok, solar_system_info} =
WandererApp.CachedInfo.get_system_static_info(location.solar_system_id)
WandererApp.MapSystemRepo.create(%{
# Use upsert instead of create - handles race conditions gracefully
WandererApp.MapSystemRepo.upsert(%{
map_id: map_id,
solar_system_id: location.solar_system_id,
name: solar_system_info.solar_system_name,
@@ -495,35 +517,79 @@ defmodule WandererApp.Map.Server.SystemsImpl do
position_y: position.y
})
|> case do
{:ok, new_system} ->
{:ok, system} ->
# System was either created or updated - both cases are success
@ddrt.insert(
{new_system.solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(new_system)},
{system.solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(system)},
rtree_name
)
WandererApp.Cache.put(
"map_#{map_id}:system_#{new_system.id}:last_activity",
"map_#{map_id}:system_#{system.id}:last_activity",
DateTime.utc_now(),
ttl: @system_inactive_timeout
)
WandererApp.Map.add_system(map_id, new_system)
Impl.broadcast!(map_id, :add_system, new_system)
WandererApp.Map.add_system(map_id, system)
Impl.broadcast!(map_id, :add_system, system)
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
WandererApp.ExternalEvents.broadcast(map_id, :add_system, %{
solar_system_id: new_system.solar_system_id,
name: new_system.name,
position_x: new_system.position_x,
position_y: new_system.position_y
solar_system_id: system.solar_system_id,
name: system.name,
position_x: system.position_x,
position_y: system.position_y
})
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :complete],
%{system_time: System.system_time()},
%{
map_id: map_id,
solar_system_id: system.solar_system_id,
system_id: system.id,
operation: :upsert
}
)
:ok
{:error, error} = result ->
Logger.warning(
"[CharacterTracking] Failed to upsert system #{location.solar_system_id} on map #{map_id}: #{inspect(error, pretty: true)}"
)
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :error],
%{system_time: System.system_time()},
%{
map_id: map_id,
solar_system_id: location.solar_system_id,
error: error,
reason: :db_upsert_failed
}
)
result
error ->
Logger.warning("Failed to create system: #{inspect(error, pretty: true)}")
:ok
Logger.warning(
"[CharacterTracking] Failed to upsert system #{location.solar_system_id} on map #{map_id}: #{inspect(error, pretty: true)}"
)
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :error],
%{system_time: System.system_time()},
%{
map_id: map_id,
solar_system_id: location.solar_system_id,
error: error,
reason: :db_upsert_failed_unexpected
}
)
{:error, error}
end
end
@@ -804,6 +870,10 @@ defmodule WandererApp.Map.Server.SystemsImpl do
update_map_system_last_activity(map_id, updated_system)
else
{:error, error} ->
Logger.error("Failed to update system: #{inspect(error, pretty: true)}")
:ok
error ->
Logger.error("Failed to update system: #{inspect(error, pretty: true)}")
:ok

View File

@@ -3,11 +3,25 @@ defmodule WandererApp.MapPingsRepo do
require Logger
def get_by_id(ping_id),
do: WandererApp.Api.MapPing.by_id!(ping_id) |> Ash.load([:system])
def get_by_id(ping_id) do
case WandererApp.Api.MapPing.by_id(ping_id) do
{:ok, ping} ->
ping |> Ash.load([:system])
def get_by_map(map_id),
do: WandererApp.Api.MapPing.by_map!(%{map_id: map_id}) |> Ash.load([:character, :system])
error ->
error
end
end
def get_by_map(map_id) do
case WandererApp.Api.MapPing.by_map(%{map_id: map_id}) do
{:ok, ping} ->
ping |> Ash.load([:character, :system])
error ->
error
end
end
def get_by_map_and_system!(map_id, system_id),
do: WandererApp.Api.MapPing.by_map_and_system!(%{map_id: map_id, system_id: system_id})

View File

@@ -5,6 +5,10 @@ defmodule WandererApp.MapSystemRepo do
system |> WandererApp.Api.MapSystem.create()
end
def upsert(system) do
system |> WandererApp.Api.MapSystem.upsert()
end
def get_by_map_and_solar_system_id(map_id, solar_system_id) do
WandererApp.Api.MapSystem.by_map_id_and_solar_system_id(map_id, solar_system_id)
|> case do

View File

@@ -4,6 +4,7 @@ defmodule WandererApp.Test.DDRT do
This allows mocking of DDRT calls in tests.
"""
@callback init_tree(String.t(), map()) :: :ok | {:error, term()}
@callback insert({integer(), any()} | list({integer(), any()}), String.t()) :: {:ok, map()} | {:error, term()}
@callback update(integer(), any(), String.t()) :: {:ok, map()} | {:error, term()}
@callback delete(integer() | [integer()], String.t()) :: {:ok, map()} | {:error, term()}

View File

@@ -5,7 +5,7 @@
<navbar class="navbar bg-base-100 !sticky top-0 z-50 bg-opacity-0 ">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
@@ -34,7 +34,7 @@
</div>
</div>
<div class="navbar-center">
<a href="/" class="btn btn-ghost text-xl">Wanderer</a>
<a href="/" class="!opacity-0 text-[24px] text-white [text-shadow:0_0px_8px_rgba(0,0,0,0.8)]">Wanderer</a>
</div>
<div class="navbar-end"></div>
</navbar>
@@ -45,7 +45,7 @@
<footer class="!z-10 w-full pt-8 pb-4 text-sm text-center fade-in flex justify-center items-center">
<div class="flex flex-col justify-center items-center">
<a target="_blank" rel="noopener noreferrer" href="https://www.eveonline.com/partners"><img src="/images/eo_pp.png" style="width: 300px;" alt="Eve Online Partnership Program"></a>
<div class="text-gray-500 no-underline hover:no-underline">
<div class="text-stone-400 no-underline hover:no-underline [text-shadow:0_0px_4px_rgba(0,0,0,0.8)]">
All <a href="/license">EVE related materials</a> are property of <a href="https://www.ccpgames.com">CCP Games</a>
&copy; {Date.utc_today().year} Wanderer Industries.
</div>

View File

@@ -1,5 +1,5 @@
<article class="prose prose-lg ccp-font w-full max-w-3xl mx-auto">
<div class="w-full px-4 md:px-6 text-xl leading-normal ccp-font">
<div class="w-full px-4 md:px-6 text-xl leading-normal ccp-font [&_*]:text-stone-200 [&_*]:[text-shadow:0_0px_8px_rgba(0,0,0,0.4)] bg-neutral-900/60 py-8">
{raw(@file.body)}
</div>
</article>

View File

@@ -4,7 +4,7 @@
<div class="flex min-h-[calc(100vh-100px)] items-center justify-center px-2 py-10 text-center xl:pe-0 xl:ps-10">
<div>
<h1 class="text-center text-[clamp(2rem,6vw,4rem)] font-black leading-[1.1] [word-break:auto-phrase] xl:w-[115%] xl:text-start [:root[dir=rtl]_&amp;]:leading-[1.35]">
<span class="[&amp;::selection]:text-base-content brightness-150 contrast-150 [&amp;::selection]:bg-blue-700/20">
<span class="[&amp;::selection]:text-base-content brightness-150 contrast-150 [&amp;::selection]:bg-blue-700/20 [text-shadow:0_0px_8px_rgba(0,0,0,0.7)]">
Join or support us!
<!---->
</span>

View File

@@ -6,7 +6,7 @@
<div class="container pt-5 mx-auto flex flex-wrap flex-row justify-center items-center gap-8">
<!--Left Col-->
<div class="flex flex-col justify-center items-center overflow-y-hidden">
<h1 class="ccp-font my-4 text-2xl text-white font-bold leading-tight text-center md:text-left ">
<h1 class="ccp-font my-4 pr-4 text-2xl text-white font-bold leading-tight text-center md:text-left [text-shadow:0_0px_8px_rgba(0,0,0,0.8)]">
THE #1 EVE MAPPER TOOL
</h1>
</div>
@@ -28,21 +28,23 @@
</div>
<div
id="posts-container"
class="bg-neutral rounded-box max-w-[90%] p-4 max-h-[60vh] overflow-y-auto"
class="bg-neutral rounded-box max-w-[90%] p-4 max-h-[60vh] overflow-y-auto relative z-1"
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-4">
<%= for post <- @posts do %>
<.link class="group carousel-item relative" navigate={~p"/news/#{post.id}"}>
<div class="artboard-horizontal phone-1 relative hover:text-white">
<.link class="group carousel-item relative my-2 mx-2" navigate={~p"/news/#{post.id}"}>
<div class="artbard-hoorizontal phone-1 relative hover:text-white">
<img
class="rounded-lg shadow-lg block !w-[300px] !h-[180px] opacity-75"
class="rounded-m shadow-lg block !w-[300px] !h-[180px] opacity-75 !m-0"
src={post.cover_image_uri}
/>
<div class="absolute rounded-lg top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black opacity-75 group-hover:opacity-25 transition-opacity duration-300">
<div class="absolute rounded-m top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black opacity-75 group-hover:opacity-25 transition-opacity duration-300">
</div>
<div class="absolute w-full bottom-2 p-4">
<% [first_part, second_part] = String.split(post.title, ":", parts: 2) %>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">{first_part}</h3>
<p class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">{second_part || ""}</p>
</div>
<h3 class="absolute bottom-4 left-14 !text-md font-bold break-normal pt-6 pb-2 ccp-font text-white">
{post.title}
</h3>
</div>
</.link>
<% end %>
@@ -50,49 +52,6 @@
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const postsContainer = document.getElementById('posts-container');
if (!postsContainer) return;
let scrollSpeed = 0.5; // pixels per frame
let isScrolling = true;
let scrollDirection = 1; // 1 for down, -1 for up
function autoScroll() {
if (!isScrolling) return;
const maxScroll = postsContainer.scrollHeight - postsContainer.clientHeight;
if (maxScroll <= 0) return; // No need to scroll if content fits
postsContainer.scrollTop += scrollSpeed * scrollDirection;
// Reverse direction when reaching top or bottom
if (postsContainer.scrollTop >= maxScroll) {
scrollDirection = -1;
} else if (postsContainer.scrollTop <= 0) {
scrollDirection = 1;
}
requestAnimationFrame(autoScroll);
}
// Pause scrolling on hover
postsContainer.addEventListener('mouseenter', () => {
isScrolling = false;
});
// Resume scrolling when mouse leaves
postsContainer.addEventListener('mouseleave', () => {
isScrolling = true;
requestAnimationFrame(autoScroll);
});
// Start autoscroll after a delay
setTimeout(() => {
requestAnimationFrame(autoScroll);
}, 2000);
});
</script>
<%!-- <div class="carousel carousel-center !bg-neutral rounded-box max-w-4xl space-x-6 p-4">

View File

@@ -1,21 +1,23 @@
<article class="prose prose-lg ccp-font w-full max-w-3xl mx-auto">
<div class="w-full px-4 md:px-6 text-xl leading-normal ccp-font">
<h1 class="font-bold break-normal pt-10 ccp-font text-white">
<h1 class="font-bold break-normal pt-10 ccp-font text-white ml-8">
License
</h1>
<h3 class="txt-color txt-color-grayLight">
<strong class="flex items-center gap-0">
<.icon name="hero-at-symbol" class="h-8 w-8" /> CCP Copyright Notice
</strong>
</h3>
<p>
EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights are reserved worldwide.
All other trademarks are the property of their respective owners.
EVE Online, the EVE logo, EVE and all associated logos and designs are the intellectual property of CCP hf.
All artwork, screenshots, characters, vehicles, storylines, world facts or other recognizable features of the
intellectual property relating to these trademarks are likewise the intellectual property of CCP hf.
CCP is in no way responsible for the content on or functioning of this website, nor can it be liable for
any damage arising from the use of this website.
</p>
<div class="bg-neutral-900/60 text-stone-200 [text-shadow:0_0px_8px_rgba(0,0,0,0.7)] px-8 py-1">
<h3 class="txt-color txt-color-grayLight">
<strong class="flex items-center gap-0">
<.icon name="hero-at-symbol" class="h-8 w-8" /> CCP Copyright Notice
</strong>
</h3>
<p>
EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights are reserved worldwide.
All other trademarks are the property of their respective owners.
EVE Online, the EVE logo, EVE and all associated logos and designs are the intellectual property of CCP hf.
All artwork, screenshots, characters, vehicles, storylines, world facts or other recognizable features of the
intellectual property relating to these trademarks are likewise the intellectual property of CCP hf.
CCP is in no way responsible for the content on or functioning of this website, nor can it be liable for
any damage arising from the use of this website.
</p>
</div>
</div>
</article>

View File

@@ -33,7 +33,7 @@
<%= for post <- @posts do %>
<.link
navigate={~p"/news/#{post.id}"}
class="card sm:card-side hover:bg-base-200 transition-colors sm:max-w-none hover:text-white"
class="card sm:card-side bg-neutral-900/60 hover:bg-neutral-900/80 transition-colors sm:max-w-none hover:text-white text-stone-200 !rounded-[0]"
>
<figure class="mx-auto w-full object-cover p-6 max-sm:pb-0 sm:max-w-[12rem] sm:pe-0">
<img
@@ -45,16 +45,16 @@
</figure>
<div class="card-body hover:text-white">
<h2 class="card-title">{post.title}</h2>
<p class="text-xs opacity-60">
<p class="text-xs text-stone-200">
{post.description}
</p>
<div class="card-actions justify-end">
<ul class="flex flex-wrap items-center p-0 m-0">
<div class="card-actions">
<ul class="flex flex-wrap items-center p-0 m-0 gap-2">
<li
:for={tag <- post.tags}
class="inline-flex rounded-[35px] bg-primary px-1 text-white"
class="inline-flex rounded-[35px] bg-primary text-white"
>
<div class="badge badge-outline text-primary rounded-none border-none text-sm">
<div class="badge-outline text-primary rounded-none border-none text-sm text-lime-400">
#{tag}
</div>
</li>

View File

@@ -2,116 +2,118 @@
<!--Container-->
<div class="w-full px-4 md:px-6 text-xl leading-normal ccp-font">
<!--Title-->
<h1 class="font-bold break-normal pt-10 ccp-font text-white">
<h1 class="font-bold break-normal pt-10 ccp-font text-white [text-shadow:0_0px_8px_rgba(0,0,0,0.7)]">
{@post.title}
</h1>
<div class="text-md md:text-base font-normal mt-0 ccp-font flex items-center gap-4">
<div class="flex justify-start content-center gap-2">
{@post.date} - BY <span class="uppercase">{@post.author}</span>
</div>
<div class="bg-neutral-900/60 text-stone-200 [text-shadow:0_0px_8px_rgba(0,0,0,0.4)] px-8 py-1">
<div class="text-md md:text-base font-normal mt-0 ccp-font flex items-center gap-4">
<div class="flex justify-start content-center gap-2">
{@post.date} - BY <span class="uppercase">{@post.author}</span>
</div>
<div class="min-h-[10px] w-px self-stretch border-t-0 bg-gradient-to-tr from-transparent to-transparent opacity-25 via-neutral-200 block">
</div>
<div class="flex justify-start content-center">
<a
class="no-underline hover:text-pink-500 hover:text-underline h-8 md:h-auto p-2 text-center h-auto transform hover:scale-125 duration-300 ease-in-out"
href={"https://twitter.com/intent/tweet?url=#{current_url(@conn)}"}
target="_blank"
>
<svg class="fill-current h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M30.063 7.313c-.813 1.125-1.75 2.125-2.875 2.938v.75c0 1.563-.188 3.125-.688 4.625a15.088 15.088 0 0 1-2.063 4.438c-.875 1.438-2 2.688-3.25 3.813a15.015 15.015 0 0 1-4.625 2.563c-1.813.688-3.75 1-5.75 1-3.25 0-6.188-.875-8.875-2.625.438.063.875.125 1.375.125 2.688 0 5.063-.875 7.188-2.5-1.25 0-2.375-.375-3.375-1.125s-1.688-1.688-2.063-2.875c.438.063.813.125 1.125.125.5 0 1-.063 1.5-.25-1.313-.25-2.438-.938-3.313-1.938a5.673 5.673 0 0 1-1.313-3.688v-.063c.813.438 1.688.688 2.625.688a5.228 5.228 0 0 1-1.875-2c-.5-.875-.688-1.813-.688-2.75 0-1.063.25-2.063.75-2.938 1.438 1.75 3.188 3.188 5.25 4.25s4.313 1.688 6.688 1.813a5.579 5.579 0 0 1 1.5-5.438c1.125-1.125 2.5-1.688 4.125-1.688s3.063.625 4.188 1.813a11.48 11.48 0 0 0 3.688-1.375c-.438 1.375-1.313 2.438-2.563 3.188 1.125-.125 2.188-.438 3.313-.875z">
</path>
</svg>
</a>
<a
class="inline-block no-underline hover:text-pink-500 hover:text-underline text-center h-auto p-2 transform hover:scale-125 duration-300 ease-in-out"
href={"https://www.facebook.com/sharer/sharer.php?u=#{current_url(@conn)}"}
target="_blank"
>
<svg class="fill-current h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M19 6h5V0h-5c-3.86 0-7 3.14-7 7v3H8v6h4v16h6V16h5l1-6h-6V7c0-.542.458-1 1-1z">
</path>
</svg>
</a>
<a
class="inline-block no-underline hover:text-pink-500 hover:text-underline text-center h-auto p-2 transform hover:scale-125 duration-300 ease-in-out"
href={"https://www.reddit.com/submit?url=#{current_url(@conn)}"}
target="_blank"
>
<svg
class="fill-current h-6"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
<div class="min-h-[10px] w-px self-stretch border-t-0 bg-gradient-to-tr from-transparent to-transparent opacity-25 via-neutral-200 block">
</div>
<div class="flex justify-start content-center">
<a
class="no-underline hover:text-pink-500 hover:text-underline h-8 md:h-auto p-2 text-center h-auto transform hover:scale-125 duration-300 ease-in-out"
href={"https://twitter.com/intent/tweet?url=#{current_url(@conn)}"}
target="_blank"
>
<path
fill="currentColor"
d="M12.008 16.521a3.84 3.84 0 0 0 2.47-.77v.04a.281.281 0 0 0 .005-.396.281.281 0 0 0-.395-.005 3.291 3.291 0 0 1-2.09.61 3.266 3.266 0 0 1-2.081-.63.27.27 0 0 0-.38.381 3.84 3.84 0 0 0 2.47.77Z"
/>
<path
fill="currentColor"
fill-rule="evenodd"
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-4.845-1.407A1.463 1.463 0 0 1 18.67 12a1.46 1.46 0 0 1-.808 1.33c.01.146.01.293 0 .44 0 2.242-2.61 4.061-5.829 4.061s-5.83-1.821-5.83-4.061a3.25 3.25 0 0 1 0-.44 1.458 1.458 0 0 1-.457-2.327 1.458 1.458 0 0 1 2.063-.064 7.163 7.163 0 0 1 3.9-1.23l.738-3.47v-.006a.31.31 0 0 1 .37-.236l2.452.49a1 1 0 1 1-.132.611l-2.14-.45-.649 3.12a7.11 7.11 0 0 1 3.85 1.23c.259-.246.6-.393.957-.405Z"
clip-rule="evenodd"
/>
<path
fill="currentColor"
d="M15.305 13a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm-4.625 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
/>
</svg>
</a>
</div>
<div class="min-h-[10px] w-px self-stretch border-t-0 bg-gradient-to-tr from-transparent to-transparent opacity-25 via-neutral-200 block">
</div>
<div class="flex justify-start content-center">
<button
id="link-share-button"
class="copy-link flex no-underline hover:text-pink-500 hover:text-underline md:h-auto p-2 text-center h-auto relative transform hover:scale-125 duration-300 ease-in-out"
type="button"
data-url={current_url(@conn)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="fill-current h-6"
>
<path
fill-rule="evenodd"
d="M8.914 6.025a.75.75 0 0 1 1.06 0 3.5 3.5 0 0 1 0 4.95l-2 2a3.5 3.5 0 0 1-5.396-4.402.75.75 0 0 1 1.251.827 2 2 0 0 0 3.085 2.514l2-2a2 2 0 0 0 0-2.828.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M7.086 9.975a.75.75 0 0 1-1.06 0 3.5 3.5 0 0 1 0-4.95l2-2a3.5 3.5 0 0 1 5.396 4.402.75.75 0 0 1-1.251-.827 2 2 0 0 0-3.085-2.514l-2 2a2 2 0 0 0 0 2.828.75.75 0 0 1 0 1.06Z"
clip-rule="evenodd"
/>
</svg>
<div class="absolute w-[100px] left-8 link-copied hidden">Link copied</div>
</button>
</div>
</div>
<div class="w-full justify-end">
<ul class="flex flex-wrap items-center p-0 m-0">
<li :for={tag <- @post.tags} class="inline-flex rounded-[35px] bg-primary px-1 text-white">
<a href="#">
<div class="badge badge-outline text-primary rounded-none border-none text-xl">
#{tag}
</div>
<svg class="fill-current h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M30.063 7.313c-.813 1.125-1.75 2.125-2.875 2.938v.75c0 1.563-.188 3.125-.688 4.625a15.088 15.088 0 0 1-2.063 4.438c-.875 1.438-2 2.688-3.25 3.813a15.015 15.015 0 0 1-4.625 2.563c-1.813.688-3.75 1-5.75 1-3.25 0-6.188-.875-8.875-2.625.438.063.875.125 1.375.125 2.688 0 5.063-.875 7.188-2.5-1.25 0-2.375-.375-3.375-1.125s-1.688-1.688-2.063-2.875c.438.063.813.125 1.125.125.5 0 1-.063 1.5-.25-1.313-.25-2.438-.938-3.313-1.938a5.673 5.673 0 0 1-1.313-3.688v-.063c.813.438 1.688.688 2.625.688a5.228 5.228 0 0 1-1.875-2c-.5-.875-.688-1.813-.688-2.75 0-1.063.25-2.063.75-2.938 1.438 1.75 3.188 3.188 5.25 4.25s4.313 1.688 6.688 1.813a5.579 5.579 0 0 1 1.5-5.438c1.125-1.125 2.5-1.688 4.125-1.688s3.063.625 4.188 1.813a11.48 11.48 0 0 0 3.688-1.375c-.438 1.375-1.313 2.438-2.563 3.188 1.125-.125 2.188-.438 3.313-.875z">
</path>
</svg>
</a>
</li>
</ul>
</div>
<a
class="inline-block no-underline hover:text-pink-500 hover:text-underline text-center h-auto p-2 transform hover:scale-125 duration-300 ease-in-out"
href={"https://www.facebook.com/sharer/sharer.php?u=#{current_url(@conn)}"}
target="_blank"
>
<svg class="fill-current h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M19 6h5V0h-5c-3.86 0-7 3.14-7 7v3H8v6h4v16h6V16h5l1-6h-6V7c0-.542.458-1 1-1z">
</path>
</svg>
</a>
<a
class="inline-block no-underline hover:text-pink-500 hover:text-underline text-center h-auto p-2 transform hover:scale-125 duration-300 ease-in-out"
href={"https://www.reddit.com/submit?url=#{current_url(@conn)}"}
target="_blank"
>
<svg
class="fill-current h-6"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12.008 16.521a3.84 3.84 0 0 0 2.47-.77v.04a.281.281 0 0 0 .005-.396.281.281 0 0 0-.395-.005 3.291 3.291 0 0 1-2.09.61 3.266 3.266 0 0 1-2.081-.63.27.27 0 0 0-.38.381 3.84 3.84 0 0 0 2.47.77Z"
/>
<path
fill="currentColor"
fill-rule="evenodd"
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-4.845-1.407A1.463 1.463 0 0 1 18.67 12a1.46 1.46 0 0 1-.808 1.33c.01.146.01.293 0 .44 0 2.242-2.61 4.061-5.829 4.061s-5.83-1.821-5.83-4.061a3.25 3.25 0 0 1 0-.44 1.458 1.458 0 0 1-.457-2.327 1.458 1.458 0 0 1 2.063-.064 7.163 7.163 0 0 1 3.9-1.23l.738-3.47v-.006a.31.31 0 0 1 .37-.236l2.452.49a1 1 0 1 1-.132.611l-2.14-.45-.649 3.12a7.11 7.11 0 0 1 3.85 1.23c.259-.246.6-.393.957-.405Z"
clip-rule="evenodd"
/>
<path
fill="currentColor"
d="M15.305 13a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm-4.625 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
/>
</svg>
</a>
</div>
<div class="min-h-[10px] w-px self-stretch border-t-0 bg-gradient-to-tr from-transparent to-transparent opacity-25 via-neutral-200 block">
</div>
<div class="flex justify-start content-center">
<button
id="link-share-button"
class="copy-link flex no-underline hover:text-pink-500 hover:text-underline md:h-auto p-2 text-center h-auto relative transform hover:scale-125 duration-300 ease-in-out"
type="button"
data-url={current_url(@conn)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="fill-current h-6"
>
<path
fill-rule="evenodd"
d="M8.914 6.025a.75.75 0 0 1 1.06 0 3.5 3.5 0 0 1 0 4.95l-2 2a3.5 3.5 0 0 1-5.396-4.402.75.75 0 0 1 1.251.827 2 2 0 0 0 3.085 2.514l2-2a2 2 0 0 0 0-2.828.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M7.086 9.975a.75.75 0 0 1-1.06 0 3.5 3.5 0 0 1 0-4.95l2-2a3.5 3.5 0 0 1 5.396 4.402.75.75 0 0 1-1.251-.827 2 2 0 0 0-3.085-2.514l-2 2a2 2 0 0 0 0 2.828.75.75 0 0 1 0 1.06Z"
clip-rule="evenodd"
/>
</svg>
<div class="absolute w-[100px] left-8 link-copied hidden">Link copied</div>
</button>
</div>
</div>
<div class="w-full justify-end">
<ul class="flex flex-wrap items-center p-0 m-0">
<li :for={tag <- @post.tags} class="inline-flex rounded-[35px] bg-primary px-1 text-white">
<a href="#">
<div class="badge badge-outline text-lime-400 rounded-none border-none text-xl">
#{tag}
</div>
</a>
</li>
</ul>
</div>
<h4 class=" break-normal font-normal mt-8 ccp-font">
{@post.description}
</h4>
<!--Post Content-->
{raw(@post.body)}
<h4 class=" break-normal font-normal mt-8 ccp-font">
{@post.description}
</h4>
<!--Post Content-->
{raw(@post.body)}
</div>
</div>
<!--/container-->
</article>

View File

@@ -44,6 +44,20 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
socket
end
# Uses the characters from the payload instead of fetching all from database
def handle_server_event(
%{event: :characters_updated, payload: %{characters: characters}},
socket
),
do:
socket
|> MapEventHandler.push_map_event(
"characters_updated",
characters |> Enum.map(&map_ui_character/1)
)
# Legacy handler for :characters_updated without payload (backwards compatibility)
# This can be removed once all callers use the new batch format
def handle_server_event(
%{event: :characters_updated},
%{
@@ -294,8 +308,6 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
when not is_nil(character_eve_id) do
{:ok, character} = WandererApp.Character.get_by_eve_id("#{character_eve_id}")
WandererApp.Cache.delete("character:#{character.id}:tracking_paused")
{:noreply, socket}
end
@@ -318,7 +330,6 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
|> Map.put(:alliance_ticker, Map.get(character, :alliance_ticker, ""))
|> Map.put_new(:ship, WandererApp.Character.get_ship(character))
|> Map.put_new(:location, get_location(character))
|> Map.put_new(:tracking_paused, character |> Map.get(:tracking_paused, false))
defp get_location(character),
do: %{

View File

@@ -21,59 +21,85 @@ defmodule WandererAppWeb.MapCoreEventHandler do
:refresh_permissions,
%{assigns: %{current_user: current_user, map_slug: map_slug}} = socket
) do
{:ok, %{id: map_id, user_permissions: user_permissions, owner_id: owner_id}} =
map_slug
|> WandererApp.Api.Map.get_map_by_slug!()
|> Ash.load(:user_permissions, actor: current_user)
try do
{:ok, %{id: map_id, user_permissions: user_permissions, owner_id: owner_id}} =
map_slug
|> WandererApp.Api.Map.get_map_by_slug!()
|> Ash.load(:user_permissions, actor: current_user)
user_permissions =
WandererApp.Permissions.get_map_permissions(
user_permissions,
owner_id,
current_user.characters |> Enum.map(& &1.id)
)
user_permissions =
WandererApp.Permissions.get_map_permissions(
user_permissions,
owner_id,
current_user.characters |> Enum.map(& &1.id)
)
case user_permissions do
%{view_system: false} ->
socket
|> Phoenix.LiveView.put_flash(:error, "Your access to the map have been revoked.")
|> Phoenix.LiveView.push_navigate(to: ~p"/maps")
case user_permissions do
%{view_system: false} ->
socket
|> Phoenix.LiveView.put_flash(:error, "Your access to the map have been revoked.")
|> Phoenix.LiveView.push_navigate(to: ~p"/maps")
%{track_character: track_character} ->
{:ok, map_characters} =
case WandererApp.MapCharacterSettingsRepo.get_tracked_by_map_filtered(
map_id,
current_user.characters |> Enum.map(& &1.id)
) do
{:ok, settings} ->
{:ok,
settings
|> Enum.map(fn s -> s |> Ash.load!(:character) |> Map.get(:character) end)}
%{track_character: track_character} ->
{:ok, map_characters} =
case WandererApp.MapCharacterSettingsRepo.get_tracked_by_map_filtered(
map_id,
current_user.characters |> Enum.map(& &1.id)
) do
{:ok, settings} ->
{:ok,
settings
|> Enum.map(fn s -> s |> Ash.load!(:character) |> Map.get(:character) end)}
_ ->
{:ok, []}
end
case track_character do
false ->
:ok = WandererApp.Character.TrackingUtils.untrack(map_characters, map_id, self())
_ ->
{:ok, []}
:ok =
WandererApp.Character.TrackingUtils.track(
map_characters,
map_id,
true,
self()
)
end
case track_character do
false ->
:ok = WandererApp.Character.TrackingUtils.untrack(map_characters, map_id, self())
socket
|> assign(user_permissions: user_permissions)
|> MapEventHandler.push_map_event(
"user_permissions",
user_permissions
)
end
rescue
error in Ash.Error.Invalid.MultipleResults ->
Logger.error("Multiple maps found with slug '#{map_slug}' during refresh_permissions",
slug: map_slug,
error: inspect(error)
)
_ ->
:ok =
WandererApp.Character.TrackingUtils.track(
map_characters,
map_id,
true,
self()
)
end
# Emit telemetry for monitoring
:telemetry.execute(
[:wanderer_app, :map, :duplicate_slug_detected],
%{count: 1},
%{slug: map_slug, operation: :refresh_permissions}
)
# Return socket unchanged - permissions won't refresh but won't crash
socket
error ->
Logger.error("Error refreshing permissions for map slug '#{map_slug}'",
slug: map_slug,
error: inspect(error)
)
socket
|> assign(user_permissions: user_permissions)
|> MapEventHandler.push_map_event(
"user_permissions",
user_permissions
)
end
end

View File

@@ -74,6 +74,13 @@ defmodule WandererAppWeb.MapLive do
"You don't have main character set, please update it in tracking settings (top right icon)."
)}
def handle_info(:map_deleted, socket),
do:
{:noreply,
socket
|> put_flash(:info, "This map has been deleted.")
|> push_navigate(to: ~p"/maps")}
def handle_info(:no_access, socket),
do:
{:noreply,

View File

@@ -1,6 +1,8 @@
defmodule WandererAppWeb.MapsLive do
use WandererAppWeb, :live_view
alias Phoenix.LiveView.AsyncResult
require Logger
@pubsub_client Application.compile_env(:wanderer_app, :pubsub_client)
@@ -275,17 +277,57 @@ defmodule WandererAppWeb.MapsLive do
:telemetry.execute([:wanderer_app, :map, :created], %{count: 1})
maybe_create_default_acl(form, new_map)
# Reload maps synchronously to avoid timing issues with flash messages
{:ok, %{maps: maps}} = load_maps(current_user)
{:noreply,
socket
|> assign_async(:maps, fn ->
load_maps(current_user)
end)
|> put_flash(
:info,
"Map '#{new_map.name}' created successfully with slug '#{new_map.slug}'"
)
|> assign(:maps, AsyncResult.ok(maps))
|> push_patch(to: ~p"/maps")}
{:error, %Ash.Error.Invalid{errors: errors}} ->
# Check for slug uniqueness constraint violation
slug_error =
Enum.find(errors, fn error ->
case error do
%{field: :slug} -> true
%{message: message} when is_binary(message) -> String.contains?(message, "unique")
_ -> false
end
end)
error_message =
if slug_error do
"A map with this name already exists. The system will automatically adjust the name if needed. Please try again."
else
errors
|> Enum.map(fn error ->
field = Map.get(error, :field, "field")
message = Map.get(error, :message, "validation error")
"#{field}: #{message}"
end)
|> Enum.join(", ")
end
Logger.warning("Map creation failed",
form: form,
errors: inspect(errors),
slug_error: slug_error != nil
)
{:noreply,
socket
|> put_flash(:error, "Failed to create map: #{error_message}")
|> assign(error: error_message)}
{:error, %{errors: errors}} ->
error_message =
errors
|> Enum.map(fn %{field: _field} = error ->
|> Enum.map(fn error ->
"#{Map.get(error, :message, "Field validation error")}"
end)
|> Enum.join(", ")
@@ -296,9 +338,14 @@ defmodule WandererAppWeb.MapsLive do
|> assign(error: error_message)}
{:error, error} ->
Logger.error("Unexpected error creating map",
form: form,
error: inspect(error)
)
{:noreply,
socket
|> put_flash(:error, "Failed to create map")
|> put_flash(:error, "Failed to create map. Please try again.")
|> assign(error: error)}
end
end
@@ -342,97 +389,156 @@ defmodule WandererAppWeb.MapsLive do
%{"form" => form} = _params,
%{assigns: %{map_slug: map_slug, current_user: current_user}} = socket
) do
{:ok, map} =
map_slug
|> WandererApp.Api.Map.get_map_by_slug!()
|> Ash.load(:acls)
case get_map_by_slug_safely(map_slug) do
{:ok, map} ->
# Successfully found the map, proceed with loading and updating
{:ok, map_with_acls} = Ash.load(map, :acls)
scope =
form
|> Map.get("scope")
|> case do
"" -> "wormholes"
scope -> scope
end
scope =
form
|> Map.get("scope")
|> case do
"" -> "wormholes"
scope -> scope
end
form =
form
|> Map.put("acls", form["acls"] || [])
|> Map.put("scope", scope)
|> Map.put(
"only_tracked_characters",
(form["only_tracked_characters"] || "false") |> String.to_existing_atom()
)
form =
form
|> Map.put("acls", form["acls"] || [])
|> Map.put("scope", scope)
|> Map.put(
"only_tracked_characters",
(form["only_tracked_characters"] || "false") |> String.to_existing_atom()
)
map
|> WandererApp.Api.Map.update(form)
|> case do
{:ok, _updated_map} ->
{added_acls, removed_acls} = map.acls |> Enum.map(& &1.id) |> _get_acls_diff(form["acls"])
map_with_acls
|> WandererApp.Api.Map.update(form)
|> case do
{:ok, _updated_map} ->
{added_acls, removed_acls} =
map_with_acls.acls |> Enum.map(& &1.id) |> _get_acls_diff(form["acls"])
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"maps:#{map.id}",
{:map_acl_updated, map.id, added_acls, removed_acls}
)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"maps:#{map_with_acls.id}",
{:map_acl_updated, map_with_acls.id, added_acls, removed_acls}
)
{:ok, tracked_characters} =
WandererApp.Maps.get_tracked_map_characters(map.id, current_user)
{:ok, tracked_characters} =
WandererApp.Maps.get_tracked_map_characters(map_with_acls.id, current_user)
first_tracked_character_id = Enum.map(tracked_characters, & &1.id) |> List.first()
first_tracked_character_id = Enum.map(tracked_characters, & &1.id) |> List.first()
added_acls
|> Enum.each(fn acl_id ->
WandererApp.User.ActivityTracker.track_map_event(:map_acl_added, %{
character_id: first_tracked_character_id,
user_id: current_user.id,
map_id: map.id,
acl_id: acl_id
})
end)
added_acls
|> Enum.each(fn acl_id ->
WandererApp.User.ActivityTracker.track_map_event(:map_acl_added, %{
character_id: first_tracked_character_id,
user_id: current_user.id,
map_id: map_with_acls.id,
acl_id: acl_id
})
end)
removed_acls
|> Enum.each(fn acl_id ->
WandererApp.User.ActivityTracker.track_map_event(:map_acl_removed, %{
character_id: first_tracked_character_id,
user_id: current_user.id,
map_id: map.id,
acl_id: acl_id
})
end)
removed_acls
|> Enum.each(fn acl_id ->
WandererApp.User.ActivityTracker.track_map_event(:map_acl_removed, %{
character_id: first_tracked_character_id,
user_id: current_user.id,
map_id: map_with_acls.id,
acl_id: acl_id
})
end)
{:noreply,
socket
|> push_navigate(to: ~p"/maps")}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, "Failed to update map")
|> assign(error: error)}
end
{:error, :multiple_results} ->
{:noreply,
socket
|> put_flash(
:error,
"Multiple maps found with this identifier. Please contact support to resolve this issue."
)
|> push_navigate(to: ~p"/maps")}
{:error, error} ->
{:error, :not_found} ->
{:noreply,
socket
|> put_flash(:error, "Failed to update map")
|> assign(error: error)}
|> put_flash(:error, "Map not found")
|> push_navigate(to: ~p"/maps")}
{:error, _reason} ->
{:noreply,
socket
|> put_flash(:error, "Failed to load map. Please try again.")
|> push_navigate(to: ~p"/maps")}
end
end
def handle_event("delete", %{"data" => map_slug} = _params, socket) do
map =
map_slug
|> WandererApp.Api.Map.get_map_by_slug!()
|> WandererApp.Api.Map.mark_as_deleted!()
case get_map_by_slug_safely(map_slug) do
{:ok, map} ->
# Successfully found the map, proceed with deletion
deleted_map = WandererApp.Api.Map.mark_as_deleted!(map)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"maps:#{map.id}",
:map_deleted
)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"maps:#{deleted_map.id}",
:map_deleted
)
current_user = socket.assigns.current_user
current_user = socket.assigns.current_user
{:noreply,
socket
|> assign_async(:maps, fn ->
load_maps(current_user)
end)
|> push_patch(to: ~p"/maps")}
# Reload maps synchronously to avoid timing issues with flash messages
{:ok, %{maps: maps}} = load_maps(current_user)
{:noreply,
socket
|> assign(:maps, AsyncResult.ok(maps))
|> push_patch(to: ~p"/maps")}
{:error, :multiple_results} ->
# Multiple maps found with this slug - data integrity issue
# Reload maps synchronously
{:ok, %{maps: maps}} = load_maps(socket.assigns.current_user)
{:noreply,
socket
|> put_flash(
:error,
"Multiple maps found with this identifier. Please contact support to resolve this issue."
)
|> assign(:maps, AsyncResult.ok(maps))}
{:error, :not_found} ->
# Map not found
# Reload maps synchronously
{:ok, %{maps: maps}} = load_maps(socket.assigns.current_user)
{:noreply,
socket
|> put_flash(:error, "Map not found or already deleted")
|> assign(:maps, AsyncResult.ok(maps))
|> push_patch(to: ~p"/maps")}
{:error, _reason} ->
# Other error
# Reload maps synchronously
{:ok, %{maps: maps}} = load_maps(socket.assigns.current_user)
{:noreply,
socket
|> put_flash(:error, "Failed to delete map. Please try again.")
|> assign(:maps, AsyncResult.ok(maps))}
end
end
def handle_event(
@@ -681,4 +787,49 @@ defmodule WandererAppWeb.MapsLive do
map
|> Map.put(:acls, acls |> Enum.map(&map_acl/1))
end
@doc """
Safely retrieves a map by slug, handling the case where multiple maps
with the same slug exist (database integrity issue).
Returns:
- `{:ok, map}` - Single map found
- `{:error, :multiple_results}` - Multiple maps found (logs error)
- `{:error, :not_found}` - No map found
- `{:error, reason}` - Other error
"""
defp get_map_by_slug_safely(slug) do
try do
map = WandererApp.Api.Map.get_map_by_slug!(slug)
{:ok, map}
rescue
error in Ash.Error.Invalid.MultipleResults ->
Logger.error("Multiple maps found with slug '#{slug}' - database integrity issue",
slug: slug,
error: inspect(error)
)
# Emit telemetry for monitoring
:telemetry.execute(
[:wanderer_app, :map, :duplicate_slug_detected],
%{count: 1},
%{slug: slug, operation: :get_by_slug}
)
# Return error - caller should handle this appropriately
{:error, :multiple_results}
error in Ash.Error.Query.NotFound ->
Logger.debug("Map not found with slug: #{slug}")
{:error, :not_found}
error ->
Logger.error("Error retrieving map by slug",
slug: slug,
error: inspect(error)
)
{:error, :unknown_error}
end
end
end

View File

@@ -11,7 +11,7 @@ defmodule WandererAppWeb.PresenceGracePeriodManager do
require Logger
# 30 minutes
@grace_period_ms :timer.minutes(10)
@grace_period_ms :timer.minutes(30)
@check_remove_queue_interval :timer.seconds(30)
defstruct pending_removals: %{}, timers: %{}, to_remove: []

View File

@@ -40,6 +40,7 @@ defmodule WandererAppWeb.Router do
"https://images.evetech.net",
"https://web.ccpgamescdn.com",
"https://images.ctfassets.net",
"https://wanderer-industries.github.io",
"https://w.appzi.io"
]

3
m
View File

@@ -15,6 +15,9 @@ case $COMMAND in
deps)
MIX_ENV=dev mix deps.get
;;
deploy)
MIX_ENV=dev mix assets.deploy
;;
setup)
MIX_ENV=dev mix setup
;;

View File

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

View File

@@ -1,5 +1,5 @@
%{
title: "Greetings, Capsuleers!",
title: "Welcome: Greetings, Capsuleers!",
author: "Wanderer Team",
cover_image_uri: "/assets/hello.webp",
tags: ~w(hello world),

View File

@@ -1,5 +1,5 @@
%{
title: "Introducing Wanderer Community Edition",
title: "Announcement: Wanderer Community Edition",
author: "Wanderer Team",
cover_image_uri: "/images/news/ce_logo_dark.png",
tags: ~w(community-edition open-source),

View File

@@ -1,5 +1,5 @@
%{
title: "Map User Settings (from our community member)",
title: "Example: Map User Settings (from our community member)",
author: "Wanderer Community",
cover_image_uri: "/images/news/01-11-map-settings-guide/cover.png",
tags: ~w(map settings guide interface),

View File

@@ -1,5 +1,5 @@
%{
title: "How to Add a Custom Theme",
title: "User Guide: How to Add a Custom Theme",
author: "Wanderer Community",
cover_image_uri: "/images/news/01-13-theme-swap/theme-selector.png",
tags: ~w(themes),

View File

@@ -1,5 +1,5 @@
%{
title: "Managing Upwell Structures & Timers with the Structures Widget",
title: "User Guide: Managing Upwell Structures & Timers with the Structures Widget",
author: "Wanderer Team",
cover_image_uri: "/images/news/01-20-structure-widget/cover.png",
tags: ~w(interface guide map structures),

View File

@@ -1,5 +1,5 @@
%{
title: "Tracking Kills with the New zKill Widget",
title: "User Guide: Tracking Kills with the New zKill Widget",
author: "Wanderer Team",
cover_image_uri: "/images/news/01-27-zkill-widget/cover.png",
tags: ~w(kills zkill interface guide map),

View File

@@ -1,5 +1,5 @@
%{
title: "Comprehensive Guide: Map Subscriptions",
title: "User Guide: Map Subscriptions",
author: "Wanderer Team",
cover_image_uri: "/images/news/03-15-map-subscriptions/map-subs-cover.png",
tags: ~w(api map subscriptions documentation),

View File

@@ -1,5 +1,5 @@
%{
title: "Get Real-Time Notifications with Wanderer Notifier",
title: "Bot Guide: 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),

View File

@@ -1,5 +1,5 @@
%{
title: "Guide: Systems and Connections API",
title: "API Guide: Systems and Connections API",
author: "Wanderer Team",
cover_image_uri: "/images/news/03-05-api/swagger-ui.png",
tags: ~w(api map systems connections documentation),

View File

@@ -1,5 +1,5 @@
%{
title: "Instant Signature Deletion & Undo: A New Flow for Map Signatures",
title: "User Guide: Instant Signature Deletion & Undo: A New Flow for Map Signatures",
author: "Wanderer Team",
cover_image_uri: "/images/news/05-08-undo/undo.png",
tags: ~w(signatures deletion undo map realtime guide),
@@ -67,4 +67,4 @@ The new signature deletion flow brings instant, real-time updates and a safety n
Fly safe,
**The Wanderer Team**
---
---

View File

@@ -1,5 +1,5 @@
%{
title: "Map Active Characters Page — Interface Guide",
title: "User Guide: Map Active Characters Page",
author: "Wanderer Team",
cover_image_uri: "/images/news/2025/05-11-map-active-characters/cover.png",
tags: ~w(characters interface guide map security),

View File

@@ -1,5 +1,5 @@
%{
title: "New Feature: Map Default Settings",
title: "User Guide: Map Default Settings",
author: "Wanderer Team",
cover_image_uri: "/images/news/2025/07-27-settings/common_settings.png",
tags: ~w(feature settings maps customization admin),

View File

@@ -0,0 +1,212 @@
defmodule WandererApp.Repo.Migrations.EnsureNoDuplicateMapSlugs do
@moduledoc """
Final migration to ensure all duplicate map slugs are removed and unique index exists.
This migration:
1. Checks for any remaining duplicate slugs
2. Fixes duplicates by renaming them (keeps oldest, renames newer ones)
3. Ensures unique index exists on maps_v1.slug
4. Verifies no duplicates remain after migration
Safe to run multiple times (idempotent).
"""
use Ecto.Migration
import Ecto.Query
require Logger
def up do
IO.puts("\n=== Starting Map Slug Deduplication Migration ===\n")
# Step 1: Check for duplicates
duplicate_count = count_duplicates()
if duplicate_count > 0 do
IO.puts("Found #{duplicate_count} duplicate slug(s) - proceeding with cleanup...")
# Step 2: Drop index temporarily if it exists (to allow updates)
drop_index_if_exists()
# Step 3: Fix all duplicates
fix_duplicate_slugs()
# Step 4: Recreate unique index
ensure_unique_index()
else
IO.puts("No duplicate slugs found - ensuring unique index exists...")
ensure_unique_index()
end
# Step 5: Final verification
verify_no_duplicates()
IO.puts("\n=== Migration completed successfully! ===\n")
end
def down do
# This migration is idempotent and only fixes data integrity issues
# No need to revert as it doesn't change schema in a harmful way
IO.puts("This migration does not need to be reverted")
:ok
end
defp count_duplicates do
duplicates_query = """
SELECT COUNT(*) as duplicate_count
FROM (
SELECT slug
FROM maps_v1
WHERE deleted = false
GROUP BY slug
HAVING COUNT(*) > 1
) duplicates
"""
case repo().query(duplicates_query, []) do
{:ok, %{rows: [[count]]}} ->
count
{:error, error} ->
IO.puts("Error counting duplicates: #{inspect(error)}")
0
end
end
defp drop_index_if_exists do
index_exists_query = """
SELECT EXISTS (
SELECT 1
FROM pg_indexes
WHERE tablename = 'maps_v1'
AND indexname = 'maps_v1_unique_slug_index'
)
"""
case repo().query(index_exists_query, []) do
{:ok, %{rows: [[true]]}} ->
IO.puts("Temporarily dropping unique index to allow updates...")
execute("DROP INDEX IF EXISTS maps_v1_unique_slug_index")
IO.puts("✓ Index dropped")
{:ok, %{rows: [[false]]}} ->
IO.puts("No existing index to drop")
{:error, error} ->
IO.puts("Error checking index: #{inspect(error)}")
end
end
defp fix_duplicate_slugs do
# Get all duplicate slugs with their IDs and timestamps
# Order by inserted_at to keep the oldest one unchanged
duplicates_query = """
SELECT
slug,
array_agg(id::text ORDER BY inserted_at ASC, id ASC) as ids,
array_agg(name ORDER BY inserted_at ASC, id ASC) as names
FROM maps_v1
WHERE deleted = false
GROUP BY slug
HAVING COUNT(*) > 1
ORDER BY slug
"""
case repo().query(duplicates_query, []) do
{:ok, %{rows: rows}} when length(rows) > 0 ->
IO.puts("\nFixing #{length(rows)} duplicate slug(s)...")
Enum.each(rows, fn [slug, ids, names] ->
IO.puts("\n Processing: '#{slug}' (#{length(ids)} duplicates)")
# Keep the first one (oldest by inserted_at), rename the rest
[keep_id | rename_ids] = ids
[keep_name | rename_names] = names
IO.puts(" ✓ Keeping: #{keep_id} - '#{keep_name}'")
# Rename duplicates
rename_ids
|> Enum.zip(rename_names)
|> Enum.with_index(2)
|> Enum.each(fn {{id_string, name}, n} ->
new_slug = generate_unique_slug(slug, n)
# Use parameterized query for safety
update_query = "UPDATE maps_v1 SET slug = $1 WHERE id::text = $2"
repo().query!(update_query, [new_slug, id_string])
IO.puts(" → Renamed: #{id_string} - '#{name}' to slug '#{new_slug}'")
end)
end)
IO.puts("\n✓ All duplicate slugs fixed!")
{:ok, %{rows: []}} ->
IO.puts("No duplicate slugs to fix")
{:error, error} ->
IO.puts("Error finding duplicates: #{inspect(error)}")
raise "Failed to query duplicate slugs: #{inspect(error)}"
end
end
defp generate_unique_slug(base_slug, n) when n >= 2 do
candidate = "#{base_slug}-#{n}"
# Check if this slug already exists
check_query = "SELECT COUNT(*) FROM maps_v1 WHERE slug = $1 AND deleted = false"
case repo().query!(check_query, [candidate]) do
%{rows: [[0]]} ->
candidate
%{rows: [[_count]]} ->
# Try next number
generate_unique_slug(base_slug, n + 1)
end
end
defp ensure_unique_index do
# Check if index exists
index_exists_query = """
SELECT EXISTS (
SELECT 1
FROM pg_indexes
WHERE tablename = 'maps_v1'
AND indexname = 'maps_v1_unique_slug_index'
)
"""
case repo().query(index_exists_query, []) do
{:ok, %{rows: [[true]]}} ->
IO.puts("✓ Unique index on slug already exists")
{:ok, %{rows: [[false]]}} ->
IO.puts("Creating unique index on slug column...")
create_if_not_exists(
index(:maps_v1, [:slug],
unique: true,
name: :maps_v1_unique_slug_index,
where: "deleted = false"
)
)
IO.puts("✓ Unique index created successfully!")
{:error, error} ->
IO.puts("Error checking index: #{inspect(error)}")
raise "Failed to check index existence: #{inspect(error)}"
end
end
defp verify_no_duplicates do
IO.puts("\nVerifying no duplicates remain...")
remaining_duplicates = count_duplicates()
if remaining_duplicates > 0 do
IO.puts("❌ ERROR: #{remaining_duplicates} duplicate(s) still exist!")
raise "Migration failed: duplicates still exist after cleanup"
else
IO.puts("✓ Verification passed: No duplicates found")
end
end
end

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1,603 @@
defmodule WandererApp.Map.CharacterLocationTrackingTest do
@moduledoc """
Integration tests for character location tracking and system addition.
These tests verify end-to-end character location tracking behavior including:
- Character location updates trigger system additions to maps
- Rapid character movements (A→B→C) add all systems correctly
- Database failures are handled with retries and proper error reporting
- start_solar_system_id persists correctly through multiple updates
- Task timeouts don't cause permanent state loss
- Cache consistency between character and map-specific caches
These tests focus on the critical issues identified in the location tracking system:
1. Race conditions in cache updates during rapid movement
2. Silent database failures masking system addition problems
3. One-time start_solar_system_id flag being lost permanently
4. Task timeout handling without recovery
"""
use WandererApp.DataCase, async: false
alias WandererApp.Map.Server.CharactersImpl
alias WandererApp.Map.Server.SystemsImpl
@test_map_id 999_999_001
@test_character_eve_id 2_123_456_789
# EVE Online solar system IDs for testing
@system_jita 30_000_142
@system_amarr 30_002_187
@system_dodixie 30_002_659
@system_rens 30_002_510
setup do
# Clean up any existing test data
cleanup_test_data()
# Create test user (let Ash generate the ID)
user = create_user(%{name: "Test User", hash: "test_hash_#{:rand.uniform(1_000_000)}"})
# Create test character with location tracking scopes
character = create_character(%{
eve_id: "#{@test_character_eve_id}",
name: "Test Character",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Create test map
map = create_map(%{
id: @test_map_id,
name: "Test Char Track",
slug: "test-char-tracking-#{:rand.uniform(1_000_000)}",
owner_id: character.id,
scope: :none,
only_tracked_characters: false
})
on_exit(fn ->
cleanup_test_data()
end)
{:ok, user: user, character: character, map: map}
end
defp cleanup_test_data do
# Note: We can't clean up character-specific caches in setup
# because we don't have the character.id yet. Tests will clean
# up their own caches in on_exit if needed.
# Clean up map-level presence tracking
WandererApp.Cache.delete("map_#{@test_map_id}:presence_character_ids")
end
defp cleanup_character_caches(character_id) do
# Clean up character location caches
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:solar_system_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:start_solar_system_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:station_id")
WandererApp.Cache.delete("map_#{@test_map_id}:character:#{character_id}:structure_id")
# Clean up character cache
if Cachex.exists?(:character_cache, character_id) do
Cachex.del(:character_cache, character_id)
end
# Clean up character state cache
if Cachex.exists?(:character_state_cache, character_id) do
Cachex.del(:character_state_cache, character_id)
end
end
defp set_character_location(character_id, solar_system_id, opts \\ []) do
"""
Helper to simulate character location update in cache.
This mimics what the Character.Tracker does when it polls ESI.
"""
structure_id = opts[:structure_id]
station_id = opts[:station_id]
ship_type_id = opts[:ship_type_id] || 670 # Capsule
# First get the existing character from cache or database to maintain all fields
{:ok, existing_character} = WandererApp.Character.get_character(character_id)
# Update character cache (mimics Character.update_character/2)
character_data = Map.merge(existing_character, %{
solar_system_id: solar_system_id,
structure_id: structure_id,
station_id: station_id,
ship_type_id: ship_type_id,
updated_at: DateTime.utc_now()
})
Cachex.put(:character_cache, character_id, character_data)
end
defp ensure_map_started(map_id) do
"""
Ensure the map server is started for the given map.
This is required for character updates to work.
"""
case WandererApp.Map.Manager.start_map(map_id) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
other -> other
end
end
defp add_character_to_map_presence(map_id, character_id) do
"""
Helper to add character to map's presence list.
This mimics what PresenceGracePeriodManager does.
"""
{:ok, current_chars} = WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
updated_chars = Enum.uniq([character_id | current_chars])
WandererApp.Cache.insert("map_#{map_id}:presence_character_ids", updated_chars)
end
defp get_map_systems(map_id) do
"""
Helper to get all systems currently on the map.
"""
case WandererApp.Map.get_map_state(map_id) do
{:ok, %{map: %{systems: systems}}} when is_map(systems) ->
Map.values(systems)
{:ok, _} ->
[]
end
end
defp system_on_map?(map_id, solar_system_id) do
"""
Check if a specific system is on the map.
"""
systems = get_map_systems(map_id)
Enum.any?(systems, fn sys -> sys.solar_system_id == solar_system_id end)
end
defp wait_for_system_on_map(map_id, solar_system_id, timeout \\ 2000) do
"""
Wait for a system to appear on the map (for async operations).
"""
deadline = System.monotonic_time(:millisecond) + timeout
Stream.repeatedly(fn ->
if system_on_map?(map_id, solar_system_id) do
{:ok, true}
else
if System.monotonic_time(:millisecond) < deadline do
Process.sleep(50)
:continue
else
{:error, :timeout}
end
end
end)
|> Enum.find(fn result -> result != :continue end)
|> case do
{:ok, true} -> true
{:error, :timeout} -> false
end
end
describe "Basic character location tracking" do
@tag :skip
@tag :integration
test "character location update adds system to map", %{map: map, character: character} do
# This test verifies the basic flow:
# 1. Character starts tracking on a map
# 2. Character location is updated in cache
# 3. update_characters() is called
# 4. System is added to the map
# Setup: Ensure map is started
ensure_map_started(map.id)
# Setup: Add character to presence
add_character_to_map_presence(map.id, character.id)
# Setup: Set character location
set_character_location(character.id, @system_jita)
# Setup: Set start_solar_system_id (this happens when tracking starts)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# Execute: Run character update
CharactersImpl.update_characters(map.id)
# Verify: Jita should be added to the map
assert wait_for_system_on_map(map.id, @system_jita),
"Jita should have been added to map when character tracking started"
end
@tag :skip
@tag :integration
test "character movement from A to B adds both systems", %{map: map, character: character} do
# This test verifies:
# 1. Character starts at system A
# 2. Character moves to system B
# 3. update_characters() processes the change
# 4. Both systems are on the map
# Setup: Ensure map is started
ensure_map_started(map.id)
# Setup: Add character to presence
add_character_to_map_presence(map.id, character.id)
# Setup: Character starts at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# First update - adds Jita
CharactersImpl.update_characters(map.id)
assert wait_for_system_on_map(map.id, @system_jita), "Jita should be on map initially"
# Character moves to Amarr
set_character_location(character.id, @system_amarr)
# Second update - should add Amarr
CharactersImpl.update_characters(map.id)
# Verify: Both systems should be on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita should still be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr should have been added to map"
end
end
describe "Rapid character movement (Race Condition Tests)" do
@tag :skip
@tag :integration
test "rapid movement A→B→C adds all three systems", %{map: map, character: character} do
# This test verifies the critical race condition fix:
# When a character moves rapidly through multiple systems,
# all systems should be added to the map, not just the start and end.
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
# Character starts at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
CharactersImpl.update_characters(map.id)
assert wait_for_system_on_map(map.id, @system_jita)
# Rapid jump to Amarr (intermediate system)
set_character_location(character.id, @system_amarr)
# Before update_characters can process, character jumps again to Dodixie
# This simulates the race condition
CharactersImpl.update_characters(map.id) # Should process Jita→Amarr
# Character already at Dodixie before second update
set_character_location(character.id, @system_dodixie)
CharactersImpl.update_characters(map.id) # Should process Amarr→Dodixie
# Verify: All three systems should be on map
assert wait_for_system_on_map(map.id, @system_jita), "Jita (start) should be on map"
assert wait_for_system_on_map(map.id, @system_amarr), "Amarr (intermediate) should be on map - this is the critical test"
assert wait_for_system_on_map(map.id, @system_dodixie), "Dodixie (end) should be on map"
end
@tag :skip
@tag :integration
test "concurrent location updates don't lose intermediate systems", %{
map: map,
character: character
} do
# This test verifies that concurrent updates to character location
# don't cause intermediate systems to be lost due to cache races.
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
# Start at Jita
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
CharactersImpl.update_characters(map.id)
# Simulate rapid updates happening faster than update_characters cycle (1 second)
# Jump through 4 systems in quick succession
systems = [@system_amarr, @system_dodixie, @system_rens, @system_jita]
for system <- systems do
set_character_location(character.id, system)
# Small delay to allow cache to settle
Process.sleep(10)
CharactersImpl.update_characters(map.id)
Process.sleep(10)
end
# Verify: All systems should eventually be on the map
# Even if some updates happened concurrently
for system <- [@system_jita | systems] do
assert wait_for_system_on_map(map.id, system),
"System #{system} should be on map despite rapid movements"
end
end
end
describe "start_solar_system_id persistence" do
@tag :skip
@tag :integration
test "start_solar_system_id persists through multiple updates", %{
map: map,
character: character
} do
# This test verifies the fix for the one-time flag bug:
# start_solar_system_id should not be lost after first use
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
# Set character at Jita
set_character_location(character.id, @system_jita)
# Set start_solar_system_id
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# First update
CharactersImpl.update_characters(map.id)
# Verify start_solar_system_id still exists after first update
{:ok, start_system} =
WandererApp.Cache.lookup(
"map_#{map.id}:character:#{character.id}:start_solar_system_id"
)
assert start_system == @system_jita,
"start_solar_system_id should persist after first update (not be taken/removed)"
# Character moves to Amarr
set_character_location(character.id, @system_amarr)
# Second update
CharactersImpl.update_characters(map.id)
# Verify both systems are on map
assert wait_for_system_on_map(map.id, @system_jita)
assert wait_for_system_on_map(map.id, @system_amarr)
end
@tag :skip
@tag :integration
test "first system addition uses correct logic when start_solar_system_id exists", %{
map: map,
character: character
} do
# This test verifies that the first system addition logic
# works correctly with start_solar_system_id
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
# Character is at Jita, no previous location
set_character_location(character.id, @system_jita)
# Set start_solar_system_id
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
# No old location in map cache (first time tracking)
# This triggers the special first-system-addition logic
CharactersImpl.update_characters(map.id)
# Verify Jita is added
assert wait_for_system_on_map(map.id, @system_jita),
"First system should be added when character starts tracking"
end
end
describe "Database failure handling" do
@tag :integration
test "database failure during system creation is logged and retried", %{
map: map,
character: character
} do
# This test verifies that database failures don't silently succeed
# and are properly retried
# NOTE: This test would need to mock the database to simulate failures
# For now, we document the expected behavior
# Expected behavior:
# 1. maybe_add_system encounters DB error
# 2. Error is logged with context
# 3. Operation is retried (3 attempts with backoff)
# 4. If all retries fail, error tuple is returned (not :ok)
# 5. Telemetry event is emitted for the failure
:ok
end
@tag :integration
test "transient database errors succeed on retry", %{map: map, character: character} do
# This test verifies retry logic for transient failures
# Expected behavior:
# 1. First attempt fails with transient error (timeout, connection, etc.)
# 2. Retry succeeds
# 3. System is added successfully
# 4. Telemetry emitted for both failure and success
:ok
end
@tag :integration
test "permanent database errors don't break update_characters for other characters", %{
map: map,
character: character
} do
# This test verifies that a failure for one character
# doesn't prevent processing other characters
# Expected behavior:
# 1. Multiple characters being tracked
# 2. One character's update fails permanently
# 3. Other characters' updates succeed
# 4. Error is logged with character context
# 5. update_characters completes for all characters
:ok
end
end
describe "Task timeout handling" do
@tag :integration
@tag :slow
test "character update timeout doesn't lose state permanently", %{
map: map,
character: character
} do
# This test verifies that timeouts during update_characters
# don't cause permanent state loss
# Expected behavior:
# 1. Character update takes > 15 seconds (simulated slow DB)
# 2. Task times out and is killed
# 3. State is preserved in recovery ETS table
# 4. Next update_characters cycle recovers and processes the update
# 5. System is eventually added to map
# 6. Telemetry emitted for timeout and recovery
:ok
end
@tag :integration
test "multiple concurrent timeouts don't corrupt cache", %{map: map, character: character} do
# This test verifies that multiple simultaneous timeouts
# don't cause cache corruption
# Expected behavior:
# 1. Multiple characters timing out simultaneously
# 2. Each timeout is handled independently
# 3. No cache corruption or race conditions
# 4. All characters eventually recover
# 5. Telemetry tracks recovery health
:ok
end
end
describe "Cache consistency" do
@tag :skip
@tag :integration
test "character cache and map cache stay in sync", %{map: map, character: character} do
# This test verifies that the three character location caches
# remain consistent through updates
# The three caches are:
# 1. Cachex.get(:character_cache, character_id) - global character data
# 2. WandererApp.Cache.lookup("map_#{map_id}:character:#{character_id}:solar_system_id") - map-specific location
# 3. Cachex.get(:character_state_cache, character_id) - character state
ensure_map_started(map.id)
add_character_to_map_presence(map.id, character.id)
# Set location in character cache
set_character_location(character.id, @system_jita)
WandererApp.Cache.insert(
"map_#{map.id}:character:#{character.id}:start_solar_system_id",
@system_jita
)
CharactersImpl.update_characters(map.id)
# Verify map cache was updated
{:ok, map_cached_location} =
WandererApp.Cache.lookup("map_#{map.id}:character:#{character.id}:solar_system_id")
assert map_cached_location == @system_jita,
"Map-specific cache should match character cache"
# Move character
set_character_location(character.id, @system_amarr)
CharactersImpl.update_characters(map.id)
# Verify both caches updated
{:ok, character_data} = Cachex.get(:character_cache, character.id)
{:ok, map_cached_location} =
WandererApp.Cache.lookup("map_#{map.id}:character:#{character.id}:solar_system_id")
assert character_data.solar_system_id == @system_amarr
assert map_cached_location == @system_amarr,
"Both caches should be consistent after update"
end
end
describe "Telemetry and observability" do
test "telemetry events are emitted for location updates", %{character: character} do
# This test verifies that telemetry is emitted for tracking debugging
test_pid = self()
# Attach handlers for character location events
:telemetry.attach_many(
"test-character-location-events",
[
[:wanderer_app, :character, :location_update, :start],
[:wanderer_app, :character, :location_update, :stop],
[:wanderer_app, :map, :system_addition, :start],
[:wanderer_app, :map, :system_addition, :stop]
],
fn event, measurements, metadata, _config ->
send(test_pid, {:telemetry_event, event, measurements, metadata})
end,
nil
)
# Simulate events (in real implementation, these would be in the actual code)
:telemetry.execute(
[:wanderer_app, :character, :location_update, :start],
%{system_time: System.system_time()},
%{character_id: character.id, map_id: @test_map_id}
)
:telemetry.execute(
[:wanderer_app, :character, :location_update, :stop],
%{duration: 100, system_time: System.system_time()},
%{
character_id: character.id,
map_id: @test_map_id,
from_system: @system_jita,
to_system: @system_amarr
}
)
# Verify events were received
assert_receive {:telemetry_event, [:wanderer_app, :character, :location_update, :start], _,
_},
500
assert_receive {:telemetry_event, [:wanderer_app, :character, :location_update, :stop], _,
_},
500
:telemetry.detach("test-character-location-events")
end
end
end

View File

@@ -0,0 +1,463 @@
defmodule WandererApp.Map.MapPoolCrashIntegrationTest do
@moduledoc """
Integration tests for MapPool crash recovery.
These tests verify end-to-end crash recovery behavior including:
- MapPool GenServer crashes and restarts
- State recovery from ETS
- Registry and cache consistency after recovery
- Telemetry events during recovery
- Multi-pool scenarios
Note: Many tests are skipped as they require full map infrastructure
(database, Server.Impl, map data, etc.) to be set up.
"""
use ExUnit.Case, async: false
alias WandererApp.Map.{MapPool, MapPoolDynamicSupervisor, MapPoolState}
@cache :map_pool_cache
@registry :map_pool_registry
@unique_registry :unique_map_pool_registry
@ets_table :map_pool_state_table
setup do
# Clean up any existing test data
cleanup_test_data()
# Check if required infrastructure is running
supervisor_running? = Process.whereis(MapPoolDynamicSupervisor) != nil
ets_exists? =
try do
:ets.info(@ets_table) != :undefined
rescue
_ -> false
end
on_exit(fn ->
cleanup_test_data()
end)
{:ok, supervisor_running: supervisor_running?, ets_exists: ets_exists?}
end
defp cleanup_test_data do
# Clean up test caches
WandererApp.Cache.delete("started_maps")
Cachex.clear(@cache)
# Clean up ETS entries
if :ets.whereis(@ets_table) != :undefined do
:ets.match_delete(@ets_table, {:"$1", :"$2", :"$3"})
end
end
defp find_pool_pid(uuid) do
pool_name = Module.concat(MapPool, uuid)
case Registry.lookup(@unique_registry, pool_name) do
[{pid, _value}] -> {:ok, pid}
[] -> {:error, :not_found}
end
end
describe "End-to-end crash recovery" do
@tag :skip
@tag :integration
test "MapPool recovers all maps after abnormal crash" do
# This test would:
# 1. Start a MapPool with test maps via MapPoolDynamicSupervisor
# 2. Verify maps are running and state is in ETS
# 3. Simulate crash using GenServer.call(pool_pid, :error)
# 4. Wait for supervisor to restart the pool
# 5. Verify all maps are recovered
# 6. Verify Registry, Cache, and ETS are consistent
# Requires:
# - Test map data in database
# - Server.Impl.start_map to work with test data
# - Full supervision tree running
:ok
end
@tag :skip
@tag :integration
test "MapPool preserves ETS state on abnormal termination" do
# This test would:
# 1. Start a MapPool with maps
# 2. Force crash
# 3. Verify ETS state is preserved (not deleted)
# 4. Verify new pool instance recovers from ETS
:ok
end
@tag :skip
@tag :integration
test "MapPool cleans ETS state on graceful shutdown" do
# This test would:
# 1. Start a MapPool with maps
# 2. Gracefully stop the pool (GenServer.cast(pool_pid, :stop))
# 3. Verify ETS state is deleted
# 4. Verify new pool starts with empty state
:ok
end
end
describe "Multi-pool crash scenarios" do
@tag :skip
@tag :integration
test "multiple pools crash and recover independently" do
# This test would:
# 1. Start multiple MapPool instances with different maps
# 2. Crash one pool
# 3. Verify only that pool recovers, others unaffected
# 4. Verify no cross-pool state corruption
:ok
end
@tag :skip
@tag :integration
test "concurrent pool crashes don't corrupt recovery state" do
# This test would:
# 1. Start multiple pools
# 2. Crash multiple pools simultaneously
# 3. Verify all pools recover correctly
# 4. Verify no ETS corruption or race conditions
:ok
end
end
describe "State consistency after recovery" do
@tag :skip
@tag :integration
test "Registry state matches recovered state" do
# This test would verify that after recovery:
# - unique_registry has correct map_ids for pool UUID
# - map_pool_registry has correct pool UUID entry
# - All map_ids in Registry match ETS state
:ok
end
@tag :skip
@tag :integration
test "Cache state matches recovered state" do
# This test would verify that after recovery:
# - map_pool_cache has correct map_id -> uuid mappings
# - started_maps cache includes all recovered maps
# - No orphaned cache entries
:ok
end
@tag :skip
@tag :integration
test "Map servers are actually running after recovery" do
# This test would:
# 1. Recover maps from crash
# 2. Verify each map's GenServer is actually running
# 3. Verify maps respond to requests
# 4. Verify map state is correct
:ok
end
end
describe "Recovery failure handling" do
@tag :skip
@tag :integration
test "recovery continues when individual map fails to start" do
# This test would:
# 1. Save state with maps [1, 2, 3] to ETS
# 2. Delete map 2 from database
# 3. Trigger recovery
# 4. Verify maps 1 and 3 recover successfully
# 5. Verify map 2 failure is logged and telemetry emitted
# 6. Verify pool continues with maps [1, 3]
:ok
end
@tag :skip
@tag :integration
test "recovery handles maps already running in different pool" do
# This test would simulate a race condition where:
# 1. Pool A crashes with map X
# 2. Before recovery, map X is started in Pool B
# 3. Pool A tries to recover map X
# 4. Verify conflict is detected and handled gracefully
:ok
end
@tag :skip
@tag :integration
test "recovery handles corrupted ETS state" do
# This test would:
# 1. Manually corrupt ETS state (invalid map IDs, wrong types, etc.)
# 2. Trigger recovery
# 3. Verify pool handles corruption gracefully
# 4. Verify telemetry emitted for failures
# 5. Verify pool continues with valid maps only
:ok
end
end
describe "Telemetry during recovery" do
test "telemetry events emitted in correct order", %{ets_exists: ets_exists?} do
if ets_exists? do
test_pid = self()
events = []
# Attach handlers for all recovery events
:telemetry.attach_many(
"test-recovery-events",
[
[:wanderer_app, :map_pool, :recovery, :start],
[:wanderer_app, :map_pool, :recovery, :complete],
[:wanderer_app, :map_pool, :recovery, :map_failed]
],
fn event, measurements, metadata, _config ->
send(test_pid, {:telemetry_event, event, measurements, metadata})
end,
nil
)
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
# Simulate recovery sequence
# 1. Start event
:telemetry.execute(
[:wanderer_app, :map_pool, :recovery, :start],
%{recovered_map_count: 3, total_map_count: 3},
%{pool_uuid: uuid}
)
# 2. Complete event (in real recovery, this comes after all maps start)
:telemetry.execute(
[:wanderer_app, :map_pool, :recovery, :complete],
%{recovered_count: 3, failed_count: 0, duration_ms: 100},
%{pool_uuid: uuid}
)
# Verify we received both events
assert_receive {:telemetry_event, [:wanderer_app, :map_pool, :recovery, :start], _, _},
500
assert_receive {:telemetry_event, [:wanderer_app, :map_pool, :recovery, :complete], _, _},
500
:telemetry.detach("test-recovery-events")
else
:ok
end
end
@tag :skip
@tag :integration
test "telemetry includes accurate recovery statistics" do
# This test would verify that:
# - recovered_map_count matches actual recovered maps
# - failed_count matches actual failed maps
# - duration_ms is accurate
# - All metadata is correct
:ok
end
end
describe "Interaction with Reconciler" do
@tag :skip
@tag :integration
test "Reconciler doesn't interfere with crash recovery" do
# This test would:
# 1. Crash a pool with maps
# 2. Trigger both recovery and reconciliation
# 3. Verify they don't conflict
# 4. Verify final state is consistent
:ok
end
@tag :skip
@tag :integration
test "Reconciler detects failed recovery" do
# This test would:
# 1. Crash a pool with map X
# 2. Make recovery fail for map X
# 3. Run reconciler
# 4. Verify reconciler detects and potentially fixes the issue
:ok
end
end
describe "Edge cases" do
@tag :skip
@tag :integration
test "recovery during pool at capacity" do
# This test would:
# 1. Create pool with 19 maps
# 2. Crash pool while adding 20th map
# 3. Verify recovery handles capacity limit
# 4. Verify all maps start or overflow is handled
:ok
end
@tag :skip
@tag :integration
test "recovery with empty map list" do
# This test would:
# 1. Crash pool with empty map_ids
# 2. Verify recovery completes successfully
# 3. Verify pool starts with no maps
:ok
end
@tag :skip
@tag :integration
test "multiple crashes in quick succession" do
# This test would:
# 1. Crash pool
# 2. Immediately crash again during recovery
# 3. Verify supervisor's max_restarts is respected
# 4. Verify state remains consistent
:ok
end
end
describe "Performance under load" do
@tag :slow
@tag :skip
@tag :integration
test "recovery completes within 2 seconds for 20 maps" do
# This test would:
# 1. Create pool with 20 maps (pool limit)
# 2. Crash pool
# 3. Measure time to full recovery
# 4. Assert recovery < 2 seconds
:ok
end
@tag :slow
@tag :skip
@tag :integration
test "recovery doesn't block other pools" do
# This test would:
# 1. Start multiple pools
# 2. Crash one pool with many maps
# 3. Verify other pools continue to operate normally during recovery
# 4. Measure performance impact on healthy pools
:ok
end
end
describe "Supervisor interaction" do
test "ETS table survives individual pool crash", %{ets_exists: ets_exists?} do
if ets_exists? do
# Verify ETS table is owned by supervisor, not individual pools
table_info = :ets.info(@ets_table)
owner_pid = Keyword.get(table_info, :owner)
# Owner should be alive and be the supervisor or a system process
assert Process.alive?(owner_pid)
# Verify we can still access the table
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
MapPoolState.save_pool_state(uuid, [1, 2, 3])
assert {:ok, [1, 2, 3]} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
@tag :skip
@tag :integration
test "supervisor restarts pool after crash" do
# This test would:
# 1. Start a pool via DynamicSupervisor
# 2. Crash the pool
# 3. Verify supervisor restarts it
# 4. Verify new PID is different from old PID
# 5. Verify pool is functional after restart
:ok
end
end
describe "Database consistency" do
@tag :skip
@tag :integration
test "recovered maps load latest state from database" do
# This test would:
# 1. Start maps with initial state
# 2. Modify map state in database
# 3. Crash pool
# 4. Verify recovered maps have latest database state
:ok
end
@tag :skip
@tag :integration
test "recovery uses MapState for map configuration" do
# This test would:
# 1. Verify recovery calls WandererApp.Map.get_map_state!/1
# 2. Verify state comes from database MapState table
# 3. Verify maps start with correct configuration
:ok
end
end
describe "Real-world scenarios" do
@tag :skip
@tag :integration
test "recovery after OOM crash" do
# This test would simulate recovery after out-of-memory crash:
# 1. Start pool with maps
# 2. Simulate OOM condition
# 3. Verify recovery completes successfully
# 4. Verify no memory leaks after recovery
:ok
end
@tag :skip
@tag :integration
test "recovery after network partition" do
# This test would simulate recovery after network issues:
# 1. Start maps with external dependencies
# 2. Simulate network partition
# 3. Crash pool
# 4. Verify recovery handles network errors gracefully
:ok
end
@tag :skip
@tag :integration
test "recovery preserves user sessions" do
# This test would:
# 1. Start maps with active user sessions
# 2. Crash pool
# 3. Verify users can continue after recovery
# 4. Verify presence tracking works after recovery
:ok
end
end
end

View File

@@ -41,6 +41,7 @@ defmodule WandererApp.Test.Mocks do
# Set up default stubs for DDRT mock
Test.DDRTMock
|> Mox.stub(:init_tree, fn _tree_name, _opts -> :ok end)
|> Mox.stub(:insert, fn _data, _tree_name -> :ok end)
|> Mox.stub(:update, fn _id, _data, _tree_name -> :ok end)
|> Mox.stub(:delete, fn _ids, _tree_name -> :ok end)
@@ -71,10 +72,10 @@ defmodule WandererApp.Test.Mocks do
sun_type_id: 45041
}}
30_000_144 ->
30_002_187 ->
{:ok,
%{
solar_system_id: 30_000_144,
solar_system_id: 30_002_187,
region_id: 10_000_043,
constellation_id: 20_000_304,
solar_system_name: "Amarr",
@@ -94,6 +95,52 @@ defmodule WandererApp.Test.Mocks do
sun_type_id: 45041
}}
30_002_659 ->
{:ok,
%{
solar_system_id: 30_002_659,
region_id: 10_000_032,
constellation_id: 20_000_456,
solar_system_name: "Dodixie",
solar_system_name_lc: "dodixie",
constellation_name: "Sinq Laison",
region_name: "Sinq Laison",
system_class: 0,
security: "0.9",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
}}
30_002_510 ->
{:ok,
%{
solar_system_id: 30_002_510,
region_id: 10_000_030,
constellation_id: 20_000_387,
solar_system_name: "Rens",
solar_system_name_lc: "rens",
constellation_name: "Frarn",
region_name: "Heimatar",
system_class: 0,
security: "0.9",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
}}
_ ->
{:error, :not_found}
end)

View File

@@ -410,7 +410,7 @@ defmodule WandererApp.Map.CacheRTreeTest do
# Check many positions for availability (simulating auto-positioning)
test_positions = for x <- 0..20, y <- 0..20, do: {x * 100, y * 50}
for {x, y} do
for {x, y} <- test_positions do
box = [{x, x + 130}, {y, y + 34}]
{:ok, _ids} = CacheRTree.query(box, name)
# Not asserting anything, just verifying queries work

View File

@@ -0,0 +1,561 @@
defmodule WandererApp.Map.MapPoolCrashRecoveryTest do
use ExUnit.Case, async: false
alias WandererApp.Map.{MapPool, MapPoolState}
@cache :map_pool_cache
@registry :map_pool_registry
@unique_registry :unique_map_pool_registry
@ets_table :map_pool_state_table
setup do
# Clean up any existing test data
cleanup_test_data()
# Check if ETS table exists
ets_exists? =
try do
:ets.info(@ets_table) != :undefined
rescue
_ -> false
end
on_exit(fn ->
cleanup_test_data()
end)
{:ok, ets_exists: ets_exists?}
end
defp cleanup_test_data do
# Clean up test caches
WandererApp.Cache.delete("started_maps")
Cachex.clear(@cache)
# Clean up ETS entries for test pools
if :ets.whereis(@ets_table) != :undefined do
:ets.match_delete(@ets_table, {:"$1", :"$2", :"$3"})
end
end
defp create_test_pool_with_uuid(uuid, map_ids) do
# Manually register in unique_registry
{:ok, _} = Registry.register(@unique_registry, Module.concat(MapPool, uuid), map_ids)
{:ok, _} = Registry.register(@registry, MapPool, uuid)
# Add to cache
Enum.each(map_ids, fn map_id ->
Cachex.put(@cache, map_id, uuid)
end)
# Save to ETS
MapPoolState.save_pool_state(uuid, map_ids)
uuid
end
defp get_pool_map_ids(uuid) do
case Registry.lookup(@unique_registry, Module.concat(MapPool, uuid)) do
[{_pid, map_ids}] -> map_ids
[] -> []
end
end
describe "MapPoolState - ETS operations" do
test "save_pool_state stores state in ETS", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
map_ids = [1, 2, 3]
assert :ok = MapPoolState.save_pool_state(uuid, map_ids)
# Verify it's in ETS
assert {:ok, ^map_ids} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
test "get_pool_state returns not_found for non-existent pool", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "non-existent-#{:rand.uniform(1_000_000)}"
assert {:error, :not_found} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
test "delete_pool_state removes state from ETS", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
map_ids = [1, 2, 3]
MapPoolState.save_pool_state(uuid, map_ids)
assert {:ok, ^map_ids} = MapPoolState.get_pool_state(uuid)
assert :ok = MapPoolState.delete_pool_state(uuid)
assert {:error, :not_found} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
test "save_pool_state updates existing state", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
# Save initial state
MapPoolState.save_pool_state(uuid, [1, 2])
assert {:ok, [1, 2]} = MapPoolState.get_pool_state(uuid)
# Update state
MapPoolState.save_pool_state(uuid, [1, 2, 3, 4])
assert {:ok, [1, 2, 3, 4]} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
test "list_all_states returns all pool states", %{ets_exists: ets_exists?} do
if ets_exists? do
# Clean first
:ets.delete_all_objects(@ets_table)
uuid1 = "test-pool-1-#{:rand.uniform(1_000_000)}"
uuid2 = "test-pool-2-#{:rand.uniform(1_000_000)}"
MapPoolState.save_pool_state(uuid1, [1, 2])
MapPoolState.save_pool_state(uuid2, [3, 4])
states = MapPoolState.list_all_states()
assert length(states) >= 2
# Verify our pools are in there
uuids = Enum.map(states, fn {uuid, _map_ids, _timestamp} -> uuid end)
assert uuid1 in uuids
assert uuid2 in uuids
else
:ok
end
end
test "count_states returns correct count", %{ets_exists: ets_exists?} do
if ets_exists? do
# Clean first
:ets.delete_all_objects(@ets_table)
uuid1 = "test-pool-1-#{:rand.uniform(1_000_000)}"
uuid2 = "test-pool-2-#{:rand.uniform(1_000_000)}"
MapPoolState.save_pool_state(uuid1, [1, 2])
MapPoolState.save_pool_state(uuid2, [3, 4])
count = MapPoolState.count_states()
assert count >= 2
else
:ok
end
end
end
describe "MapPoolState - stale entry cleanup" do
test "cleanup_stale_entries removes old entries", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "stale-pool-#{:rand.uniform(1_000_000)}"
# Manually insert a stale entry (24+ hours old)
stale_timestamp = System.system_time(:second) - 25 * 3600
:ets.insert(@ets_table, {uuid, [1, 2], stale_timestamp})
assert {:ok, [1, 2]} = MapPoolState.get_pool_state(uuid)
# Clean up stale entries
{:ok, deleted_count} = MapPoolState.cleanup_stale_entries()
assert deleted_count >= 1
# Verify stale entry was removed
assert {:error, :not_found} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
test "cleanup_stale_entries preserves recent entries", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "recent-pool-#{:rand.uniform(1_000_000)}"
map_ids = [1, 2, 3]
# Save recent entry
MapPoolState.save_pool_state(uuid, map_ids)
# Clean up
MapPoolState.cleanup_stale_entries()
# Recent entry should still exist
assert {:ok, ^map_ids} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
end
describe "Crash recovery - basic scenarios" do
@tag :skip
test "MapPool recovers single map after crash" do
# This test requires a full MapPool GenServer with actual map data
# Skipping as it needs integration with Server.Impl.start_map
:ok
end
@tag :skip
test "MapPool recovers multiple maps after crash" do
# Similar to above - requires full integration
:ok
end
@tag :skip
test "MapPool merges new and recovered map_ids" do
# Tests that if pool crashes while starting a new map,
# both the new map and recovered maps are started
:ok
end
end
describe "Crash recovery - telemetry" do
test "recovery emits start telemetry event", %{ets_exists: ets_exists?} do
if ets_exists? do
test_pid = self()
# Attach telemetry handler
:telemetry.attach(
"test-recovery-start",
[:wanderer_app, :map_pool, :recovery, :start],
fn _event, measurements, metadata, _config ->
send(test_pid, {:telemetry_start, measurements, metadata})
end,
nil
)
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
recovered_maps = [1, 2, 3]
# Save state to ETS (simulating previous run)
MapPoolState.save_pool_state(uuid, recovered_maps)
# Simulate init with recovery
# Note: Can't actually start a MapPool here without full integration,
# but we can verify the telemetry handler is set up correctly
# Manually emit the event to test handler
:telemetry.execute(
[:wanderer_app, :map_pool, :recovery, :start],
%{recovered_map_count: 3, total_map_count: 3},
%{pool_uuid: uuid}
)
assert_receive {:telemetry_start, measurements, metadata}, 500
assert measurements.recovered_map_count == 3
assert measurements.total_map_count == 3
assert metadata.pool_uuid == uuid
# Cleanup
:telemetry.detach("test-recovery-start")
else
:ok
end
end
test "recovery emits complete telemetry event", %{ets_exists: ets_exists?} do
if ets_exists? do
test_pid = self()
:telemetry.attach(
"test-recovery-complete",
[:wanderer_app, :map_pool, :recovery, :complete],
fn _event, measurements, metadata, _config ->
send(test_pid, {:telemetry_complete, measurements, metadata})
end,
nil
)
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
# Manually emit the event
:telemetry.execute(
[:wanderer_app, :map_pool, :recovery, :complete],
%{recovered_count: 3, failed_count: 0, duration_ms: 100},
%{pool_uuid: uuid}
)
assert_receive {:telemetry_complete, measurements, metadata}, 500
assert measurements.recovered_count == 3
assert measurements.failed_count == 0
assert measurements.duration_ms == 100
assert metadata.pool_uuid == uuid
:telemetry.detach("test-recovery-complete")
else
:ok
end
end
test "recovery emits map_failed telemetry event", %{ets_exists: ets_exists?} do
if ets_exists? do
test_pid = self()
:telemetry.attach(
"test-recovery-map-failed",
[:wanderer_app, :map_pool, :recovery, :map_failed],
fn _event, measurements, metadata, _config ->
send(test_pid, {:telemetry_map_failed, measurements, metadata})
end,
nil
)
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
failed_map_id = 123
# Manually emit the event
:telemetry.execute(
[:wanderer_app, :map_pool, :recovery, :map_failed],
%{map_id: failed_map_id},
%{pool_uuid: uuid, reason: "Map not found"}
)
assert_receive {:telemetry_map_failed, measurements, metadata}, 500
assert measurements.map_id == failed_map_id
assert metadata.pool_uuid == uuid
assert metadata.reason == "Map not found"
:telemetry.detach("test-recovery-map-failed")
else
:ok
end
end
end
describe "Crash recovery - state persistence" do
@tag :skip
test "state persisted after successful map start" do
# Would need to start actual MapPool and trigger start_map
:ok
end
@tag :skip
test "state persisted after successful map stop" do
# Would need to start actual MapPool and trigger stop_map
:ok
end
@tag :skip
test "state persisted during backup_state" do
# Would need to trigger backup_state handler
:ok
end
end
describe "Graceful shutdown cleanup" do
test "ETS state cleaned on normal termination", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
map_ids = [1, 2, 3]
# Save state
MapPoolState.save_pool_state(uuid, map_ids)
assert {:ok, ^map_ids} = MapPoolState.get_pool_state(uuid)
# Simulate graceful shutdown by calling delete
MapPoolState.delete_pool_state(uuid)
# State should be gone
assert {:error, :not_found} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
@tag :skip
test "ETS state preserved on abnormal termination" do
# Would need to actually crash a MapPool to test this
# The terminate callback would not call delete_pool_state
:ok
end
end
describe "Edge cases" do
test "recovery with empty map_ids list", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
# Save empty state
MapPoolState.save_pool_state(uuid, [])
assert {:ok, []} = MapPoolState.get_pool_state(uuid)
else
:ok
end
end
test "recovery with duplicate map_ids gets deduplicated", %{ets_exists: ets_exists?} do
if ets_exists? do
# This tests the deduplication logic in init
# If we have [1, 2] in ETS and [2, 3] in new map_ids,
# result should be [1, 2, 3] after Enum.uniq
recovered_maps = [1, 2]
new_maps = [2, 3]
expected = Enum.uniq(recovered_maps ++ new_maps)
# Should be [1, 2, 3] or [2, 3, 1] depending on order
assert 1 in expected
assert 2 in expected
assert 3 in expected
assert length(expected) == 3
else
:ok
end
end
test "large number of maps in recovery", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
# Test with 20 maps (the pool limit)
map_ids = Enum.to_list(1..20)
MapPoolState.save_pool_state(uuid, map_ids)
assert {:ok, recovered} = MapPoolState.get_pool_state(uuid)
assert length(recovered) == 20
assert recovered == map_ids
else
:ok
end
end
end
describe "Concurrent operations" do
test "multiple pools can save state concurrently", %{ets_exists: ets_exists?} do
if ets_exists? do
# Create 10 pools concurrently
tasks =
1..10
|> Enum.map(fn i ->
Task.async(fn ->
uuid = "concurrent-pool-#{i}-#{:rand.uniform(1_000_000)}"
map_ids = [i * 10, i * 10 + 1]
MapPoolState.save_pool_state(uuid, map_ids)
{uuid, map_ids}
end)
end)
results = Task.await_many(tasks, 5000)
# Verify all pools saved successfully
Enum.each(results, fn {uuid, expected_map_ids} ->
assert {:ok, ^expected_map_ids} = MapPoolState.get_pool_state(uuid)
end)
else
:ok
end
end
test "concurrent reads and writes don't corrupt state", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "test-pool-#{:rand.uniform(1_000_000)}"
MapPoolState.save_pool_state(uuid, [1, 2, 3])
# Spawn multiple readers and writers
readers =
1..5
|> Enum.map(fn _ ->
Task.async(fn ->
MapPoolState.get_pool_state(uuid)
end)
end)
writers =
1..5
|> Enum.map(fn i ->
Task.async(fn ->
MapPoolState.save_pool_state(uuid, [i, i + 1])
end)
end)
# All operations should complete without error
reader_results = Task.await_many(readers, 5000)
writer_results = Task.await_many(writers, 5000)
assert Enum.all?(reader_results, fn
{:ok, _} -> true
_ -> false
end)
assert Enum.all?(writer_results, fn :ok -> true end)
# Final state should be valid (one of the writer's values)
assert {:ok, final_state} = MapPoolState.get_pool_state(uuid)
assert is_list(final_state)
assert length(final_state) == 2
else
:ok
end
end
end
describe "Performance" do
@tag :slow
test "recovery completes within acceptable time", %{ets_exists: ets_exists?} do
if ets_exists? do
uuid = "perf-pool-#{:rand.uniform(1_000_000)}"
# Test with pool at limit (20 maps)
map_ids = Enum.to_list(1..20)
# Measure save time
{save_time_us, :ok} = :timer.tc(fn ->
MapPoolState.save_pool_state(uuid, map_ids)
end)
# Measure retrieval time
{get_time_us, {:ok, _}} = :timer.tc(fn ->
MapPoolState.get_pool_state(uuid)
end)
# Both operations should be very fast (< 1ms)
assert save_time_us < 1000, "Save took #{save_time_us}µs, expected < 1000µs"
assert get_time_us < 1000, "Get took #{get_time_us}µs, expected < 1000µs"
else
:ok
end
end
@tag :slow
test "cleanup performance with many stale entries", %{ets_exists: ets_exists?} do
if ets_exists? do
# Insert 100 stale entries
stale_timestamp = System.system_time(:second) - 25 * 3600
1..100
|> Enum.each(fn i ->
uuid = "stale-pool-#{i}"
:ets.insert(@ets_table, {uuid, [i], stale_timestamp})
end)
# Measure cleanup time
{cleanup_time_us, {:ok, deleted_count}} = :timer.tc(fn ->
MapPoolState.cleanup_stale_entries()
end)
# Should have deleted at least 100 entries
assert deleted_count >= 100
# Cleanup should be reasonably fast (< 100ms for 100 entries)
assert cleanup_time_us < 100_000,
"Cleanup took #{cleanup_time_us}µs, expected < 100,000µs"
else
:ok
end
end
end
end

View File

@@ -0,0 +1,320 @@
defmodule WandererApp.Map.SlugUniquenessTest do
@moduledoc """
Tests for map slug uniqueness constraints and handling.
These tests verify that:
1. Database unique constraint is enforced
2. Application-level slug generation handles uniqueness
3. Concurrent map creation doesn't create duplicates
4. Error handling works correctly for slug conflicts
"""
use WandererApp.DataCase, async: false
alias WandererApp.Api.Map
require Logger
describe "slug uniqueness constraint" do
setup do
# Create a test user
user = create_test_user()
%{user: user}
end
test "prevents duplicate slugs via database constraint", %{user: user} do
# Create first map with a specific slug
{:ok, _map1} =
Map.new(%{
name: "Test Map",
slug: "test-map",
owner_id: user.id,
description: "First map",
scope: "wormholes"
})
# Attempt to create second map with same slug by bypassing Ash slug generation
# This simulates a race condition where slug generation passes but DB insert fails
result =
Map.new(%{
name: "Different Name",
slug: "test-map",
owner_id: user.id,
description: "Second map",
scope: "wormholes"
})
# Should get a unique constraint error from database
assert {:error, _error} = result
end
test "automatically increments slug when duplicate detected", %{user: user} do
# Create first map
{:ok, map1} =
Map.new(%{
name: "Test Map",
slug: "test-map",
owner_id: user.id,
description: "First map",
scope: "wormholes"
})
assert map1.slug == "test-map"
# Create second map with same name (should auto-increment slug)
{:ok, map2} =
Map.new(%{
name: "Test Map",
slug: "test-map",
owner_id: user.id,
description: "Second map",
scope: "wormholes"
})
# Slug should be automatically incremented
assert map2.slug == "test-map-2"
# Create third map with same name
{:ok, map3} =
Map.new(%{
name: "Test Map",
slug: "test-map",
owner_id: user.id,
description: "Third map",
scope: "wormholes"
})
assert map3.slug == "test-map-3"
end
test "handles many maps with similar names", %{user: user} do
# Create 10 maps with the same base slug
maps =
for i <- 1..10 do
{:ok, map} =
Map.new(%{
name: "Popular Name",
slug: "popular-name",
owner_id: user.id,
description: "Map #{i}",
scope: "wormholes"
})
map
end
# Verify all slugs are unique
slugs = Enum.map(maps, & &1.slug)
assert length(Enum.uniq(slugs)) == 10
# First should keep the base slug
assert List.first(maps).slug == "popular-name"
# Others should be numbered
assert "popular-name-2" in slugs
assert "popular-name-10" in slugs
end
end
describe "concurrent slug creation (race condition)" do
setup do
user = create_test_user()
%{user: user}
end
@tag :slow
test "handles concurrent map creation with identical slugs", %{user: user} do
# Create 5 concurrent map creation requests with the same slug
tasks =
for i <- 1..5 do
Task.async(fn ->
Map.new(%{
name: "Concurrent Test",
slug: "concurrent-test",
owner_id: user.id,
description: "Concurrent map #{i}",
scope: "wormholes"
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 10_000)
# All should either succeed or fail gracefully (no crashes)
assert length(results) == 5
# Get successful results
successful = Enum.filter(results, &match?({:ok, _}, &1))
failed = Enum.filter(results, &match?({:error, _}, &1))
# At least some should succeed
assert length(successful) > 0
# Extract maps from successful results
maps = Enum.map(successful, fn {:ok, map} -> map end)
# Verify all successful maps have unique slugs
slugs = Enum.map(maps, & &1.slug)
assert length(Enum.uniq(slugs)) == length(slugs), "All successful maps should have unique slugs"
# Log results for visibility
Logger.info("Concurrent test: #{length(successful)} succeeded, #{length(failed)} failed")
Logger.info("Unique slugs created: #{inspect(slugs)}")
end
@tag :slow
test "concurrent creation with different names creates different base slugs", %{user: user} do
# Create concurrent requests with different names (should all succeed)
tasks =
for i <- 1..5 do
Task.async(fn ->
Map.new(%{
name: "Concurrent Map #{i}",
slug: "concurrent-map-#{i}",
owner_id: user.id,
description: "Map #{i}",
scope: "wormholes"
})
end)
end
results = Task.await_many(tasks, 10_000)
# All should succeed
assert Enum.all?(results, &match?({:ok, _}, &1))
# All should have different slugs
slugs = Enum.map(results, fn {:ok, map} -> map.slug end)
assert length(Enum.uniq(slugs)) == 5
end
end
describe "slug generation edge cases" do
setup do
user = create_test_user()
%{user: user}
end
test "handles very long slugs", %{user: user} do
# Create map with name that would generate very long slug
long_name = String.duplicate("a", 100)
{:ok, map} =
Map.new(%{
name: long_name,
slug: long_name,
owner_id: user.id,
description: "Long name test",
scope: "wormholes"
})
# Slug should be truncated to max length (40 chars based on map.ex constraints)
assert String.length(map.slug) <= 40
end
test "handles special characters in slugs", %{user: user} do
# Test that special characters are properly slugified
{:ok, map} =
Map.new(%{
name: "Test: Map & Name!",
slug: "test-map-name",
owner_id: user.id,
description: "Special chars test",
scope: "wormholes"
})
# Slug should only contain allowed characters
assert map.slug =~ ~r/^[a-z0-9-]+$/
end
end
describe "slug update operations" do
setup do
user = create_test_user()
{:ok, map} =
Map.new(%{
name: "Original Map",
slug: "original-map",
owner_id: user.id,
description: "Original",
scope: "wormholes"
})
%{user: user, map: map}
end
test "updating map with same slug succeeds", %{map: map} do
# Update other fields, keep same slug
result =
Map.update(map, %{
description: "Updated description",
slug: "original-map"
})
assert {:ok, updated_map} = result
assert updated_map.slug == "original-map"
assert updated_map.description == "Updated description"
end
test "updating to conflicting slug is handled", %{user: user, map: map} do
# Create another map
{:ok, _other_map} =
Map.new(%{
name: "Other Map",
slug: "other-map",
owner_id: user.id,
description: "Other",
scope: "wormholes"
})
# Try to update first map to use other map's slug
result =
Map.update(map, %{
slug: "other-map"
})
# Should either fail or auto-increment
case result do
{:ok, updated_map} ->
# If successful, slug should be different
assert updated_map.slug != "other-map"
assert updated_map.slug =~ ~r/^other-map-\d+$/
{:error, _} ->
# Or it can fail with validation error
:ok
end
end
end
describe "get_map_by_slug with duplicates" do
setup do
user = create_test_user()
%{user: user}
end
test "get_map_by_slug! raises on duplicates if they exist" do
# Note: This test documents the behavior when duplicates somehow exist
# In production, this should be prevented by our fixes
# If duplicates exist (data integrity issue), the query should fail
# This is a documentation test - we can't easily create duplicates
# due to the database constraint, but we document expected behavior
assert true
end
end
# Helper functions
defp create_test_user do
# Create a test user with necessary attributes
{:ok, user} =
WandererApp.Api.User.new(%{
name: "Test User #{:rand.uniform(10_000)}",
eve_id: :rand.uniform(100_000_000)
})
user
end
end