Compare commits

...

29 Commits

Author SHA1 Message Date
Dmitry Popov
1226b6abf3 chore: added advent challenge 2025-12-18 19:04:43 +01:00
Dmitry Popov
7a1f5c0966 chore: [skip ci] 2025-12-17 19:32:37 +01:00
Dmitry Popov
7039ced11e fix(core): reduce chracters untrack grace period to 15 mins (after change/close/disconnect from map)
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 Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-12-15 12:46:02 +01:00
CI
42b5bb337f chore: [skip ci] 2025-12-15 11:35:24 +00:00
CI
1dbb24f6ec chore: release version v1.90.8 2025-12-15 11:35:24 +00:00
Dmitry Popov
c242f510e0 fix(core): skip systems or connections cleanup for not started maps 2025-12-15 12:34:55 +01:00
CI
c59d51636e chore: [skip ci] 2025-12-15 00:36:18 +00:00
CI
c5a8aa1b4d chore: release version v1.90.7 2025-12-15 00:36:18 +00:00
Dmitry Popov
cba050a9e7 fix(core): fixed scopes 2025-12-15 01:35:41 +01:00
CI
59fcbef3b1 chore: [skip ci] 2025-12-12 18:49:02 +00:00
CI
2f1eb6eeaa chore: release version v1.90.6 2025-12-12 18:49:02 +00:00
Dmitry Popov
71ae326cf7 fix(core): fixed map scopes 2025-12-12 19:48:26 +01:00
CI
07829caf0f chore: [skip ci] 2025-12-12 18:36:03 +00:00
CI
a5850b5a8d chore: release version v1.90.5 2025-12-12 18:36:03 +00:00
Dmitry Popov
9f6849209b fix(core): fixed map scopes 2025-12-12 19:35:26 +01:00
CI
7bd295cbad chore: [skip ci] 2025-12-12 17:07:55 +00:00
CI
078e5fc19e chore: release version v1.90.4 2025-12-12 17:07:55 +00:00
Dmitry Popov
3877e121c3 fix(core): fixed map scopes & signatures clean up behaviour 2025-12-12 18:07:18 +01:00
CI
dcb2a0cdb2 chore: [skip ci] 2025-12-11 00:17:06 +00:00
CI
f5294eee84 chore: release version v1.90.3 2025-12-11 00:17:06 +00:00
Dmitry Popov
a5c87b6fa4 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-11 01:16:27 +01:00
Dmitry Popov
eae275f515 fix(core): added pagination for long ACL lists 2025-12-11 01:16:24 +01:00
CI
68ae6706dd chore: [skip ci] 2025-12-10 23:56:28 +00:00
CI
a34b30af15 chore: release version v1.90.2 2025-12-10 23:56:28 +00:00
Dmitry Popov
38b49266ed fix(core): added system position updates to SSE
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 Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-12-11 00:55:52 +01:00
CI
049884bb4c chore: [skip ci] 2025-12-08 21:56:20 +00:00
CI
3c75b2b59f chore: release version v1.90.1 2025-12-08 21:56:20 +00:00
Dmitry Popov
4ad5d191a3 fix(core): fixed connections and signatures remove issues, added comprehensive audit log for auto removed connections and signatures 2025-12-08 22:55:39 +01:00
CI
2499c24cc1 chore: [skip ci] 2025-12-06 10:58:14 +00:00
29 changed files with 1347 additions and 132 deletions

View File

@@ -2,6 +2,78 @@
<!-- changelog -->
## [v1.90.8](https://github.com/wanderer-industries/wanderer/compare/v1.90.7...v1.90.8) (2025-12-15)
### Bug Fixes:
* core: skip systems or connections cleanup for not started maps
## [v1.90.7](https://github.com/wanderer-industries/wanderer/compare/v1.90.6...v1.90.7) (2025-12-15)
### Bug Fixes:
* core: fixed scopes
## [v1.90.6](https://github.com/wanderer-industries/wanderer/compare/v1.90.5...v1.90.6) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes
## [v1.90.5](https://github.com/wanderer-industries/wanderer/compare/v1.90.4...v1.90.5) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes
## [v1.90.4](https://github.com/wanderer-industries/wanderer/compare/v1.90.3...v1.90.4) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes & signatures clean up behaviour
## [v1.90.3](https://github.com/wanderer-industries/wanderer/compare/v1.90.2...v1.90.3) (2025-12-11)
### Bug Fixes:
* core: added pagination for long ACL lists
## [v1.90.2](https://github.com/wanderer-industries/wanderer/compare/v1.90.1...v1.90.2) (2025-12-10)
### Bug Fixes:
* core: added system position updates to SSE
## [v1.90.1](https://github.com/wanderer-industries/wanderer/compare/v1.90.0...v1.90.1) (2025-12-08)
### Bug Fixes:
* core: fixed connections and signatures remove issues, added comprehensive audit log for auto removed connections and signatures
## [v1.90.0](https://github.com/wanderer-industries/wanderer/compare/v1.89.6...v1.90.0) (2025-12-06)

View File

@@ -57,7 +57,7 @@ export default {
};
refreshZone.addEventListener('click', handleUpdate);
refreshZone.addEventListener('mouseover', handleUpdate);
// refreshZone.addEventListener('mouseover', handleUpdate);
this.updated();
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -98,8 +98,8 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
"id" => payload["system_id"] || payload[:system_id],
"attributes" => %{
"locked" => payload["locked"] || payload[:locked],
"x" => payload["x"] || payload[:x],
"y" => payload["y"] || payload[:y],
"position_x" => payload["position_x"] || payload[:position_x],
"position_y" => payload["position_y"] || payload[:position_y],
"updated_at" => event.timestamp
},
"relationships" => %{

View File

@@ -12,6 +12,7 @@ defmodule WandererApp.Map do
defstruct map_id: nil,
name: nil,
scope: :none,
scopes: nil,
owner_id: nil,
characters: [],
systems: Map.new(),
@@ -22,11 +23,15 @@ defmodule WandererApp.Map do
characters_limit: nil,
hubs_limit: nil
def new(%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs}) do
def new(%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs} = input) do
# Extract the new scopes array field if present (nil if not set)
scopes = Map.get(input, :scopes)
map =
struct!(__MODULE__,
map_id: map_id,
scope: scope,
scopes: scopes,
owner_id: owner_id,
name: name,
acls: acls,

View File

@@ -822,15 +822,25 @@ defmodule WandererApp.Map.Server.CharactersImpl do
) do
scopes = get_effective_scopes(map)
ConnectionsImpl.is_connection_valid(
scopes,
old_location.solar_system_id,
location.solar_system_id
is_valid =
ConnectionsImpl.is_connection_valid(
scopes,
old_location.solar_system_id,
location.solar_system_id
)
Logger.debug(
"[CharacterTracking] update_location: map=#{map_id}, " <>
"from=#{old_location.solar_system_id}, to=#{location.solar_system_id}, " <>
"scopes=#{inspect(scopes)}, map.scopes=#{inspect(map[:scopes])}, " <>
"map.scope=#{inspect(map[:scope])}, is_valid=#{is_valid}"
)
|> case do
case is_valid do
true ->
# Add new location system
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts) do
# Connection is valid (at least one system matches scopes)
# Add systems that match the map's scopes - individual system filtering by maybe_add_system
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts, scopes) do
:ok ->
:ok
@@ -840,8 +850,8 @@ defmodule WandererApp.Map.Server.CharactersImpl do
)
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
# Add old location system (in case it wasn't on map) - only if it matches scopes
case SystemsImpl.maybe_add_system(map_id, old_location, location, map_opts, scopes) do
:ok ->
:ok
@@ -881,13 +891,16 @@ defmodule WandererApp.Map.Server.CharactersImpl do
defp is_character_in_space?(%{station_id: station_id, structure_id: structure_id} = _location),
do: is_nil(structure_id) && is_nil(station_id)
# Get effective scopes from map, with fallback to legacy scope
defp get_effective_scopes(%{scopes: scopes}) when is_list(scopes) and scopes != [], do: scopes
@doc """
Get effective scopes from map, with fallback to legacy scope.
Returns the scopes array that should be used for filtering.
"""
def get_effective_scopes(%{scopes: scopes}) when is_list(scopes) and scopes != [], do: scopes
defp get_effective_scopes(%{scope: scope}) when is_atom(scope),
def get_effective_scopes(%{scope: scope}) when is_atom(scope),
do: legacy_scope_to_scopes(scope)
defp get_effective_scopes(_), do: [:wormholes]
def get_effective_scopes(_), do: [:wormholes]
# Legacy scope to new scopes array conversion
defp legacy_scope_to_scopes(:wormholes), do: [:wormholes]

View File

@@ -296,6 +296,30 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
do: update_connection(map_id, :update_custom_info, [:custom_info], connection_update)
def cleanup_connections(map_id) do
# Defensive check: Skip cleanup if cache appears invalid
# This prevents incorrectly deleting connections when cache is empty due to
# race conditions during map restart or cache corruption
case WandererApp.Map.get_map(map_id) do
{:error, :not_found} ->
Logger.warning(
"[cleanup_connections] Skipping map #{map_id} - cache miss detected, " <>
"map data not found in cache"
)
:telemetry.execute(
[:wanderer_app, :map, :cleanup_connections, :cache_miss],
%{system_time: System.system_time()},
%{map_id: map_id}
)
:ok
{:ok, _map} ->
do_cleanup_connections(map_id)
end
end
defp do_cleanup_connections(map_id) do
connection_auto_expire_hours = get_connection_auto_expire_hours()
connection_auto_eol_hours = get_connection_auto_eol_hours()
connection_eol_expire_timeout_hours = get_eol_expire_timeout_mins() / 60
@@ -349,6 +373,27 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
solar_system_source: solar_system_source_id,
solar_system_target: solar_system_target_id
} ->
# Emit telemetry for connection auto-deletion
:telemetry.execute(
[:wanderer_app, :map, :connection_cleanup, :delete],
%{system_time: System.system_time()},
%{
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id,
reason: :auto_cleanup
}
)
# Log auto-deletion for audit trail (no user/character context for auto-cleanup)
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
})
delete_connection(map_id, %{
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
@@ -723,15 +768,33 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
when is_list(scopes) and from_solar_system_id != to_solar_system_id do
with {:ok, from_system_static_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_system_static_info} <- get_system_static_info(to_solar_system_id) do
# Connection is valid if:
# 1. Neither system is prohibited
# 2. At least one system matches one of the selected scopes
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id)) and
(system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes))
# First check: neither system is prohibited
not_prohibited =
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id))
if not_prohibited do
from_is_wormhole = from_system_static_info.system_class in @wh_space
to_is_wormhole = to_system_static_info.system_class in @wh_space
wormholes_enabled = :wormholes in scopes
# Wormhole border behavior: if wormholes scope is enabled AND at least one
# system is a wormhole, allow the connection (adds border k-space systems)
# Otherwise: BOTH systems must match the configured scopes
if wormholes_enabled and (from_is_wormhole or to_is_wormhole) do
# At least one system matches (wormhole matches :wormholes, or other matches its scope)
system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes)
else
# Non-wormhole movement: both systems must match scopes
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
end
else
false
end
else
_ -> false
end

View File

@@ -256,6 +256,37 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
defp maybe_update_connection_mass_status(_map_id, _old_sig, _updated_sig), do: :ok
@doc """
Wrapper for updating a signature's linked_system_id with logging.
Logs all unlink operations (when linked_system_id is set to nil) with context
to help diagnose unexpected unlinking issues.
"""
def update_signature_linked_system(signature, %{linked_system_id: nil} = params) do
# Log all unlink operations with context for debugging
Logger.warning(
"[Signature Unlink] eve_id=#{signature.eve_id} " <>
"system_id=#{signature.system_id} " <>
"old_linked_system_id=#{signature.linked_system_id} " <>
"stacktrace=#{format_stacktrace()}"
)
MapSystemSignature.update_linked_system(signature, params)
end
def update_signature_linked_system(signature, params) do
MapSystemSignature.update_linked_system(signature, params)
end
defp format_stacktrace do
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
stacktrace
|> Enum.take(10)
|> Enum.map_join(" <- ", fn {mod, fun, arity, _} ->
"#{inspect(mod)}.#{fun}/#{arity}"
end)
end
defp track_activity(event, map_id, solar_system_id, user_id, character_id, signatures) do
ActivityTracker.track_map_event(event, %{
map_id: map_id,

View File

@@ -4,6 +4,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
require Logger
alias WandererApp.Map.Server.Impl
alias WandererApp.Map.Server.SignaturesImpl
@ddrt Application.compile_env(:wanderer_app, :ddrt)
@system_auto_expire_minutes 15
@@ -146,6 +147,30 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
def cleanup_systems(map_id) do
# Defensive check: Skip cleanup if cache appears invalid
# This prevents incorrectly deleting systems when cache is empty due to
# race conditions during map restart or cache corruption
case WandererApp.Map.get_map(map_id) do
{:error, :not_found} ->
Logger.warning(
"[cleanup_systems] Skipping map #{map_id} - cache miss detected, " <>
"map data not found in cache"
)
:telemetry.execute(
[:wanderer_app, :map, :cleanup_systems, :cache_miss],
%{system_time: System.system_time()},
%{map_id: map_id}
)
:ok
{:ok, _map} ->
do_cleanup_systems(map_id)
end
end
defp do_cleanup_systems(map_id) do
expired_systems =
map_id
|> WandererApp.Map.list_systems!()
@@ -383,6 +408,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|> Enum.each(fn connection ->
try do
Logger.debug(fn -> "Removing connection from map: #{inspect(connection)}" end)
# Audit logging for cascade deletion (no user/character context)
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_source_id: connection.solar_system_source,
solar_system_target_id: connection.solar_system_target
})
:ok = WandererApp.MapConnectionRepo.destroy(map_id, connection)
:ok = WandererApp.Map.remove_connection(map_id, connection)
Impl.broadcast!(map_id, :remove_connections, [connection])
@@ -393,55 +428,77 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end)
end
# When destination systems are deleted, unlink signatures instead of destroying them.
# This preserves the user's scan data while removing the stale link.
defp cleanup_linked_signatures(map_id, removed_solar_system_ids) do
removed_solar_system_ids
|> Enum.map(fn solar_system_id ->
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
end)
|> List.flatten()
|> Enum.uniq_by(& &1.system_id)
|> Enum.each(fn s ->
try do
{:ok, %{eve_id: eve_id, system: system}} = s |> Ash.load([:system])
# Group signatures by their source system for efficient broadcasting
signatures_by_system =
removed_solar_system_ids
|> Enum.flat_map(fn solar_system_id ->
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
end)
|> Enum.uniq_by(& &1.id)
|> Enum.group_by(fn sig -> sig.system_id end)
# Use Ash.destroy (not destroy!) to handle already-deleted signatures gracefully
case Ash.destroy(s) do
:ok ->
# Handle case where parent system was already deleted
case system do
nil ->
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} destroyed (parent system already deleted)"
end)
signatures_by_system
|> Enum.each(fn {_system_id, signatures} ->
signatures
|> Enum.each(fn sig ->
try do
{:ok, %{eve_id: eve_id, system: system}} = sig |> Ash.load([:system])
%{solar_system_id: solar_system_id} ->
Logger.debug(fn ->
"[cleanup_linked_signatures] for system #{solar_system_id}: #{inspect(eve_id)}"
end)
# Clear the linked_system_id instead of destroying the signature
# Use the wrapper to log unlink operations
case SignaturesImpl.update_signature_linked_system(sig, %{
linked_system_id: nil
}) do
{:ok, _updated_sig} ->
case system do
nil ->
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} unlinked (parent system already deleted)"
end)
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
end
%{solar_system_id: solar_system_id} ->
Logger.debug(fn ->
"[cleanup_linked_signatures] unlinked signature #{eve_id} in system #{solar_system_id}"
end)
{:error, %Ash.Error.Invalid{errors: errors}} ->
# Check if this is a StaleRecord error (signature already deleted)
if Enum.any?(errors, &match?(%Ash.Error.Changes.StaleRecord{}, &1)) do
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} already deleted (StaleRecord)"
end)
else
# Audit logging for cascade unlink (no user/character context)
WandererApp.User.ActivityTracker.track_map_event(:signatures_unlinked, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_id: solar_system_id,
signatures: [eve_id]
})
end
{:error, error} ->
Logger.error(
"[cleanup_linked_signatures] Failed to destroy signature #{eve_id}: #{inspect(errors)}"
"[cleanup_linked_signatures] Failed to unlink signature #{sig.eve_id}: #{inspect(error)}"
)
end
{:error, error} ->
Logger.error(
"[cleanup_linked_signatures] Failed to destroy signature: #{inspect(error)}"
)
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
end)
# Broadcast once per source system after all its signatures are processed
case List.first(signatures) do
%{system: %{solar_system_id: solar_system_id}} ->
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
_ ->
# Try to get the system info if not preloaded
case List.first(signatures) |> Ash.load([:system]) do
{:ok, %{system: %{solar_system_id: solar_system_id}}} ->
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
_ ->
:ok
end
end
end)
end
@@ -466,8 +523,47 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end)
end
def maybe_add_system(map_id, location, old_location, map_opts)
def maybe_add_system(map_id, location, old_location, map_opts, scopes \\ nil)
def maybe_add_system(map_id, location, old_location, map_opts, scopes)
when not is_nil(location) do
alias WandererApp.Map.Server.ConnectionsImpl
# Check if the system matches the map's configured scopes before adding
should_add =
case scopes do
nil ->
true
[] ->
true
scopes when is_list(scopes) ->
# First check: does the location directly match scopes?
if ConnectionsImpl.can_add_location(scopes, location.solar_system_id) do
true
else
# Second check: wormhole border behavior
# If :wormholes scope is enabled AND old_location is a wormhole,
# allow this system to be added as a border system (so you can see
# where your wormhole exits to)
:wormholes in scopes and
not is_nil(old_location) and
ConnectionsImpl.can_add_location([:wormholes], old_location.solar_system_id)
end
end
if should_add do
do_add_system_from_location(map_id, location, old_location, map_opts)
else
# System filtered out by scope settings - this is expected behavior
:ok
end
end
def maybe_add_system(_map_id, _location, _old_location, _map_opts, _scopes), do: :ok
defp do_add_system_from_location(map_id, location, old_location, map_opts) do
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :start],
%{system_time: System.system_time()},
@@ -546,12 +642,14 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|> case do
{:ok, solar_system_info} ->
# Use upsert instead of create - handles race conditions gracefully
# visible: true ensures previously-deleted systems become visible again
WandererApp.MapSystemRepo.upsert(%{
map_id: map_id,
solar_system_id: location.solar_system_id,
name: solar_system_info.solar_system_name,
position_x: position.x,
position_y: position.y
position_y: position.y,
visible: true
})
|> case do
{:ok, system} ->
@@ -673,8 +771,6 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
end
def maybe_add_system(_map_id, _location, _old_location, _map_opts), do: :ok
defp do_add_system(
map_id,
%{
@@ -1008,12 +1104,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
# This may fail if the relay is not available (e.g., in tests), which is fine
WandererApp.ExternalEvents.broadcast(map_id, :system_metadata_changed, %{
system_id: updated_system.id,
solar_system_id: updated_system.solar_system_id,
name: updated_system.name,
temporary_name: updated_system.temporary_name,
labels: updated_system.labels,
description: updated_system.description,
status: updated_system.status
status: updated_system.status,
locked: updated_system.locked,
position_x: updated_system.position_x,
position_y: updated_system.position_y
})
:ok

View File

@@ -23,6 +23,7 @@ defmodule WandererAppWeb.Layouts do
attr :app_version, :string
attr :enabled, :boolean
attr :latest_post, :any, default: nil
def new_version_banner(assigns) do
~H"""
@@ -36,27 +37,89 @@ defmodule WandererAppWeb.Layouts do
>
<div class="hs-overlay-backdrop transition duration absolute left-0 top-0 w-full h-full bg-gray-900 bg-opacity-50 dark:bg-opacity-80 dark:bg-neutral-900">
</div>
<div class="absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] flex items-center">
<div class="rounded w-9 h-9 w-[80px] h-[66px] flex items-center justify-center relative z-20">
<.icon name="hero-chevron-double-right" class="w-9 h-9 mr-[-40px]" />
</div>
<div id="refresh-area">
<.live_component module={WandererAppWeb.MapRefresh} id="map-refresh" />
<div class="absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-6">
<div class="flex items-center">
<div class="rounded w-9 h-9 w-[80px] h-[66px] flex items-center justify-center relative z-20">
<.icon name="hero-chevron-double-right" class="w-9 h-9 mr-[-40px]" />
</div>
<div id="refresh-area">
<.live_component module={WandererAppWeb.MapRefresh} id="map-refresh" />
</div>
<div class="rounded h-[66px] flex items-center justify-center relative z-20">
<div class=" flex items-center w-[200px] h-full">
<.icon name="hero-chevron-double-left" class="w-9 h-9 mr-[20px]" />
<div class=" flex flex-col items-center justify-center h-full">
<div class="text-white text-nowrap text-sm [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
Update Required
</div>
<a
href="/changelog"
target="_blank"
class="text-sm link-secondary [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]"
>
What's new?
</a>
</div>
</div>
</div>
</div>
<div class="rounded h-[66px] flex items-center justify-center relative z-20">
<div class=" flex items-center w-[200px] h-full">
<.icon name="hero-chevron-double-left" class="w-9 h-9 mr-[20px]" />
<div class=" flex flex-col items-center justify-center h-full">
<div class="text-white text-nowrap text-sm [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
Update Required
<div class="flex flex-row gap-6 z-20">
<div
:if={@latest_post}
class="bg-gray-800/80 rounded-lg overflow-hidden min-w-[300px] backdrop-blur-sm border border-gray-700"
>
<a href={"/news/#{@latest_post.id}"} target="_blank" class="block group/post">
<div class="relative">
<img
src={@latest_post.cover_image_uri}
class="w-[300px] h-[140px] object-cover opacity-80 group-hover/post:opacity-100 transition-opacity"
/>
<div class="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black/70">
</div>
<div class="absolute top-2 left-2 flex items-center gap-1 bg-orange-500/90 px-2 py-0.5 rounded text-xs font-semibold">
<.icon name="hero-newspaper-solid" class="w-3 h-3" />
<span>Latest News</span>
</div>
<div class="absolute bottom-0 left-0 w-full p-3">
<% [first_part | rest] = String.split(@latest_post.title, ":", parts: 2) %>
<h3 class="text-white text-sm font-bold ccp-font [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
{first_part}
</h3>
<p
:if={rest != []}
class="text-gray-200 text-xs ccp-font text-ellipsis overflow-hidden whitespace-nowrap [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]"
>
{List.first(rest)}
</p>
</div>
</div>
</a>
</div>
<div class="bg-gray-800/80 rounded-lg p-4 min-w-[280px] backdrop-blur-sm border border-gray-700">
<div class="flex items-center gap-2 mb-3">
<.icon name="hero-gift-solid" class="w-5 h-5 text-green-400" />
<span class="text-white font-semibold text-sm [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
Support Wanderer
</span>
</div>
<div class="text-gray-300 text-xs mb-3 [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
Buy PLEX from the official EVE Online store using our promocode to support the development.
</div>
<div class="flex items-center gap-3">
<code class="bg-gray-900/60 px-2 py-1 rounded text-green-400 text-sm font-mono border border-gray-600">
WANDERER
</code>
<a
href="/changelog"
href="https://www.eveonline.com/plex"
target="_blank"
class="text-sm link-secondary [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-green-400 hover:text-green-300 transition-colors"
>
What's new?
<span>Get PLEX</span>
<.icon name="hero-arrow-top-right-on-square-mini" class="w-4 h-4" />
</a>
</div>
</div>

View File

@@ -31,7 +31,11 @@
</div>
</aside>
<.new_version_banner app_version={@app_version} enabled={@map_subscriptions_enabled?} />
<.new_version_banner
app_version={@app_version}
enabled={true}
latest_post={@latest_post}
/>
</div>
<.live_component module={WandererAppWeb.Alerts} id="notifications" view_flash={@flash} />

View File

@@ -455,7 +455,9 @@ defmodule WandererAppWeb.AccessListMemberAPIController do
end)
{:error, error} ->
Logger.warning("Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}")
Logger.warning(
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
)
end
end

View File

@@ -42,12 +42,18 @@ defmodule WandererAppWeb.AuthController do
WandererApp.Character.update_character(character.id, character_update)
# Update corporation/alliance data from ESI to ensure access control is current
update_character_affiliation(character)
{:ok, character}
{:error, _error} ->
{:ok, character} = WandererApp.Api.Character.create(character_data)
:telemetry.execute([:wanderer_app, :user, :character, :registered], %{count: 1})
# Fetch initial corporation/alliance data for new characters
update_character_affiliation(character)
{:ok, character}
end
@@ -113,4 +119,102 @@ defmodule WandererAppWeb.AuthController do
end
def maybe_update_character_user_id(_character, _user_id), do: :ok
# Updates character's corporation and alliance data from ESI.
# This ensures ACL-based access control uses current corporation membership,
# even for characters not actively being tracked on any map.
defp update_character_affiliation(%{id: character_id, eve_id: eve_id} = character) do
# Run async to not block the SSO callback
Task.start(fn ->
character_eve_id = eve_id |> String.to_integer()
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
{:ok, [affiliation_info]} when is_map(affiliation_info) ->
new_corporation_id = Map.get(affiliation_info, "corporation_id")
new_alliance_id = Map.get(affiliation_info, "alliance_id")
# Check if corporation changed
corporation_changed = character.corporation_id != new_corporation_id
alliance_changed = character.alliance_id != new_alliance_id
if corporation_changed or alliance_changed do
update_affiliation_data(character_id, character, new_corporation_id, new_alliance_id)
end
{:error, error} ->
Logger.warning(
"[AuthController] Failed to fetch affiliation for character #{character_id}: #{inspect(error)}"
)
_ ->
:ok
end
end)
end
defp update_character_affiliation(_character), do: :ok
defp update_affiliation_data(character_id, character, corporation_id, alliance_id) do
# Fetch corporation info
corporation_update =
case WandererApp.Esi.get_corporation_info(corporation_id) do
{:ok, %{"name" => corp_name, "ticker" => corp_ticker}} ->
%{
corporation_id: corporation_id,
corporation_name: corp_name,
corporation_ticker: corp_ticker
}
_ ->
%{corporation_id: corporation_id}
end
# Fetch alliance info if present
alliance_update =
case alliance_id do
nil ->
%{alliance_id: nil, alliance_name: nil, alliance_ticker: nil}
_ ->
case WandererApp.Esi.get_alliance_info(alliance_id) do
{:ok, %{"name" => alliance_name, "ticker" => alliance_ticker}} ->
%{
alliance_id: alliance_id,
alliance_name: alliance_name,
alliance_ticker: alliance_ticker
}
_ ->
%{alliance_id: alliance_id}
end
end
full_update = Map.merge(corporation_update, alliance_update)
# Update database
case character.corporation_id != corporation_id do
true ->
{:ok, _} = WandererApp.Api.Character.update_corporation(character, corporation_update)
false ->
:ok
end
case character.alliance_id != alliance_id do
true ->
{:ok, _} = WandererApp.Api.Character.update_alliance(character, alliance_update)
false ->
:ok
end
# Update cache
WandererApp.Character.update_character(character_id, full_update)
Logger.info(
"[AuthController] Updated affiliation for character #{character_id}: " <>
"corp #{character.corporation_id} -> #{corporation_id}, " <>
"alliance #{character.alliance_id} -> #{alliance_id}"
)
end
end

View File

@@ -4,6 +4,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
require Logger
alias WandererAppWeb.UserActivityItem
alias WandererAppWeb.Helpers.APIUtils
# -----------------------------------------------------------------
@@ -153,10 +154,10 @@ defmodule WandererAppWeb.MapAuditAPIController do
result
|> Map.put(:character, WandererAppWeb.MapEventHandler.map_ui_character_stat(character))
|> Map.put(:event_name, WandererAppWeb.UserActivity.get_event_name(event_type))
|> Map.put(:event_name, WandererAppWeb.UserActivityItem.get_event_name(event_type))
|> Map.put(
:event_data,
WandererAppWeb.UserActivity.get_event_data(
WandererAppWeb.UserActivityItem.get_event_data(
event_type,
Jason.decode!(event_data) |> Map.drop(["character_id"])
)

View File

@@ -5,6 +5,8 @@ defmodule WandererAppWeb.AccessListsLive do
require Ash.Query
require Logger
@members_per_page 50
@impl true
def mount(_params, %{"user_id" => user_id} = _session, socket) when not is_nil(user_id) do
{:ok, characters} = WandererApp.Api.Character.active_by_user(%{user_id: user_id})
@@ -25,7 +27,9 @@ defmodule WandererAppWeb.AccessListsLive do
user_id: user_id,
access_lists: access_lists |> Enum.map(fn acl -> map_ui_acl(acl, nil) end),
characters: characters,
members: []
members: [],
members_page: 1,
members_per_page: @members_per_page
)}
end
@@ -39,7 +43,9 @@ defmodule WandererAppWeb.AccessListsLive do
allow_acl_creation: false,
access_lists: [],
characters: [],
members: []
members: [],
members_page: 1,
members_per_page: @members_per_page
)}
end
@@ -93,10 +99,8 @@ defmodule WandererAppWeb.AccessListsLive do
|> assign(:page_title, "Access Lists - Members")
|> assign(:selected_acl_id, acl_id)
|> assign(:access_list, access_list)
|> assign(
:members,
members
)
|> assign(:members, members)
|> assign(:members_page, 1)
else
_ ->
socket
@@ -324,6 +328,20 @@ defmodule WandererAppWeb.AccessListsLive do
{:noreply, assign(socket, form: form)}
end
@impl true
def handle_event("members_prev_page", _, socket) do
new_page = max(1, socket.assigns.members_page - 1)
{:noreply, assign(socket, :members_page, new_page)}
end
@impl true
def handle_event("members_next_page", _, socket) do
total_members = length(socket.assigns.members)
max_page = max(1, ceil(total_members / socket.assigns.members_per_page))
new_page = min(max_page, socket.assigns.members_page + 1)
{:noreply, assign(socket, :members_page, new_page)}
end
@impl true
def handle_event("noop", _, socket) do
{:noreply, socket}
@@ -719,6 +737,17 @@ defmodule WandererAppWeb.AccessListsLive do
acl |> Map.put(:selected, acl.id == selected_id)
end
defp paginated_members(members, page, per_page) do
members
|> Enum.sort_by(&{&1.role, &1.name}, &<=/2)
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
defp total_pages(members, per_page) do
max(1, ceil(length(members) / per_page))
end
# Broadcast ACL update and invalidate map_characters cache for all maps using this ACL
# This ensures the tracking page shows updated members even when map server isn't running
defp broadcast_acl_updated(acl_id) do
@@ -742,7 +771,9 @@ defmodule WandererAppWeb.AccessListsLive do
end)
{:error, error} ->
Logger.warning("Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}")
Logger.warning(
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
)
end
end
end

View File

@@ -82,11 +82,14 @@
</h3>
<div
class="dropzone droppable draggable-dropzone--occupied flex flex-col gap-1 w-full rounded-none h-[calc(100vh-211px)] !overflow-y-auto"
class={[
"dropzone droppable draggable-dropzone--occupied flex flex-col gap-1 w-full rounded-none h-[calc(100vh-191px)] !overflow-y-auto",
classes("!h-[calc(100vh-240px)]": length(@members) > @members_per_page)
]}
id="acl_members"
>
<div
:for={member <- @members |> Enum.sort_by(&{&1.role, &1.name}, &<=/2)}
:for={member <- paginated_members(@members, @members_page, @members_per_page)}
draggable="true"
id={member.id}
class="draggable !p-1 h-10 cursor-move bg-black bg-opacity-25 hover:text-white"
@@ -113,10 +116,32 @@
</div>
</div>
</div>
<div>
<div :if={length(@members) > @members_per_page} class="flex items-center justify-between px-3 py-2 border-t border-gray-500 bg-black bg-opacity-25">
<span class="text-sm text-gray-400">
Page {@members_page} of {total_pages(@members, @members_per_page)} ({length(@members)} members)
</span>
<div class="flex gap-2">
<button
phx-click="members_prev_page"
disabled={@members_page <= 1}
class={"btn btn-sm btn-ghost " <> if(@members_page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="members_next_page"
disabled={@members_page >= total_pages(@members, @members_per_page)}
class={"btn btn-sm btn-ghost " <> if(@members_page >= total_pages(@members, @members_per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<.link
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none"
class="btn w-full btn-neutral rounded-none"
patch={~p"/access-lists/#{@selected_acl_id}/add-members"}
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
@@ -129,6 +154,7 @@
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</div>
</div>
</div>
</main>
</div>
@@ -153,10 +179,10 @@
placeholder="Select an owner"
options={Enum.map(@characters, fn character -> {character.label, character.id} end)}
/>
<!-- Divider between above inputs and the API key section -->
<hr class="my-4 border-gray-600" />
<!-- API Key Section with grid layout -->
<div class="mt-2">
<label class="block text-sm font-medium text-gray-200 mb-1">ACL API key</label>

View File

@@ -1,4 +1,4 @@
defmodule WandererAppWeb.UserActivity do
defmodule WandererAppWeb.UserActivityItem do
use WandererAppWeb, :live_component
use LiveViewEvents

View File

@@ -120,10 +120,16 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
{:ok, signatures} =
WandererApp.Api.MapSystemSignature.by_linked_system_id(solar_system_target_id)
signatures
|> Enum.filter(fn s ->
s.system_id == source_system.id
end)
filtered_signatures =
signatures
|> Enum.filter(fn s ->
s.system_id == source_system.id
end)
# Collect eve_ids for audit logging
deleted_eve_ids = Enum.map(filtered_signatures, & &1.eve_id)
filtered_signatures
|> Enum.each(fn s ->
if not is_nil(s.temporary_name) && s.temporary_name == target_system.temporary_name do
map_id
@@ -143,6 +149,17 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
|> WandererApp.Api.MapSystemSignature.destroy!()
end)
# Audit log signatures deleted with connection
if deleted_eve_ids != [] do
WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_id: solar_system_source_id,
signatures: deleted_eve_ids
})
end
WandererApp.Map.Server.Impl.broadcast!(
map_id,
:signatures_updated,

View File

@@ -363,8 +363,8 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
linked_sig_eve_id: nil
})
s
|> WandererApp.Api.MapSystemSignature.update_linked_system(%{
# Use the wrapper to log unlink operations
WandererApp.Map.Server.SignaturesImpl.update_signature_linked_system(s, %{
linked_system_id: nil
})
end)

View File

@@ -3,8 +3,6 @@ defmodule WandererAppWeb.MapAuditLive do
require Logger
alias WandererAppWeb.UserActivity
def mount(
%{"slug" => map_slug, "period" => period, "activity" => activity} = _params,
_session,

View File

@@ -109,7 +109,7 @@
/>
</div>
<.live_component
module={UserActivity}
module={WandererAppWeb.UserActivityItem}
id="user-activity"
notify_to={self()}
can_undo_types={@can_undo_types}

View File

@@ -16,6 +16,8 @@ defmodule WandererAppWeb.Nav do
show_admin =
socket.assigns.current_user_role == :admin
latest_post = WandererApp.Blog.recent_posts(1) |> List.first()
{:cont,
socket
|> attach_hook(:active_tab, :handle_params, &set_active_tab/3)
@@ -25,7 +27,8 @@ defmodule WandererAppWeb.Nav do
show_admin: show_admin,
show_sidebar: true,
map_subscriptions_enabled?: WandererApp.Env.map_subscriptions_enabled?(),
app_version: WandererApp.Env.vsn()
app_version: WandererApp.Env.vsn(),
latest_post: latest_post
)}
end

View File

@@ -25,8 +25,8 @@ defmodule WandererAppWeb.PresenceGracePeriodManager do
require Logger
# 1 hour grace period before removing disconnected characters
@grace_period_ms :timer.hours(1)
# 15 minutes grace period before removing disconnected characters
@grace_period_ms :timer.minutes(15)
defstruct pending_removals: %{}, timers: %{}

View File

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

View File

@@ -0,0 +1,68 @@
%{
title: "Christmas Giveaway Challenge",
author: "Wanderer Team",
cover_image_uri: "/images/news/2025/12-18-advent-giveaway/cover.jpg",
tags: ~w(event giveaway challenge christmas advent partnership),
description: "Join our Advent Christmas Giveaway Challenge! Win exclusive partnership codes every day for a week. Be the fastest to claim your reward!"
}
---
![Christmas Giveaway Challenge](/images/news/2025/12-18-advent-giveaway/cover.jpg "Christmas Giveaway Challenge")
### The Season of Giving
This holiday season, we're spreading some festive cheer with a special event for our community: the **Advent Christmas Giveaway Challenge**!
Starting next week, we'll be giving away **1 exclusive partnership code every day for 7 days**. But here's the twist — it's a challenge!
---
### How It Works
1. **Daily Giveaway:**
- Every day during the event week, a partnership code will be revealed at a specific scheduled time.
- The exact reveal time will be announced for each day.
2. **The Challenge:**
- When the code is revealed, it becomes visible to **all participants** at the exact same moment.
- **First person to activate the code wins!**
- Speed and timing are everything.
3. **One Code Per Day:**
- Each day features a single partnership code.
- Miss today? Come back tomorrow for another chance!
---
### Event Details
- **Event Name:** Advent Christmas Giveaway
- **Duration:** 1 week (7 days, 7 codes)
- **Organizer:** @Demiro (Wanderer core developer, EventCortex CTO)
- **Event Link:** [Advent Christmas Giveaway - EventCortex](https://eventcortex.com/events/invite/cYdBywu1ygfVS3UN6ZZcmDzL1q85aDmH)
---
### Tips for Participants
- **Be Ready:** Know the reveal time and be online a few minutes early.
- **Stay Alert:** The code appears for everyone simultaneously — every second counts!
- **Keep Trying:** Didn't win today? There's always tomorrow's code.
---
### Why Participate?
Partnership codes can be redeemed in EVE Online for **exclusive partnership SKINs** — unique ship skins that let you fly in style! This is your chance to grab one for free — if you're fast enough!
Good luck, and may the fastest capsuleer win!
---
Fly safe and happy holidays,
**The Wanderer Team**
---

View File

@@ -0,0 +1,473 @@
defmodule WandererApp.Map.MapScopeFilteringTest do
@moduledoc """
Integration tests for map scope filtering during character location tracking.
These tests verify that systems are correctly filtered based on map scope settings
when characters move between systems. The key scenarios tested:
1. Characters moving between systems with [:wormholes, :null] scopes:
- Wormhole systems should be added
- Null-sec systems should be added
- High-sec systems should NOT be added (filtered out)
- Low-sec systems should NOT be added (filtered out)
2. Wormhole border behavior:
- When a character jumps from wormhole to k-space, the wormhole should be added
- K-space border systems should only be added if they match the scopes
3. K-space only movement:
- Characters moving within k-space should only track systems matching scopes
- No "border system" behavior for k-space to k-space movement
Reference bug: Characters with [:wormholes, :null] scopes were getting
high-sec (0.6) and low-sec (0.4) systems added to the map when traveling.
"""
use WandererApp.DataCase
# System class constants (matching ConnectionsImpl)
@c1 1
@c2 2
@hs 7
@ls 8
@ns 9
# Test solar system IDs
# C1 wormhole
@wh_system_j100001 31_000_001
# C2 wormhole
@wh_system_j100002 31_000_002
# High-sec system (0.6)
@hs_system_halenan 30_000_001
# High-sec system (0.6)
@hs_system_mili 30_000_002
# Low-sec system (0.4)
@ls_system_halmah 30_000_100
# Null-sec system
@ns_system_geminate 30_000_200
setup do
# Setup system static info cache with both wormhole and k-space systems
setup_scope_test_systems()
:ok
end
# Setup system static info for scope testing
defp setup_scope_test_systems do
test_systems = %{
# C1 Wormhole
@wh_system_j100001 => %{
solar_system_id: @wh_system_j100001,
solar_system_name: "J100001",
solar_system_name_lc: "j100001",
region_id: 11_000_001,
constellation_id: 21_000_001,
region_name: "A-R00001",
constellation_name: "A-C00001",
system_class: @c1,
security: "-1.0",
type_description: "Class 1",
class_title: "C1",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: ["H121"],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# C2 Wormhole
@wh_system_j100002 => %{
solar_system_id: @wh_system_j100002,
solar_system_name: "J100002",
solar_system_name_lc: "j100002",
region_id: 11_000_001,
constellation_id: 21_000_001,
region_name: "A-R00001",
constellation_name: "A-C00001",
system_class: @c2,
security: "-1.0",
type_description: "Class 2",
class_title: "C2",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: ["D382", "L005"],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# High-sec system (Halenan 0.6)
@hs_system_halenan => %{
solar_system_id: @hs_system_halenan,
solar_system_name: "Halenan",
solar_system_name_lc: "halenan",
region_id: 10_000_067,
constellation_id: 20_000_901,
region_name: "Devoid",
constellation_name: "Devoid",
system_class: @hs,
security: "0.6",
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
},
# High-sec system (Mili 0.6)
@hs_system_mili => %{
solar_system_id: @hs_system_mili,
solar_system_name: "Mili",
solar_system_name_lc: "mili",
region_id: 10_000_067,
constellation_id: 20_000_901,
region_name: "Devoid",
constellation_name: "Devoid",
system_class: @hs,
security: "0.6",
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
},
# Low-sec system (Halmah 0.4)
@ls_system_halmah => %{
solar_system_id: @ls_system_halmah,
solar_system_name: "Halmah",
solar_system_name_lc: "halmah",
region_id: 10_000_067,
constellation_id: 20_000_901,
region_name: "Devoid",
constellation_name: "Devoid",
system_class: @ls,
security: "0.4",
type_description: "Low Security",
class_title: "Low Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Null-sec system
@ns_system_geminate => %{
solar_system_id: @ns_system_geminate,
solar_system_name: "Geminate",
solar_system_name_lc: "geminate",
region_id: 10_000_029,
constellation_id: 20_000_400,
region_name: "Geminate",
constellation_name: "Geminate",
system_class: @ns,
security: "-0.5",
type_description: "Null Security",
class_title: "Null Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
}
}
Enum.each(test_systems, fn {solar_system_id, system_info} ->
Cachex.put(:system_static_info_cache, solar_system_id, system_info)
end)
:ok
end
describe "Scope filtering logic tests" do
# These tests verify the filtering logic without full integration
# The actual filtering is tested more comprehensively in map_scopes_test.exs
alias WandererApp.Map.Server.ConnectionsImpl
alias WandererApp.Map.Server.SystemsImpl
test "can_add_location correctly filters high-sec with [:wormholes, :null] scopes" do
# High-sec should NOT be allowed with [:wormholes, :null]
refute ConnectionsImpl.can_add_location([:wormholes, :null], @hs_system_halenan),
"High-sec should be filtered out with [:wormholes, :null] scopes"
refute ConnectionsImpl.can_add_location([:wormholes, :null], @hs_system_mili),
"High-sec should be filtered out with [:wormholes, :null] scopes"
end
test "can_add_location correctly filters low-sec with [:wormholes, :null] scopes" do
# Low-sec should NOT be allowed with [:wormholes, :null]
refute ConnectionsImpl.can_add_location([:wormholes, :null], @ls_system_halmah),
"Low-sec should be filtered out with [:wormholes, :null] scopes"
end
test "can_add_location correctly allows wormholes with [:wormholes, :null] scopes" do
# Wormholes should be allowed
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_j100001),
"Wormhole should be allowed with [:wormholes, :null] scopes"
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_j100002),
"Wormhole should be allowed with [:wormholes, :null] scopes"
end
test "can_add_location correctly allows null-sec with [:wormholes, :null] scopes" do
# Null-sec should be allowed
assert ConnectionsImpl.can_add_location([:wormholes, :null], @ns_system_geminate),
"Null-sec should be allowed with [:wormholes, :null] scopes"
end
test "maybe_add_system filters out high-sec when not jumping from wormhole" do
# When scopes is [:wormholes, :null] and NOT jumping from wormhole,
# high-sec systems should be filtered
location = %{solar_system_id: @hs_system_halenan}
# old_location is nil (no previous system)
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes, :null])
assert result == :ok
# old_location is also high-sec (k-space to k-space)
old_location = %{solar_system_id: @hs_system_mili}
result = SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
assert result == :ok
end
test "maybe_add_system filters out low-sec when not jumping from wormhole" do
location = %{solar_system_id: @ls_system_halmah}
# old_location is high-sec (k-space to k-space)
old_location = %{solar_system_id: @hs_system_halenan}
result = SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
assert result == :ok
end
test "maybe_add_system allows border high-sec when jumping FROM wormhole" do
# When jumping FROM a wormhole TO high-sec with :wormholes scope,
# the high-sec should be added as a border system
location = %{solar_system_id: @hs_system_halenan}
old_location = %{solar_system_id: @wh_system_j100001}
# This should attempt to add the system (not filter it out)
# The result will be an error because the map doesn't exist,
# but that proves the filtering logic allowed it through
result = SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes])
# The function attempts to add (returns error because map doesn't exist)
# This proves border behavior is working - system was NOT filtered out
assert match?({:error, _}, result),
"Border system should attempt to be added (error because map doesn't exist)"
end
test "is_connection_valid allows WH to HS with [:wormholes, :null] (border behavior)" do
# The connection is valid for border behavior - but individual systems are filtered
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@wh_system_j100001,
@hs_system_halenan
),
"WH to HS connection should be valid (border behavior)"
end
test "is_connection_valid rejects HS to LS with [:wormholes, :null] (no border)" do
# HS to LS should be rejected - neither system matches scopes and no wormhole involved
refute ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@hs_system_halenan,
@ls_system_halmah
),
"HS to LS connection should be rejected with [:wormholes, :null]"
end
test "is_connection_valid rejects HS to HS with [:wormholes, :null]" do
# HS to HS should be rejected
refute ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@hs_system_halenan,
@hs_system_mili
),
"HS to HS connection should be rejected with [:wormholes, :null]"
end
end
describe "get_effective_scopes behavior" do
alias WandererApp.Map.Server.CharactersImpl
test "get_effective_scopes returns scopes array when present" do
# Create a map struct with scopes array
map = %{scopes: [:wormholes, :null]}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:wormholes, :null]
end
test "get_effective_scopes converts legacy :all scope" do
map = %{scope: :all}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:wormholes, :hi, :low, :null, :pochven]
end
test "get_effective_scopes converts legacy :wormholes scope" do
map = %{scope: :wormholes}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:wormholes]
end
test "get_effective_scopes converts legacy :stargates scope" do
map = %{scope: :stargates}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:hi, :low, :null, :pochven]
end
test "get_effective_scopes converts legacy :none scope" do
map = %{scope: :none}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == []
end
test "get_effective_scopes defaults to [:wormholes] when no scope" do
map = %{}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:wormholes]
end
end
describe "WandererApp.Map struct and new/1 function" do
alias WandererApp.Map.Server.CharactersImpl
test "Map struct includes scopes field" do
# Verify the struct has the scopes field
map_struct = %WandererApp.Map{}
assert Map.has_key?(map_struct, :scopes)
assert map_struct.scopes == nil
end
test "Map.new/1 extracts scopes from input" do
# Simulate input from database (Ash resource)
input = %{
id: "test-map-id",
name: "Test Map",
scope: :wormholes,
scopes: [:wormholes, :null],
owner_id: "owner-123",
acls: [],
hubs: []
}
map = WandererApp.Map.new(input)
assert map.map_id == "test-map-id"
assert map.name == "Test Map"
assert map.scope == :wormholes
assert map.scopes == [:wormholes, :null]
end
test "Map.new/1 handles missing scopes (nil)" do
# When scopes is not present in input, it should be nil
input = %{
id: "test-map-id",
name: "Test Map",
scope: :all,
owner_id: "owner-123",
acls: [],
hubs: []
}
map = WandererApp.Map.new(input)
assert map.map_id == "test-map-id"
assert map.scope == :all
assert map.scopes == nil
end
test "get_effective_scopes uses scopes field from Map struct when present" do
# Create map struct with both scope and scopes
input = %{
id: "test-map-id",
name: "Test Map",
scope: :all,
scopes: [:wormholes, :null],
owner_id: "owner-123",
acls: [],
hubs: []
}
map = WandererApp.Map.new(input)
# get_effective_scopes should prioritize scopes over scope
effective = CharactersImpl.get_effective_scopes(map)
assert effective == [:wormholes, :null]
end
test "get_effective_scopes falls back to legacy scope when scopes is nil" do
# Create map struct with only legacy scope
input = %{
id: "test-map-id",
name: "Test Map",
scope: :all,
owner_id: "owner-123",
acls: [],
hubs: []
}
map = WandererApp.Map.new(input)
# get_effective_scopes should convert legacy :all scope
effective = CharactersImpl.get_effective_scopes(map)
assert effective == [:wormholes, :hi, :low, :null, :pochven]
end
test "get_effective_scopes falls back to legacy scope when scopes is empty list" do
# Empty scopes list should fall back to legacy scope
input = %{
id: "test-map-id",
name: "Test Map",
scope: :stargates,
scopes: [],
owner_id: "owner-123",
acls: [],
hubs: []
}
map = WandererApp.Map.new(input)
# get_effective_scopes should fall back to legacy scope conversion
effective = CharactersImpl.get_effective_scopes(map)
assert effective == [:hi, :low, :null, :pochven]
end
test "Map.new/1 extracts all scope variations correctly" do
# Test various scope combinations
test_cases = [
{[:wormholes], [:wormholes]},
{[:hi, :low], [:hi, :low]},
{[:wormholes, :hi, :low, :null, :pochven], [:wormholes, :hi, :low, :null, :pochven]},
{[:null], [:null]}
]
for {input_scopes, expected_scopes} <- test_cases do
input = %{
id: "test-map-id",
name: "Test Map",
scope: :wormholes,
scopes: input_scopes,
owner_id: "owner-123",
acls: [],
hubs: []
}
map = WandererApp.Map.new(input)
effective = CharactersImpl.get_effective_scopes(map)
assert effective == expected_scopes,
"Expected #{inspect(expected_scopes)}, got #{inspect(effective)} for input #{inspect(input_scopes)}"
end
end
end
end

View File

@@ -300,7 +300,7 @@ defmodule WandererAppWeb.Factory do
# Include owner_id in the form data just like the LiveView does
create_attrs =
built_attrs
|> Map.take([:name, :slug, :description, :scope, :only_tracked_characters])
|> Map.take([:name, :slug, :description, :scope, :scopes, :only_tracked_characters])
|> Map.put(:owner_id, owner_id)
# Debug: ensure owner_id is valid

View File

@@ -206,37 +206,55 @@ defmodule WandererApp.Map.Server.MapScopesTest do
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @hs_system_id) == false
end
test "connection valid when at least one system matches a scope" do
# WH to HS: valid if either :wormholes or :hi is selected
test "wormhole border behavior: WH connections allow border k-space systems" do
# WH to HS with [:wormholes]: valid (wormhole border behavior)
# At least one system is WH, :wormholes is enabled -> border k-space allowed
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
true
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == true
# WH to HS with [:hi] only: INVALID (no wormhole scope, WH doesn't match :hi)
# Neither system matches when we require both to match (no wormhole border behavior)
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == false
# WH to WH: valid only if :wormholes is selected
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @c2_system_id) ==
true
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @c2_system_id) == false
# HS to LS: valid if :hi or :low is selected
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == true
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == true
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
end
test "connection with multiple scopes allows cross-space movement" do
# With [:wormholes, :hi], all of these should be valid:
# - WH to WH (wormholes matches)
# - HS to HS (hi matches)
# - WH to HS (either matches)
test "k-space connections require BOTH systems to match scopes" do
# HS to LS: requires BOTH to match, so single scope is not enough
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == false
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == false
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
# HS to LS with [:hi, :low]: valid (both match)
assert ConnectionsImpl.is_connection_valid([:hi, :low], @hs_system_id, @ls_system_id) == true
# HS to HS: valid with [:hi] (both match)
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, 30_000_002) == true
# NS to NS: valid with [:null] (both match)
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
# (same system returns false)
end
test "connection with multiple scopes" do
# With [:wormholes, :hi]:
# - WH to WH: valid (both match :wormholes)
# - HS to HS: valid (both match :hi)
# - WH to HS: valid (wormhole border behavior - WH is wormhole, :wormholes enabled)
scopes = [:wormholes, :hi]
assert ConnectionsImpl.is_connection_valid(scopes, @wh_system_id, @c2_system_id) == true
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, 30_000_002) == true
assert ConnectionsImpl.is_connection_valid(scopes, @wh_system_id, @hs_system_id) == true
# But LS to NS should not be valid with [:wormholes, :hi]
# LS to NS should not be valid with [:wormholes, :hi] (neither is WH, neither matches)
assert ConnectionsImpl.is_connection_valid(scopes, @ls_system_id, @ns_system_id) == false
# HS to LS should not be valid with [:wormholes, :hi] (neither is WH, only HS matches)
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, @ls_system_id) == false
end
test "all scopes allows any connection" do
@@ -294,4 +312,127 @@ defmodule WandererApp.Map.Server.MapScopesTest do
assert ConnectionsImpl.is_prohibited_system_class?(25) == false
end
end
describe "maybe_add_system/5 scope filtering" do
alias WandererApp.Map.Server.SystemsImpl
test "returns :ok without filtering when scopes is nil" do
# When scopes is nil, should not filter (backward compatibility)
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [])
assert result == :ok
end
test "returns :ok without filtering when scopes is empty list" do
# Empty scopes should not filter (let through)
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [], [])
assert result == :ok
end
test "filters system when scopes provided and system doesn't match" do
# When scopes is [:wormholes] and system is Hi-Sec, should filter (return :ok without adding)
location = %{solar_system_id: @hs_system_id}
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes])
# Returns :ok because system was filtered out (not an error, just skipped)
assert result == :ok
end
test "allows system through when scopes match (verified via can_add_location)" do
# When scopes is [:wormholes] and system is WH, filtering should allow it
# We test this via can_add_location which is what maybe_add_system uses internally
assert ConnectionsImpl.can_add_location([:wormholes], @wh_system_id) == true
assert ConnectionsImpl.can_add_location([:null], @ns_system_id) == true
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_id) == true
assert ConnectionsImpl.can_add_location([:wormholes, :null], @ns_system_id) == true
end
end
describe "border system auto-addition behavior" do
# Tests that verify bordered systems are correctly auto-added ONLY for wormholes.
# Key behavior:
# - Wormhole border: WH to Hi-Sec with [:wormholes] -> BOTH added (border behavior)
# - K-space only: Null to Hi-Sec with [:wormholes, :null] -> REJECTED (no border for k-space)
# - K-space must match: both systems must match scopes when no wormhole involved
test "WORMHOLE BORDER: WH->Hi-Sec with [:wormholes] is VALID (border k-space added)" do
# Border case: moving from WH to k-space
# Valid because :wormholes enabled AND one system is WH
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) == true
end
test "WORMHOLE BORDER: Hi-Sec->WH with [:wormholes] is VALID (border k-space added)" do
# Border case: moving from k-space to WH
# Valid because :wormholes enabled AND one system is WH
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @wh_system_id) == true
end
test "K-SPACE ONLY: Hi-Sec->Hi-Sec with [:wormholes] is REJECTED" do
# No wormhole involved, neither matches :wormholes
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == false
end
test "K-SPACE ONLY: Null->Hi-Sec with [:wormholes, :null] is REJECTED (no border for k-space)" do
# Neither system is a wormhole, so no border behavior
# Null matches :null, but Hi-Sec doesn't match any scope -> BOTH must match
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @ns_system_id, @hs_system_id) ==
false
end
test "K-SPACE ONLY: Hi-Sec->Low-Sec with [:wormholes, :null] is REJECTED" do
# Neither Hi-Sec nor Low-Sec match [:wormholes, :null], no WH involved
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
false
end
test "K-SPACE ONLY: Low-Sec->Hi-Sec with [:low] is REJECTED (no border for k-space)" do
# Low-Sec matches :low, but Hi-Sec doesn't match
# No wormhole involved, so BOTH must match -> rejected
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, @hs_system_id) == false
end
test "K-SPACE MATCH: Low-Sec->Low-Sec with [:low] is VALID (both match)" do
# Both systems match :low
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, 30_000_101) == true
end
test "K-SPACE MATCH: Null->Null with [:null] is VALID (both match)" do
# Would need two different null-sec systems for this test
# Using same system returns false (same system check)
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
end
test "WORMHOLE BORDER: Pochven->WH with [:wormholes, :pochven] is VALID" do
# WH is wormhole, :wormholes enabled -> border behavior applies
assert ConnectionsImpl.is_connection_valid([:wormholes, :pochven], @pochven_id, @wh_system_id) ==
true
end
test "WORMHOLE BORDER: WH->Pochven with [:wormholes] is VALID (border k-space)" do
# WH is wormhole, :wormholes enabled -> border behavior, Pochven added as border
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @pochven_id) == true
end
test "border systems: WH->Hi-Sec->WH path with [:wormholes] scope" do
# Simulates a character path through k-space between WHs
# First jump: WH to Hi-Sec - valid (wormhole border)
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) == true
# Second jump: Hi-Sec to WH - valid (wormhole border)
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @c2_system_id) == true
end
test "excluded path: k-space chain with [:wormholes] scope remains excluded" do
# If character moves within k-space (no WH involved), should be excluded
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == false
assert ConnectionsImpl.is_connection_valid([:wormholes], 30_000_002, @ls_system_id) == false
end
test "excluded path: Null->Hi-Sec->Low-Sec with [:wormholes, :null] - only Null tracked" do
# Character in Null (tracked) jumps to Hi-Sec (border - but NO wormhole!) -> REJECTED
# This is the key case: k-space to k-space should NOT add border systems
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @ns_system_id, @hs_system_id) ==
false
# Hi-Sec to Low-Sec also rejected (neither matches)
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
false
end
end
end