Compare commits

...

54 Commits

Author SHA1 Message Date
CI
e585cdfd20 chore: release version v1.91.7 2026-01-05 20:29:10 +00:00
Dmitry Popov
3a3180f7b3 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-05 21:28:38 +01:00
Dmitry Popov
53abc580e5 chore: added promo on characters page 2026-01-05 21:28:35 +01:00
CI
8710d172a0 chore: [skip ci] 2026-01-04 23:49:15 +00:00
CI
301a380a4b chore: release version v1.91.6 2026-01-04 23:49:15 +00:00
Dmitry Popov
8c911f89e0 fix(core): fixed new connections got deleted after linked signature cleanup 2026-01-05 00:48:38 +01:00
CI
d7e09fc94e chore: [skip ci] 2025-12-30 10:49:35 +00:00
CI
3b7e191898 chore: release version v1.91.5 2025-12-30 10:49:35 +00:00
Dmitry Popov
f351fbaf20 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-30 11:49:02 +01:00
Dmitry Popov
016e793ba7 chore: Added 2026 roadmap blog post 2025-12-30 11:48:59 +01:00
CI
db483fd253 chore: [skip ci] 2025-12-30 09:27:37 +00:00
CI
911ba231cd chore: release version v1.91.4 2025-12-30 09:27:37 +00:00
Dmitry Popov
b3053f325d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-30 10:27:06 +01:00
Dmitry Popov
4ab47334fc fix(core): fixed connections create between k-space systems (considered as wh connection) 2025-12-30 10:27:03 +01:00
CI
e163f02526 chore: [skip ci] 2025-12-28 17:02:12 +00:00
CI
9e22dba8f1 chore: release version v1.91.3 2025-12-28 17:02:12 +00:00
Dmitry Popov
9631406def Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-28 18:01:43 +01:00
Dmitry Popov
f6ae448c3b chore: update event post 2025-12-28 18:01:39 +01:00
CI
46345ef596 chore: [skip ci] 2025-12-27 22:11:03 +00:00
CI
1625f16c8f chore: release version v1.91.2 2025-12-27 22:11:03 +00:00
Dmitry Popov
b4ef9ae983 fix(core): fixed map scopes updates & logic 2025-12-27 23:10:26 +01:00
CI
3b9c2dd996 chore: [skip ci] 2025-12-25 18:20:20 +00:00
CI
8a0f9a58d0 chore: release version v1.91.1 2025-12-25 18:20:20 +00:00
Dmitry Popov
5fe8caac0d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-25 19:19:47 +01:00
Dmitry Popov
f18f567727 chore: fix blog link styles 2025-12-25 19:19:44 +01:00
CI
91acc49980 chore: [skip ci] 2025-12-24 15:09:40 +00:00
CI
ae3873a225 chore: release version v1.91.0 2025-12-24 15:09:40 +00:00
Dmitry Popov
b351c6cc26 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-24 16:09:06 +01:00
Dmitry Popov
698244d945 feat(admin): added maps administration view with basic info, search, restore/delete, acls view and edit options 2025-12-24 16:09:03 +01:00
CI
2c7dd9dc5b chore: [skip ci] 2025-12-19 12:33:26 +00:00
CI
36934cce0b chore: release version v1.90.13 2025-12-19 12:33:26 +00:00
Dmitry Popov
b7da7e4ecb Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-19 13:32:46 +01:00
Dmitry Popov
6471ea5590 fix(core): fixed welcome page 2025-12-19 13:32:44 +01:00
CI
b46bcac642 chore: [skip ci] 2025-12-19 09:38:36 +00:00
CI
52d90361e9 chore: release version v1.90.12 2025-12-19 09:38:36 +00:00
Dmitry Popov
1c902d3319 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-19 10:38:02 +01:00
Dmitry Popov
8f671a359b fix(core): fixed permissions update after character corp updates 2025-12-19 10:37:59 +01:00
CI
840c416684 chore: [skip ci] 2025-12-18 21:47:59 +00:00
CI
56e29ad30a chore: release version v1.90.11 2025-12-18 21:47:59 +00:00
Dmitry Popov
cd8f8b5801 chore: added promo codes support for map subs 2025-12-18 22:19:50 +01:00
Dmitry Popov
70e013fa3d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-18 22:19:38 +01:00
Dmitry Popov
d6bfaf8008 chore: added promo codes support for map subs 2025-12-18 22:19:26 +01:00
CI
95944199a0 chore: [skip ci] 2025-12-18 18:05:48 +00:00
CI
3bd5db8cf3 chore: release version v1.90.10 2025-12-18 18:05:48 +00:00
Dmitry Popov
a245330ada Merge branch 'advent-challenge' 2025-12-18 19:05:10 +01:00
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
CI
e5afa1d5bc chore: [skip ci] 2025-12-15 11:46:40 +00:00
CI
1473fe8646 chore: release version v1.90.9 2025-12-15 11:46:40 +00: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
44 changed files with 2338 additions and 157 deletions

View File

@@ -16,3 +16,8 @@ export WANDERER_SSE_ENABLED="true"
export WANDERER_WEBHOOKS_ENABLED="true"
export WANDERER_SSE_MAX_CONNECTIONS="1000"
export WANDERER_WEBHOOK_TIMEOUT_MS="15000"
# Promo codes for map subscriptions (optional)
# Format: CODE:DISCOUNT_PERCENT,CODE2:DISCOUNT_PERCENT2
# Codes are case-insensitive, discounts stack with period discounts
# export WANDERER_PROMO_CODES="PROMO2025:10,NEWUSER:20"

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ repomix*
/priv/static/images/
/priv/static/*.js
/priv/static/*.css
/priv/static/*-*.png
/priv/static/*-*.webp
/priv/static/*-*.webmanifest
# Dialyzer PLT files
/priv/plts/

View File

@@ -2,6 +2,108 @@
<!-- changelog -->
## [v1.91.7](https://github.com/wanderer-industries/wanderer/compare/v1.91.6...v1.91.7) (2026-01-05)
## [v1.91.6](https://github.com/wanderer-industries/wanderer/compare/v1.91.5...v1.91.6) (2026-01-04)
### Bug Fixes:
* core: fixed new connections got deleted after linked signature cleanup
## [v1.91.5](https://github.com/wanderer-industries/wanderer/compare/v1.91.4...v1.91.5) (2025-12-30)
## [v1.91.4](https://github.com/wanderer-industries/wanderer/compare/v1.91.3...v1.91.4) (2025-12-30)
### Bug Fixes:
* core: fixed connections create between k-space systems (considered as wh connection)
## [v1.91.3](https://github.com/wanderer-industries/wanderer/compare/v1.91.2...v1.91.3) (2025-12-28)
## [v1.91.2](https://github.com/wanderer-industries/wanderer/compare/v1.91.1...v1.91.2) (2025-12-27)
### Bug Fixes:
* core: fixed map scopes updates & logic
## [v1.91.1](https://github.com/wanderer-industries/wanderer/compare/v1.91.0...v1.91.1) (2025-12-25)
## [v1.91.0](https://github.com/wanderer-industries/wanderer/compare/v1.90.13...v1.91.0) (2025-12-24)
### Features:
* admin: added maps administration view with basic info, search, restore/delete, acls view and edit options
## [v1.90.13](https://github.com/wanderer-industries/wanderer/compare/v1.90.12...v1.90.13) (2025-12-19)
### Bug Fixes:
* core: fixed welcome page
## [v1.90.12](https://github.com/wanderer-industries/wanderer/compare/v1.90.11...v1.90.12) (2025-12-19)
### Bug Fixes:
* core: fixed permissions update after character corp updates
## [v1.90.11](https://github.com/wanderer-industries/wanderer/compare/v1.90.10...v1.90.11) (2025-12-18)
## [v1.90.10](https://github.com/wanderer-industries/wanderer/compare/v1.90.9...v1.90.10) (2025-12-18)
## [v1.90.9](https://github.com/wanderer-industries/wanderer/compare/v1.90.8...v1.90.9) (2025-12-15)
### Bug Fixes:
* core: reduce chracters untrack grace period to 15 mins (after change/close/disconnect from map)
## [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)

View File

@@ -1001,3 +1001,27 @@ body > div:first-of-type {
.verticalTabsContainer .p-tabview-panel {
flex-grow: 1;
}
/* Blog post CTA links - only in main post content */
.post-content a {
display: inline-block;
background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
color: white !important;
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
text-decoration: none !important;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.3);
}
.post-content a:hover {
background: linear-gradient(135deg, #db2777 0%, #7c3aed 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(236, 72, 153, 0.4);
}
.post-content a:active {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(236, 72, 153, 0.3);
}

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: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -92,6 +92,31 @@ map_subscription_extra_hubs_10_price =
config_dir
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_EXTRA_HUBS_10_PRICE", 10_000_000)
# Parse promo codes from environment variable
# Format: "CODE1:10,CODE2:20" where numbers are discount percentages
promo_codes =
config_dir
|> get_var_from_path_or_env("WANDERER_PROMO_CODES", "")
|> case do
"" ->
%{}
codes_string ->
codes_string
|> String.split(",")
|> Enum.map(fn entry ->
case String.split(String.trim(entry), ":") do
[code, discount] ->
{String.upcase(String.trim(code)), String.to_integer(String.trim(discount))}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
end
map_connection_auto_expire_hours =
config_dir
|> get_int_from_path_or_env("WANDERER_MAP_CONNECTION_AUTO_EXPIRE_HOURS", 24)
@@ -176,7 +201,8 @@ config :wanderer_app,
}
],
extra_characters_50: map_subscription_extra_characters_50_price,
extra_hubs_10: map_subscription_extra_hubs_10_price
extra_hubs_10: map_subscription_extra_hubs_10_price,
promo_codes: promo_codes
},
# Finch pool configuration - separate pools for different services
# ESI Character Tracking pool - high capacity for bulk character operations

View File

@@ -67,6 +67,8 @@ defmodule WandererApp.Api.Map do
)
define(:duplicate, action: :duplicate)
define(:admin_all, action: :admin_all)
define(:restore, action: :restore)
end
calculations do
@@ -107,6 +109,12 @@ defmodule WandererApp.Api.Map do
prepare WandererApp.Api.Preparations.FilterMapsByRoles
end
read :admin_all do
# Admin-only action that bypasses FilterMapsByRoles
# Returns ALL maps including soft-deleted ones with owner and ACLs loaded
prepare build(load: [:owner, :acls])
end
create :new do
accept [
:name,
@@ -194,6 +202,14 @@ defmodule WandererApp.Api.Map do
change(set_attribute(:deleted, true))
end
update :restore do
# Admin-only action to restore a soft-deleted map
accept([])
require_atomic? false
change(set_attribute(:deleted, false))
end
update :update_api_key do
accept [:public_api_key]
require_atomic? false

View File

@@ -731,6 +731,14 @@ defmodule WandererApp.Character.Tracker do
{:character_alliance, {character_id, character_update}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{alliance_id: nil})
end
@@ -769,6 +777,14 @@ defmodule WandererApp.Character.Tracker do
{:character_alliance, {character_id, character_update}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{alliance_id: alliance_id})
@@ -823,6 +839,14 @@ defmodule WandererApp.Character.Tracker do
}}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{corporation_id: corporation_id})

View File

@@ -42,6 +42,35 @@ defmodule WandererApp.Env do
def corp_eve_id(), do: get_key(:corp_id, -1)
def subscription_settings(), do: get_key(:subscription_settings)
@doc """
Returns the promo code configuration map.
Keys are uppercase code strings, values are discount percentages.
"""
def promo_codes() do
case subscription_settings() do
%{promo_codes: codes} when is_map(codes) -> codes
_ -> %{}
end
end
@doc """
Validates a promo code and returns the discount percentage.
Returns {:ok, discount_percent} if valid, {:error, :invalid_code} otherwise.
Codes are case-insensitive.
"""
def validate_promo_code(nil), do: {:error, :invalid_code}
def validate_promo_code(""), do: {:error, :invalid_code}
def validate_promo_code(code) when is_binary(code) do
normalized = String.upcase(String.trim(code))
case Map.get(promo_codes(), normalized) do
nil -> {:error, :invalid_code}
discount when is_integer(discount) and discount > 0 and discount <= 100 -> {:ok, discount}
_ -> {:error, :invalid_code}
end
end
@decorate cacheable(
cache: WandererApp.Cache,
key: "restrict_maps_creation"

View File

@@ -7,7 +7,8 @@ defmodule WandererApp.EveDataService do
alias WandererApp.Utils.JSONUtil
@eve_db_dump_url "https://www.fuzzwork.co.uk/dump/latest"
# @eve_db_dump_url "https://www.fuzzwork.co.uk/dump/latest"
@eve_db_dump_url "https://wanderer-industries.github.io/wanderer-assets/sde-files"
@dump_file_names [
"invGroups.csv",

View File

@@ -23,7 +23,10 @@ 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} = input) 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)

View File

@@ -72,28 +72,36 @@ defmodule WandererApp.Map.SubscriptionManager do
:ok
end
def estimate_price(params, renew?, promo_code \\ nil)
def estimate_price(
%{
"period" => period,
"characters_limit" => characters_limit,
"hubs_limit" => hubs_limit
},
renew?
} = params,
renew?,
promo_code
)
when is_binary(characters_limit),
do:
estimate_price(
%{
period: period |> String.to_integer(),
characters_limit: characters_limit |> String.to_integer(),
hubs_limit: hubs_limit |> String.to_integer()
},
renew?
)
when is_binary(characters_limit) do
# Extract promo_code from params if passed there (from form)
promo_code = promo_code || Map.get(params, "promo_code")
estimate_price(
%{
period: period |> String.to_integer(),
characters_limit: characters_limit |> String.to_integer(),
hubs_limit: hubs_limit |> String.to_integer()
},
renew?,
promo_code
)
end
def estimate_price(
%{characters_limit: characters_limit, hubs_limit: hubs_limit} = params,
renew?
renew?,
promo_code
) do
%{
plans: plans,
@@ -136,7 +144,7 @@ defmodule WandererApp.Map.SubscriptionManager do
total_price = estimated_price * period
{:ok, discount} =
{:ok, period_discount} =
calc_discount(
period,
total_price,
@@ -144,13 +152,27 @@ defmodule WandererApp.Map.SubscriptionManager do
renew?
)
{:ok, total_price, discount}
# Calculate promo discount on price after period discount
price_after_period_discount = total_price - period_discount
{:ok, promo_discount, promo_valid?} =
calc_promo_discount(promo_code, price_after_period_discount)
total_discount = period_discount + promo_discount
{:ok, total_price, total_discount, promo_valid?}
end
def calc_additional_price(params, selected_subscription, promo_code \\ nil)
def calc_additional_price(
%{"characters_limit" => characters_limit, "hubs_limit" => hubs_limit},
selected_subscription
%{"characters_limit" => characters_limit, "hubs_limit" => hubs_limit} = params,
selected_subscription,
promo_code
) do
# Extract promo_code from params if passed there (from form)
promo_code = promo_code || Map.get(params, "promo_code")
%{
plans: plans,
extra_characters_50: extra_characters_50,
@@ -189,7 +211,7 @@ defmodule WandererApp.Map.SubscriptionManager do
total_price = additional_price * period
{:ok, discount} =
{:ok, period_discount} =
calc_discount(
period,
total_price,
@@ -197,7 +219,15 @@ defmodule WandererApp.Map.SubscriptionManager do
false
)
{:ok, total_price, discount}
# Calculate promo discount on price after period discount
price_after_period_discount = total_price - period_discount
{:ok, promo_discount, promo_valid?} =
calc_promo_discount(promo_code, price_after_period_discount)
total_discount = period_discount + promo_discount
{:ok, total_price, total_discount, promo_valid?}
end
defp get_active_months(subscription) do
@@ -255,6 +285,22 @@ defmodule WandererApp.Map.SubscriptionManager do
when period >= 3,
do: {:ok, round(total_price * month_3_discount)}
# Calculates the promo code discount amount.
# Returns {:ok, discount_amount, is_valid?}
defp calc_promo_discount(nil, _price), do: {:ok, 0, false}
defp calc_promo_discount("", _price), do: {:ok, 0, false}
defp calc_promo_discount(promo_code, price) when is_binary(promo_code) do
case WandererApp.Env.validate_promo_code(promo_code) do
{:ok, discount_percent} ->
discount_amount = round(price * discount_percent / 100)
{:ok, discount_amount, true}
{:error, :invalid_code} ->
{:ok, 0, false}
end
end
def get_balance(map) do
map
|> WandererApp.MapRepo.load_relationships([
@@ -302,7 +348,8 @@ defmodule WandererApp.Map.SubscriptionManager do
defp renew_subscription(%{auto_renew?: true, map: map} = subscription)
when is_map(subscription) do
with {:ok, estimated_price, discount} <- estimate_price(subscription, true),
# No promo code for auto-renewals, ignore the promo_valid? return value
with {:ok, estimated_price, discount, _promo_valid?} <- estimate_price(subscription, true),
{:ok, map_balance} <- get_balance(map) do
case map_balance >= estimated_price do
true ->

View File

@@ -56,7 +56,7 @@ defmodule WandererApp.Map.Server.AclsImpl do
end
)
map_update = %{acls: map.acls, scope: map.scope}
map_update = %{acls: map.acls, scope: map.scope, scopes: map.scopes}
WandererApp.Map.update_map(map_id, map_update)
WandererApp.Cache.delete("map_characters-#{map_id}")

View File

@@ -569,6 +569,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
# Broadcast permission update to trigger LiveView refresh
broadcast_permission_update(character_id)
:has_update
{:character_corporation, _info} ->
@@ -580,6 +583,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
# Broadcast permission update to trigger LiveView refresh
broadcast_permission_update(character_id)
:has_update
_ ->
@@ -953,4 +959,21 @@ defmodule WandererApp.Map.Server.CharactersImpl do
track: true
})
end
# Broadcasts permission update to trigger LiveView refresh for the character's user.
# This is called when a character's corporation or alliance changes, ensuring
# users are kicked off maps they no longer have access to.
defp broadcast_permission_update(character_id) do
case WandererApp.Character.get_character(character_id) do
{:ok, %{eve_id: eve_id}} when not is_nil(eve_id) ->
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{eve_id}",
:update_permissions
)
_ ->
:ok
end
end
end

View File

@@ -5,6 +5,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
alias WandererApp.Map.Server.Impl
alias WandererApp.Map.Server.SignaturesImpl
alias WandererApp.Map.Server.SystemsImpl
# @ccp1 -1
@c1 1
@@ -296,6 +297,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
@@ -756,17 +781,39 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
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)
cond do
# Case 1: Wormhole border behavior - at least one system is a wormhole
# and :wormholes is enabled, allow the connection (adds border k-space systems)
wormholes_enabled and (from_is_wormhole or to_is_wormhole) ->
# 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)
# Case 2: K-space to K-space with :wormholes enabled - check if it's a wormhole connection
# If neither system is a wormhole AND there's no stargate between them, it's a wormhole connection
wormholes_enabled and not from_is_wormhole and not to_is_wormhole ->
# Check if there's a known stargate connection
case find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
{:ok, known_jumps} when known_jumps == [] ->
# No stargate exists - this is a wormhole connection through k-space
true
{:ok, _known_jumps} ->
# Stargate exists - this is NOT a wormhole, check normal scope matching
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
_ ->
# Error fetching jumps - fall back to scope matching
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
end
# Case 3: Non-wormhole movement without :wormholes scope
# Both systems must match the configured scopes
true ->
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
@@ -841,6 +888,44 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
end
end
@doc """
Check if a connection between two k-space systems is a wormhole connection.
Returns true if:
1. Both systems are k-space (not wormhole space)
2. There is no known stargate between them
This is used to detect wormhole connections through k-space, like when
a player jumps from low-sec to low-sec through a wormhole.
"""
def is_kspace_wormhole_connection?(from_solar_system_id, to_solar_system_id)
when is_nil(from_solar_system_id) or is_nil(to_solar_system_id),
do: false
def is_kspace_wormhole_connection?(from_solar_system_id, to_solar_system_id)
when from_solar_system_id == to_solar_system_id,
do: false
def is_kspace_wormhole_connection?(from_solar_system_id, to_solar_system_id) do
with {:ok, from_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_info} <- get_system_static_info(to_solar_system_id) do
from_is_wormhole = from_info.system_class in @wh_space
to_is_wormhole = to_info.system_class in @wh_space
# Both must be k-space (not wormhole space)
if not from_is_wormhole and not to_is_wormhole do
# Check if there's a known stargate
case find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
{:ok, []} -> true # No stargate = wormhole connection
_ -> false # Stargate exists or error
end
else
false
end
else
_ -> false
end
end
defp get_system_static_info(solar_system_id) do
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system_static_info} when not is_nil(system_static_info) ->
@@ -874,6 +959,13 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
WandererApp.Cache.delete("map_#{map_id}:conn_#{connection.id}:start_time")
# Clear linked_sig_eve_id on target system when connection is deleted
# This ensures old signatures become orphaned and won't affect future connections
SystemsImpl.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: location.solar_system_id,
linked_sig_eve_id: nil
})
_error ->
:ok
end

View File

@@ -170,16 +170,20 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
end
defp remove_signature(map_id, sig, system, delete_conn?) do
# optionally remove the linked connection
if delete_conn? && sig.linked_system_id do
# Check if this signature is the active one for the target system
# This prevents deleting connections when old/orphan signatures are removed
is_active = sig.linked_system_id && is_active_signature_for_target?(map_id, sig)
# Only delete connection if this signature is the active one
if delete_conn? && is_active do
ConnectionsImpl.delete_connection(map_id, %{
solar_system_source_id: system.solar_system_id,
solar_system_target_id: sig.linked_system_id
})
end
# clear any linked_sig_eve_id on the target system
if sig.linked_system_id do
# Only clear linked_sig_eve_id if this signature is the active one
if is_active do
SystemsImpl.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: sig.linked_system_id,
linked_sig_eve_id: nil
@@ -190,6 +194,16 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|> MapSystemSignature.destroy!()
end
defp is_active_signature_for_target?(map_id, sig) do
case MapSystem.read_by_map_and_solar_system(%{
map_id: map_id,
solar_system_id: sig.linked_system_id
}) do
{:ok, target_system} -> target_system.linked_sig_eve_id == sig.eve_id
_ -> false
end
end
def apply_update_signature(
map_id,
%MapSystemSignature{} = existing,

View File

@@ -147,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!()
@@ -523,9 +547,24 @@ defmodule WandererApp.Map.Server.SystemsImpl do
# 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)
wormhole_border_from_wh_space =
:wormholes in scopes and
not is_nil(old_location) and
ConnectionsImpl.can_add_location([:wormholes], old_location.solar_system_id)
# Third check: k-space wormhole connection
# If :wormholes scope is enabled AND there's no stargate between the systems,
# this is a wormhole connection through k-space - add both systems
kspace_wormhole_connection =
:wormholes in scopes and
not is_nil(old_location) and
not is_nil(old_location.solar_system_id) and
ConnectionsImpl.is_kspace_wormhole_connection?(
old_location.solar_system_id,
location.solar_system_id
)
wormhole_border_from_wh_space or kspace_wormhole_connection
end
end

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,7 @@
</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

@@ -41,12 +41,15 @@
<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) %>
<% {first_part, second_part} = case String.split(post.title, ":", parts: 2) do
[first, second] -> {first, second}
[first] -> {first, nil}
end %>
<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 :if={second_part} class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part}
</p>
</div>
</div>

View File

@@ -115,7 +115,9 @@
{@post.description}
</h4>
<!--Post Content-->
{raw(@post.body)}
<div class="post-content">
{raw(@post.body)}
</div>
</div>
</div>
<!--/container-->

View File

@@ -117,43 +117,48 @@
</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
: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>
</div>
<.link
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
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" />
<h3 class="card-title text-center text-md">Add Members</h3>
</.link>
<div
:if={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none btn-disabled"
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</div>
<.link
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
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" />
<h3 class="card-title text-center text-md">Add Members</h3>
</.link>
<div
:if={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none btn-disabled"
>
<.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>
@@ -179,10 +184,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

@@ -15,6 +15,15 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 2xl:grid-cols-4 pb-6">
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<span class="text-gray-400 dark:text-gray-400">Maps Management</span>
<.link class="btn mt-2 w-full btn-neutral rounded-none" navigate={~p"/admin/maps"}>
<.icon name="hero-map-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Manage All Maps</h3>
</.link>
</div>
</div>
<div :if={@restrict_maps_creation?} class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.button class="mt-2" type="button" phx-click="create-map">

View File

@@ -0,0 +1,273 @@
defmodule WandererAppWeb.AdminMapsLive do
@moduledoc """
Admin LiveView for managing all maps on the server.
Allows admins to view, edit, soft-delete, and restore maps regardless of ownership.
"""
use WandererAppWeb, :live_view
alias Phoenix.LiveView.AsyncResult
require Logger
@maps_per_page 20
@impl true
def mount(_params, %{"user_id" => user_id} = _session, socket)
when not is_nil(user_id) and is_connected?(socket) do
{:ok,
socket
|> assign(
maps: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @maps_per_page
)
|> load_maps_async()}
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(
maps: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @maps_per_page
)}
end
@impl true
def handle_params(params, _url, socket) when is_connected?(socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Maps")
|> assign(:selected_map, nil)
|> assign(:form, nil)
end
defp apply_action(socket, :edit, %{"id" => map_id}) do
case load_map_for_edit(map_id) do
{:ok, map} ->
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Edit Map")
|> assign(:selected_map, map)
|> assign(
:form,
map
|> AshPhoenix.Form.for_update(:update, forms: [auto?: true])
|> to_form()
)
|> load_owner_options()
{:error, _} ->
socket
|> put_flash(:error, "Map not found")
|> push_navigate(to: ~p"/admin/maps")
end
end
defp apply_action(socket, :view_acls, %{"id" => map_id}) do
case load_map_with_acls(map_id) do
{:ok, map} ->
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Map ACLs")
|> assign(:selected_map, map)
{:error, _} ->
socket
|> put_flash(:error, "Map not found")
|> push_navigate(to: ~p"/admin/maps")
end
end
# Data loading functions
defp load_maps_async(socket) do
socket
|> assign_async(:maps, fn -> load_all_maps() end)
end
defp load_all_maps do
case WandererApp.Api.Map.admin_all() do
{:ok, maps} ->
maps =
maps
|> Enum.sort_by(& &1.name, :asc)
{:ok, %{maps: maps}}
_ ->
{:ok, %{maps: []}}
end
end
defp load_map_for_edit(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
{:ok, map} = Ash.load(map, [:owner, :acls])
{:ok, map}
error ->
error
end
end
defp load_map_with_acls(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
{:ok, map} = Ash.load(map, acls: [:owner, :members])
{:ok, map}
error ->
error
end
end
defp load_owner_options(socket) do
case WandererApp.Api.Character.read() do
{:ok, characters} ->
options =
characters
|> Enum.map(fn c -> {c.name, c.id} end)
|> Enum.sort_by(&elem(&1, 0))
socket |> assign(:owner_options, options)
_ ->
socket |> assign(:owner_options, [])
end
end
# Event handlers
@impl true
def handle_event("search", %{"value" => term}, socket) do
{:noreply, socket |> assign(:search_term, term) |> assign(:page, 1)}
end
@impl true
def handle_event("toggle_deleted", _params, socket) do
{:noreply,
socket |> assign(:show_deleted, not socket.assigns.show_deleted) |> assign(:page, 1)}
end
@impl true
def handle_event("delete_map", %{"id" => map_id}, socket) do
case soft_delete_map(map_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Map marked as deleted")
|> load_maps_async()}
{:error, _} ->
{:noreply, socket |> put_flash(:error, "Failed to delete map")}
end
end
@impl true
def handle_event("restore_map", %{"id" => map_id}, socket) do
case restore_map(map_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Map restored successfully")
|> load_maps_async()}
{:error, _} ->
{:noreply, socket |> put_flash(:error, "Failed to restore map")}
end
end
@impl true
def handle_event("validate", %{"form" => params}, socket) do
form = AshPhoenix.Form.validate(socket.assigns.form, params)
{:noreply, assign(socket, form: form)}
end
@impl true
def handle_event("save", %{"form" => params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
{:ok, _map} ->
{:noreply,
socket
|> put_flash(:info, "Map updated successfully")
|> push_navigate(to: ~p"/admin/maps")}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("page", %{"page" => page}, socket) do
{:noreply, socket |> assign(:page, String.to_integer(page))}
end
@impl true
def handle_event(_event, _params, socket) do
{:noreply, socket}
end
# Helper functions
defp soft_delete_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
WandererApp.Api.Map.mark_as_deleted(map)
error ->
error
end
end
defp restore_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
WandererApp.Api.Map.restore(map)
error ->
error
end
end
def filter_maps(maps, search_term, show_deleted) do
maps
|> Enum.filter(fn map ->
(show_deleted or not map.deleted) and
(search_term == "" or
String.contains?(String.downcase(map.name || ""), String.downcase(search_term)) or
String.contains?(String.downcase(map.slug || ""), String.downcase(search_term)))
end)
end
def paginate(maps, page, per_page) do
maps
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
def total_pages(maps, per_page) do
max(1, ceil(length(maps) / per_page))
end
def format_date(nil), do: "-"
def format_date(datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
def owner_name(nil), do: "No owner"
def owner_name(%{name: name}), do: name
end

View File

@@ -0,0 +1,240 @@
<main class="w-full h-full col-span-2 lg:col-span-1 p-4 pl-20 overflow-auto">
<div class="page-content">
<div class="container-fluid px-[0.625rem]">
<!-- Header -->
<div class="grid grid-cols-1 pb-6">
<div class="md:flex items-center justify-between px-[2px]">
<h4 class="text-[18px] font-medium text-gray-800 mb-sm-0 grow dark:text-gray-100 mb-2 md:mb-0">
Admin - Maps Management
</h4>
<.link navigate={~p"/admin"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left-solid" class="w-4 h-4" /> Back to Admin
</.link>
</div>
</div>
<!-- Search and Filters -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600 mb-4">
<div class="card-body flex flex-row gap-4 items-center">
<div class="flex-1">
<input
type="text"
placeholder="Search by name or slug..."
value={@search_term}
phx-keyup="search"
phx-debounce="300"
name="search"
class="input input-bordered w-full"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox"
checked={@show_deleted}
phx-click="toggle_deleted"
/>
<span class="text-sm">Show deleted</span>
</label>
</div>
</div>
<!-- Maps Table -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.async_result :let={maps} assign={@maps}>
<:loading>
<div class="flex justify-center p-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
</:loading>
<:failed :let={reason}>
<div class="alert alert-error">{inspect(reason)}</div>
</:failed>
<% filtered_maps = filter_maps(maps, @search_term, @show_deleted) %>
<% paginated_maps = paginate(filtered_maps, @page, @per_page) %>
<.table id="admin-maps" rows={paginated_maps} class="!max-h-[60vh] !overflow-y-auto">
<:col :let={map} label="Name">
<div class="flex items-center gap-2">
<span class={if map.deleted, do: "line-through text-gray-500", else: ""}>
{map.name}
</span>
<span :if={map.deleted} class="badge badge-error badge-sm">Deleted</span>
</div>
</:col>
<:col :let={map} label="Slug">
<span class="text-sm text-gray-400">{map.slug}</span>
</:col>
<:col :let={map} label="Owner">
{owner_name(map.owner)}
</:col>
<:col :let={map} label="Created">
<span class="text-sm">{format_date(map.inserted_at)}</span>
</:col>
<:col :let={map} label="Scope">
<span class="badge badge-ghost badge-sm">{map.scope}</span>
</:col>
<:action :let={map}>
<.link
patch={~p"/admin/maps/#{map.id}/edit"}
class="btn btn-ghost btn-xs hover:text-white"
title="Edit"
>
<.icon name="hero-pencil-solid" class="w-4 h-4" />
</.link>
</:action>
<:action :let={map}>
<.link
patch={~p"/admin/maps/#{map.id}/acls"}
class="btn btn-ghost btn-xs hover:text-white"
title="View ACLs"
>
<.icon name="hero-shield-check-solid" class="w-4 h-4" />
</.link>
</:action>
<:action :let={map}>
<button
:if={not map.deleted}
phx-click="delete_map"
phx-value-id={map.id}
data={[confirm: "Are you sure you want to delete this map?"]}
class="btn btn-ghost btn-xs hover:text-red-500"
title="Delete"
>
<.icon name="hero-trash-solid" class="w-4 h-4" />
</button>
<button
:if={map.deleted}
phx-click="restore_map"
phx-value-id={map.id}
data={[confirm: "Are you sure you want to restore this map?"]}
class="btn btn-ghost btn-xs hover:text-green-500"
title="Restore"
>
<.icon name="hero-arrow-path-solid" class="w-4 h-4" />
</button>
</:action>
</.table>
<!-- Pagination -->
<div
:if={length(filtered_maps) > @per_page}
class="flex items-center justify-between mt-4"
>
<span class="text-sm text-gray-400">
Page {@page} of {total_pages(filtered_maps, @per_page)} ({length(filtered_maps)} maps)
</span>
<div class="flex gap-2">
<button
phx-click="page"
phx-value-page={max(1, @page - 1)}
disabled={@page <= 1}
class={"btn btn-sm btn-ghost " <> if(@page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="page"
phx-value-page={min(total_pages(filtered_maps, @per_page), @page + 1)}
disabled={@page >= total_pages(filtered_maps, @per_page)}
class={"btn btn-sm btn-ghost " <> if(@page >= total_pages(filtered_maps, @per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Empty state -->
<div :if={length(filtered_maps) == 0} class="text-center py-8 text-gray-400">
No maps found
</div>
</.async_result>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<.modal
:if={@live_action == :edit and not is_nil(@selected_map)}
title="Edit Map"
class="!w-[500px]"
id="edit_map_modal"
show
on_cancel={JS.patch(~p"/admin/maps")}
>
<.form :let={f} for={@form} phx-change="validate" phx-submit="save">
<.input type="text" field={f[:name]} label="Name" placeholder="Map name" />
<.input type="text" field={f[:slug]} label="Slug" placeholder="map-slug" />
<.input
type="textarea"
field={f[:description]}
label="Description"
placeholder="Description"
/>
<.input
type="select"
field={f[:scope]}
label="Scope"
options={[
{"Wormholes", :wormholes},
{"Stargates", :stargates},
{"None", :none},
{"All", :all}
]}
/>
<.input
type="select"
field={f[:owner_id]}
label="Owner"
options={@owner_options}
prompt="Select owner..."
/>
<div class="modal-action">
<.button type="submit" phx-disable-with="Saving...">
Save Changes
</.button>
</div>
</.form>
</.modal>
<!-- View ACLs Modal -->
<.modal
:if={@live_action == :view_acls and not is_nil(@selected_map)}
title={"ACLs for: #{@selected_map.name}"}
class="!w-[600px]"
id="view_acls_modal"
show
on_cancel={JS.patch(~p"/admin/maps")}
>
<div class="space-y-4">
<div :if={Enum.empty?(@selected_map.acls)} class="text-gray-400 text-center py-4">
No ACLs assigned to this map
</div>
<div :for={acl <- @selected_map.acls} class="card bg-base-200">
<div class="card-body p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold">{acl.name}</h3>
<p class="text-sm text-gray-400">{acl.description || "No description"}</p>
</div>
<div class="badge badge-ghost">
{length(acl.members)} members
</div>
</div>
<div class="text-sm mt-2">
<span class="text-gray-400">Owner:</span>
<span>{if acl.owner, do: acl.owner.name, else: "Unknown"}</span>
</div>
</div>
</div>
</div>
<div class="modal-action">
<.link patch={~p"/admin/maps"} class="btn btn-ghost">
Close
</.link>
</div>
</.modal>
</main>

View File

@@ -29,6 +29,34 @@
id="characters-list"
class="w-full h-full col-span-2 lg:col-span-1 p-4 pl-20 pb-20 overflow-auto"
>
<div class="flex items-center justify-between gap-4 px-4 py-2 mb-4 bg-stone-900/60 border border-stone-800 rounded">
<div class="flex items-center gap-3">
<.icon name="hero-gift-solid" class="w-4 h-4 text-green-400 flex-shrink-0" />
<span class="text-sm text-gray-300">
Support development by using promocode
<code class="ml-1 px-1.5 py-0.5 bg-stone-800 rounded text-green-400 text-xs font-mono">WANDERER</code>
<span class="ml-1">at official</span>
</span>
<a
href="https://store.eveonline.com/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-green-400 hover:text-green-300 transition-colors"
>
<span>EVE Online Store</span>
<.icon name="hero-arrow-top-right-on-square-mini" class="w-3 h-3" />
</a>
</div>
<a
href="https://wanderer.ltd/news"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1 text-sm text-white rounded bg-gradient-to-r from-stone-700 to-stone-600 hover:from-stone-600 hover:to-stone-500 transition-all duration-300 animate-pulse hover:animate-none"
>
<.icon name="hero-newspaper-solid" class="w-3.5 h-3.5" />
<span>Check Latest News</span>
</a>
</div>
<div
:if={@show_characters_add_alert}
role="alert"

View File

@@ -15,6 +15,9 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
is_adding_subscription?: false,
map_subscriptions: [],
selected_subscription: nil,
promo_code: "",
promo_code_valid?: false,
promo_code_error: nil,
subscription_periods: [
{"1 Month", "1"},
{"3 Months", "3"},
@@ -34,12 +37,13 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
"period" => "1",
"characters_limit" => "50",
"hubs_limit" => "20",
"auto_renew?" => true
"auto_renew?" => true,
"promo_code" => ""
}
{:ok, map} = WandererApp.MapRepo.get(map_id)
{:ok, estimated_price, discount} =
{:ok, estimated_price, discount, _promo_valid?} =
SubscriptionManager.estimate_price(subscription_form, false)
{:ok, map_subscriptions} =
@@ -53,7 +57,10 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
map_subscriptions: map_subscriptions,
subscription_form: subscription_form |> to_form(),
estimated_price: estimated_price,
discount: discount
discount: discount,
promo_code: "",
promo_code_valid?: false,
promo_code_error: nil
)
{:ok, socket}
@@ -73,10 +80,11 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
"plan" => "omega",
"characters_limit" => "#{selected_subscription.characters_limit}",
"hubs_limit" => "#{selected_subscription.hubs_limit}",
"auto_renew?" => selected_subscription.auto_renew?
"auto_renew?" => selected_subscription.auto_renew?,
"promo_code" => ""
}
{:ok, additional_price, discount} =
{:ok, additional_price, discount, _promo_valid?} =
SubscriptionManager.calc_additional_price(
subscription_form,
selected_subscription
@@ -89,6 +97,9 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
selected_subscription: selected_subscription,
additional_price: additional_price,
discount: discount,
promo_code: "",
promo_code_valid?: false,
promo_code_error: nil,
subscription_form: subscription_form |> to_form()
)}
end
@@ -142,23 +153,46 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
params,
%{assigns: %{selected_subscription: selected_subscription}} = socket
) do
promo_code = Map.get(params, "promo_code", "")
# Validate promo code and set error message
{promo_code_valid?, promo_code_error} =
case WandererApp.Env.validate_promo_code(promo_code) do
{:ok, _discount} -> {true, nil}
{:error, :invalid_code} when promo_code != "" -> {false, "Invalid promo code"}
_ -> {false, nil}
end
socket =
case is_nil(selected_subscription) do
true ->
{:ok, estimated_price, discount} =
{:ok, estimated_price, discount, _valid?} =
WandererApp.Map.SubscriptionManager.estimate_price(params, false)
socket
|> assign(estimated_price: estimated_price, discount: discount)
|> assign(
estimated_price: estimated_price,
discount: discount,
promo_code: promo_code,
promo_code_valid?: promo_code_valid?,
promo_code_error: promo_code_error
)
_ ->
{:ok, additional_price, discount} =
{:ok, additional_price, discount, _valid?} =
WandererApp.Map.SubscriptionManager.calc_additional_price(
params,
selected_subscription
)
socket |> assign(additional_price: additional_price, discount: discount)
socket
|> assign(
additional_price: additional_price,
discount: discount,
promo_code: promo_code,
promo_code_valid?: promo_code_valid?,
promo_code_error: promo_code_error
)
end
{:noreply, assign(socket, subscription_form: params)}
@@ -176,8 +210,9 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
%{assigns: %{map_id: map_id, map: map, current_user: current_user}} = socket
) do
period = period |> String.to_integer()
promo_code = Map.get(subscription_form, "promo_code", "")
{:ok, estimated_price, discount} =
{:ok, estimated_price, discount, _promo_valid?} =
WandererApp.Map.SubscriptionManager.estimate_price(subscription_form, false)
active_till =
@@ -219,7 +254,8 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
:telemetry.execute([:wanderer_app, :map, :subscription, :new], %{count: 1}, %{
map_id: map_id,
amount: estimated_price - discount
amount: estimated_price - discount,
promo_code: if(promo_code != "", do: String.upcase(promo_code), else: nil)
})
# Automatically create a license for the map
@@ -266,7 +302,7 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
}
} = socket
) do
{:ok, additional_price, discount} =
{:ok, additional_price, discount, _promo_valid?} =
WandererApp.Map.SubscriptionManager.calc_additional_price(
subscription_form,
selected_subscription
@@ -537,6 +573,17 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
class="range range-xs"
/>
<.input field={f[:auto_renew?]} label="Auto Renew" type="checkbox" />
<div :if={is_nil(@selected_subscription)} class="mt-2">
<.input
field={f[:promo_code]}
label="Promo Code (optional)"
type="text"
placeholder="Enter promo code"
class="input input-bordered w-full"
/>
<p :if={@promo_code_error} class="text-rose-500 text-xs mt-1">{@promo_code_error}</p>
<p :if={@promo_code_valid?} class="text-green-500 text-xs mt-1">✓ Promo code applied!</p>
</div>
<div
:if={is_nil(@selected_subscription)}
class="stats w-full bg-primary text-primary-content mt-2"
@@ -556,7 +603,12 @@ defmodule WandererAppWeb.Maps.MapSubscriptionsComponent do
</div>
</div>
<div>
<div class="stat-title">Discount</div>
<div class="stat-title">
Discount
<span :if={@promo_code_valid?} class="text-xs text-green-400 ml-1">
(incl. promo)
</span>
</div>
<div class="stat-value text-white relative">
ISK {@discount
|> Number.to_human(units: ["", "K", "M", "B", "T", "P"])}

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

@@ -503,6 +503,9 @@ defmodule WandererAppWeb.Router do
] do
live("/", AdminLive, :index)
live("/invite", AdminLive, :add_invite_link)
live("/maps", AdminMapsLive, :index)
live("/maps/:id/edit", AdminMapsLive, :edit)
live("/maps/:id/acls", AdminMapsLive, :view_acls)
end
error_tracker_dashboard("/errors",

View File

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

View File

@@ -0,0 +1,53 @@
%{
title: "Event: Wanderer 2026 Roadmap Reveal",
author: "Wanderer Team",
cover_image_uri: "/images/news/2026/01-01-roadmap/cover.webp",
tags: ~w(event roadmap 2026 announcement community),
description: "JWanderer's 2026 roadmap are ready to reveal! Discover what exciting features and improvements are coming in 2026."
}
---
### Wanderer 2026 Roadmap Live Event
We're excited to announce that we're ready to share **Wanderer 2026 Roadmap**! Join to see the actual version with live updates for vision and plans.
---
### Event Details
- **Event Link:** [Wanderer 2026 Roadmap](https://eventcortex.com/events/invite/LcHQjTPb1jqHLzttlrgvUIb1RSBt7MFE)
- **You can always support development by join us on [Patreon](https://www.patreon.com/WandererLtd) to give feedback & increase priority for your feature requests in our special Discord channel available to our patrons only.**
---
### What to Expect
This year, we have ambitious plans to make Wanderer even better for the EVE Online community. Check event page for live updates on:
- **New Planned Features:** Exciting additions to enhance your mapping experience
- **Performance Improvements:** Faster, smoother, and more reliable
- **Community Requests:** Features you've been asking for
- **Integration Enhancements:** Better tools for corporations and alliances
- **API Expansions:** More power for developers and third-party tools
---
### Stay Connected
Join our community channels to stay updated:
- **[Discord](https://discord.gg/cafERvDD2k)**
- **[Telegram](https://t.me/wanderer_mapper)**
- **[Github](https://github.com/wanderer-industries)**
- **[YouTube](https://www.youtube.com/channel/UCalmteoec8rNXQugzZQcGnw?sub_confirmation=1)**
- **[Patreon](https://www.patreon.com/WandererLtd)**
---
We can't wait to share what's coming in 2026!
Fly safe,
**The Wanderer Team**
---

View File

@@ -0,0 +1,36 @@
%{
title: "Event: Weekly Giveaway Challenge",
author: "Wanderer Team",
cover_image_uri: "/images/news/2026/01-05-weekly-giveaway/cover.webp",
tags: ~w(event giveaway challenge),
description: "Join our Weekly Giveaway Challenge! Be the fastest to claim your reward!"
}
---
![Weekly Giveaway Challenge](/images/news/2026/01-05-weekly-giveaway/cover.webp "Weekly Giveaway Challenge")
### Event Details
In 2026, we're going to giveaway partnership SKIN codes for our community, every week!
- **Event Name:** Weekly Giveaway Challenge
- **Event Link:** [Join Weekly Giveaway Challenge](https://eventcortex.com/events/invite/Cjo87svZFq6J8cc1cubH4B7AR_VfPmQ4)
---
### Tips for Participants
- **Be Ready:** Know the reveal time and be online a few minutes early.
---
Good luck, and may the fastest capsuleer win!
---
Fly safe,
**Wanderer Team**
---

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

View File

@@ -186,7 +186,9 @@ defmodule WandererApp.AclMemberCacheInvalidationTest do
# Verify cache was invalidated
cached_data_after = WandererApp.Cache.lookup!(cache_key)
assert is_nil(cached_data_after), "Cache should be invalidated after adding corporation member"
assert is_nil(cached_data_after),
"Cache should be invalidated after adding corporation member"
end
@tag :integration

View File

@@ -0,0 +1,355 @@
defmodule WandererApp.Map.CorporationChangePermissionTest do
@moduledoc """
Integration tests for permission revocation when a character's corporation changes.
This tests the fix for the issue where:
- A user is granted map access via corporation-based ACL membership
- The user's character leaves or changes corporation
- The user could still see the map until they logged out
The fix ensures that when a character's corporation changes:
1. An :update_permissions broadcast is sent to the character's LiveView connections
2. The LiveView triggers a permission refresh
3. If access is revoked, the user is redirected away from the map
Related files:
- lib/wanderer_app/character/tracker.ex (broadcasts on corp change)
- lib/wanderer_app/map/server/map_server_characters_impl.ex (backup broadcast)
- lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex (handles broadcast)
"""
use WandererApp.DataCase, async: false
alias WandererAppWeb.Factory
import Mox
setup :verify_on_exit!
@test_corp_id_a 98000001
@test_corp_id_b 98000002
@test_alliance_id_a 99000001
setup do
# Configure the PubSubMock to forward to real Phoenix.PubSub for broadcast testing
Test.PubSubMock
|> Mox.stub(:broadcast!, fn server, topic, message ->
Phoenix.PubSub.broadcast!(server, topic, message)
end)
|> Mox.stub(:broadcast, fn server, topic, message ->
Phoenix.PubSub.broadcast(server, topic, message)
end)
|> Mox.stub(:subscribe, fn server, topic ->
Phoenix.PubSub.subscribe(server, topic)
end)
|> Mox.stub(:unsubscribe, fn server, topic ->
Phoenix.PubSub.unsubscribe(server, topic)
end)
:ok
end
describe "PubSub broadcast on corporation change" do
test "broadcasts :update_permissions to character channel when corporation update is simulated" do
# Create test data
user = Factory.create_user()
character =
Factory.create_character(%{
user_id: user.id,
corporation_id: @test_corp_id_a,
corporation_name: "Test Corp A",
corporation_ticker: "TCPA"
})
# Subscribe to the character's channel (this is what LiveView does via tracking_utils.ex)
Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{character.eve_id}")
# Simulate what happens in tracker.ex when a corporation change is detected
# This simulates the fix: broadcasting :update_permissions after corp change
simulate_corporation_change(character, @test_corp_id_b)
# Should receive :update_permissions broadcast
assert_receive :update_permissions, 1000,
"Should receive :update_permissions when corporation changes"
end
test "broadcasts :update_permissions to character channel when alliance update is simulated" do
# Create test data
user = Factory.create_user()
character =
Factory.create_character(%{
user_id: user.id,
corporation_id: @test_corp_id_a,
alliance_id: @test_alliance_id_a,
alliance_name: "Test Alliance A",
alliance_ticker: "TALA"
})
# Subscribe to the character's channel
Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{character.eve_id}")
# Simulate what happens when alliance is removed
simulate_alliance_removal(character)
# Should receive :update_permissions broadcast
assert_receive :update_permissions, 1000,
"Should receive :update_permissions when alliance is removed"
end
end
describe "Corporation-based ACL permission verification" do
test "character with corp A has access to map with corp A ACL" do
# Setup: Create a map with corporation-based ACL
owner_user = Factory.create_user()
owner = Factory.create_character(%{user_id: owner_user.id})
map =
Factory.create_map(%{
owner_id: owner.id,
name: "Corp Access Test Map",
slug: "corp-access-test-#{:rand.uniform(1_000_000)}"
})
# Create ACL that grants access to corporation A
acl = Factory.create_access_list(owner.id, %{name: "Corp A Access"})
_map_acl = Factory.create_map_access_list(map.id, acl.id)
_corp_member =
Factory.create_access_list_member(acl.id, %{
eve_corporation_id: "#{@test_corp_id_a}",
name: "Corporation A",
role: "member"
})
# Create user with character in corp A
test_user = Factory.create_user()
test_character =
Factory.create_character(%{
user_id: test_user.id,
corporation_id: @test_corp_id_a,
corporation_name: "Test Corp A",
corporation_ticker: "TCPA"
})
# Verify character has access via corporation membership
{:ok, map_with_acls} =
WandererApp.MapRepo.get(map.id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
)
[character_permissions] =
WandererApp.Permissions.check_characters_access([test_character], map_with_acls.acls)
map_permissions =
WandererApp.Permissions.get_map_permissions(
character_permissions,
owner.id,
[test_character.id]
)
assert map_permissions.view_system == true,
"Character in corp A should have view_system permission"
end
test "character in corp B does not have access to map with corp A ACL" do
# Setup: Create a map with corporation-based ACL for corp A
owner_user = Factory.create_user()
owner = Factory.create_character(%{user_id: owner_user.id})
map =
Factory.create_map(%{
owner_id: owner.id,
name: "CorpB Test",
slug: "corp-access-test-2-#{:rand.uniform(1_000_000)}"
})
# Create ACL that grants access only to corporation A
acl = Factory.create_access_list(owner.id, %{name: "Corp A Only Access"})
_map_acl = Factory.create_map_access_list(map.id, acl.id)
_corp_member =
Factory.create_access_list_member(acl.id, %{
eve_corporation_id: "#{@test_corp_id_a}",
name: "Corporation A",
role: "member"
})
# Create user with character in corp B (not A)
test_user = Factory.create_user()
test_character =
Factory.create_character(%{
user_id: test_user.id,
corporation_id: @test_corp_id_b,
corporation_name: "Test Corp B",
corporation_ticker: "TCPB"
})
# Verify character does NOT have access
{:ok, map_with_acls} =
WandererApp.MapRepo.get(map.id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
)
[character_permissions] =
WandererApp.Permissions.check_characters_access([test_character], map_with_acls.acls)
map_permissions =
WandererApp.Permissions.get_map_permissions(
character_permissions,
owner.id,
[test_character.id]
)
assert map_permissions.view_system == false,
"Character in corp B should NOT have view_system permission for corp A map"
end
test "permission check result changes when character changes from corp A to corp B" do
# Setup: Create a map with corporation-based ACL
owner_user = Factory.create_user()
owner = Factory.create_character(%{user_id: owner_user.id})
map =
Factory.create_map(%{
owner_id: owner.id,
name: "Corp Change Test Map",
slug: "corp-change-test-#{:rand.uniform(1_000_000)}"
})
# Create ACL that grants access to corporation A
acl = Factory.create_access_list(owner.id, %{name: "Corp A Access"})
_map_acl = Factory.create_map_access_list(map.id, acl.id)
_corp_member =
Factory.create_access_list_member(acl.id, %{
eve_corporation_id: "#{@test_corp_id_a}",
name: "Corporation A",
role: "member"
})
# Create user with character initially in corp A
test_user = Factory.create_user()
test_character =
Factory.create_character(%{
user_id: test_user.id,
corporation_id: @test_corp_id_a,
corporation_name: "Test Corp A",
corporation_ticker: "TCPA"
})
{:ok, map_with_acls} =
WandererApp.MapRepo.get(map.id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
)
# Verify initial access
[initial_permissions] =
WandererApp.Permissions.check_characters_access([test_character], map_with_acls.acls)
initial_map_permissions =
WandererApp.Permissions.get_map_permissions(
initial_permissions,
owner.id,
[test_character.id]
)
assert initial_map_permissions.view_system == true,
"Initially character in corp A should have view_system permission"
# Now simulate the character changing corporation
# Update the character's corporation in the database
character_update = %{
corporation_id: @test_corp_id_b,
corporation_name: "Test Corp B",
corporation_ticker: "TCPB"
}
{:ok, updated_character} =
WandererApp.Api.Character.update_corporation(test_character, character_update)
WandererApp.Character.update_character(test_character.id, character_update)
# Verify character no longer has access after corporation change
[new_permissions] =
WandererApp.Permissions.check_characters_access([updated_character], map_with_acls.acls)
new_map_permissions =
WandererApp.Permissions.get_map_permissions(
new_permissions,
owner.id,
[updated_character.id]
)
assert new_map_permissions.view_system == false,
"After changing to corp B, character should NOT have view_system permission"
end
end
# Helper functions that simulate what the tracker does
defp simulate_corporation_change(character, new_corporation_id) do
# Update character in database
character_update = %{
corporation_id: new_corporation_id,
corporation_name: "Test Corp B",
corporation_ticker: "TCPB"
}
{:ok, _} = WandererApp.Api.Character.update_corporation(character, character_update)
WandererApp.Character.update_character(character.id, character_update)
# Broadcast corporation change (existing behavior)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character.id}:corporation",
{:character_corporation, {character.id, character_update}}
)
# Broadcast permission update (THE FIX)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
end
defp simulate_alliance_removal(character) do
# Update character in database
character_update = %{
alliance_id: nil,
alliance_name: nil,
alliance_ticker: nil
}
{:ok, _} = WandererApp.Api.Character.update_alliance(character, character_update)
WandererApp.Character.update_character(character.id, character_update)
# Broadcast alliance change (existing behavior)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character.id}:alliance",
{:character_alliance, {character.id, character_update}}
)
# Broadcast permission update (THE FIX)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
end
end

View File

@@ -49,6 +49,8 @@ defmodule WandererApp.Map.MapScopeFilteringTest do
setup do
# Setup system static info cache with both wormhole and k-space systems
setup_scope_test_systems()
# Setup known stargates between adjacent k-space systems
setup_kspace_stargates()
:ok
end
@@ -190,6 +192,30 @@ defmodule WandererApp.Map.MapScopeFilteringTest do
:ok
end
# Setup known stargates between adjacent k-space systems
# This ensures that k-space to k-space connections WITH stargates are properly filtered
# (connections WITHOUT stargates are treated as wormhole connections)
defp setup_kspace_stargates do
# Stargate between Halenan (HS) and Mili (HS) - adjacent high-sec systems
# Cache key format: "jump_#{smaller_id}_#{larger_id}"
halenan_mili_key = "jump_#{@hs_system_halenan}_#{@hs_system_mili}"
WandererApp.Cache.insert(halenan_mili_key, %{
from_solar_system_id: @hs_system_halenan,
to_solar_system_id: @hs_system_mili
})
# Stargate between Halenan (HS) and Halmah (LS) - adjacent high-sec to low-sec
halenan_halmah_key = "jump_#{@hs_system_halenan}_#{@ls_system_halmah}"
WandererApp.Cache.insert(halenan_halmah_key, %{
from_solar_system_id: @hs_system_halenan,
to_solar_system_id: @ls_system_halmah
})
: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
@@ -237,7 +263,10 @@ defmodule WandererApp.Map.MapScopeFilteringTest do
# 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])
result =
SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
assert result == :ok
end
@@ -245,7 +274,10 @@ defmodule WandererApp.Map.MapScopeFilteringTest 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])
result =
SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
assert result == :ok
end

View File

@@ -0,0 +1,187 @@
defmodule WandererApp.Map.Server.AclScopesPropagationTest do
@moduledoc """
Unit tests for verifying that map scopes are properly propagated
when ACL updates occur.
This test verifies the fix in lib/wanderer_app/map/server/map_server_acls_impl.ex:59
where `scopes` was added to the map_update struct.
Bug: When users update map scope settings (Wormholes, High-Sec, Low-Sec, Null-Sec,
Pochven checkboxes), the map server's cached state wasn't being updated with the
new scopes array. This caused connection tracking to use stale scope settings
until the server was restarted.
Fix: Changed `map_update = %{acls: map.acls, scope: map.scope}`
To: `map_update = %{acls: map.acls, scope: map.scope, scopes: map.scopes}`
"""
use WandererApp.DataCase, async: false
import WandererAppWeb.Factory
describe "MapRepo.get returns scopes field" do
test "map scopes are loaded when fetching map data" do
# Create a user and character for map ownership
user = create_user()
character = create_character(%{user_id: user.id})
# Create a map with specific scopes
map =
create_map(%{
owner_id: character.id,
name: "Scopes Test",
slug: "scopes-prop-test-#{:rand.uniform(1_000_000)}",
scope: :wormholes,
scopes: [:wormholes, :hi, :low]
})
# Verify the map was created with the expected scopes
assert map.scopes == [:wormholes, :hi, :low]
# Fetch the map the same way AclsImpl.handle_map_acl_updated does
{:ok, fetched_map} =
WandererApp.MapRepo.get(map.id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
)
# Verify scopes are returned - this is what the fix relies on
assert fetched_map.scopes == [:wormholes, :hi, :low],
"MapRepo.get should return the scopes field. Got: #{inspect(fetched_map.scopes)}"
# Verify the scope (legacy) field is also present
assert fetched_map.scope == :wormholes
end
test "map scopes field is available for map_update construction" do
# Create test data
user = create_user()
character = create_character(%{user_id: user.id})
map =
create_map(%{
owner_id: character.id,
name: "Update Test",
slug: "scopes-update-test-#{:rand.uniform(1_000_000)}",
scope: :all,
scopes: [:wormholes, :hi, :low, :null, :pochven]
})
# Fetch map as AclsImpl does
{:ok, fetched_map} = WandererApp.MapRepo.get(map.id, acls: [:owner_id])
# Build map_update the same way the fixed code does
# This is the exact line that was fixed in map_server_acls_impl.ex:59
map_update = %{acls: fetched_map.acls, scope: fetched_map.scope, scopes: fetched_map.scopes}
# Verify all fields are present in the update struct
assert Map.has_key?(map_update, :acls), "map_update should include :acls"
assert Map.has_key?(map_update, :scope), "map_update should include :scope"
assert Map.has_key?(map_update, :scopes), "map_update should include :scopes"
# Verify the scopes value is correct
assert map_update.scopes == [:wormholes, :hi, :low, :null, :pochven],
"map_update.scopes should have the complete scopes array"
end
end
describe "scopes update in database" do
test "updating map scopes persists correctly" do
# Create test data
user = create_user()
character = create_character(%{user_id: user.id})
map =
create_map(%{
owner_id: character.id,
name: "DB Update Test",
slug: "scopes-db-test-#{:rand.uniform(1_000_000)}",
scope: :wormholes,
scopes: [:wormholes]
})
# Initial state
assert map.scopes == [:wormholes]
# Update scopes (simulating what the LiveView does)
{:ok, updated_map} =
WandererApp.Api.Map.update(map, %{
scopes: [:wormholes, :hi, :low, :null]
})
assert updated_map.scopes == [:wormholes, :hi, :low, :null],
"Database update should persist new scopes"
# Fetch again to confirm persistence
{:ok, refetched_map} = WandererApp.MapRepo.get(map.id, [])
assert refetched_map.scopes == [:wormholes, :hi, :low, :null],
"Refetched map should have updated scopes"
end
test "partial scopes update works correctly" do
# Create test data
user = create_user()
character = create_character(%{user_id: user.id})
map =
create_map(%{
owner_id: character.id,
name: "Partial Update",
slug: "partial-scopes-#{:rand.uniform(1_000_000)}",
scope: :wormholes,
scopes: [:wormholes, :hi, :low, :null, :pochven]
})
# Update to a subset of scopes
{:ok, updated_map} =
WandererApp.Api.Map.update(map, %{
scopes: [:wormholes, :null]
})
assert updated_map.scopes == [:wormholes, :null],
"Should be able to update to partial scopes"
end
end
describe "get_effective_scopes uses scopes array" do
alias WandererApp.Map.Server.CharactersImpl
test "get_effective_scopes returns scopes array when present" do
map_struct = %{scopes: [:wormholes, :hi, :low], scope: :all}
effective_scopes = CharactersImpl.get_effective_scopes(map_struct)
assert effective_scopes == [:wormholes, :hi, :low],
"get_effective_scopes should return scopes array when present"
end
test "get_effective_scopes falls back to legacy scope when scopes is empty" do
map_struct = %{scopes: [], scope: :wormholes}
effective_scopes = CharactersImpl.get_effective_scopes(map_struct)
assert effective_scopes == [:wormholes],
"get_effective_scopes should fall back to legacy scope conversion"
end
test "get_effective_scopes falls back to legacy scope when scopes is nil" do
map_struct = %{scopes: nil, scope: :all}
effective_scopes = CharactersImpl.get_effective_scopes(map_struct)
assert effective_scopes == [:wormholes, :hi, :low, :null, :pochven],
"get_effective_scopes should convert :all to full scope list"
end
test "get_effective_scopes defaults to [:wormholes] when no scope info" do
map_struct = %{}
effective_scopes = CharactersImpl.get_effective_scopes(map_struct)
assert effective_scopes == [:wormholes],
"get_effective_scopes should default to [:wormholes]"
end
end
end

View File

@@ -56,8 +56,12 @@ defmodule WandererApp.Map.Server.MapScopesTest do
30_000_101 => %{solar_system_id: 30_000_101, system_class: @ls},
# Nullsec system
30_000_200 => %{solar_system_id: 30_000_200, system_class: @ns},
# Another nullsec for tests
30_000_201 => %{solar_system_id: 30_000_201, system_class: @ns},
# Pochven system
30_000_300 => %{solar_system_id: 30_000_300, system_class: @pochven},
# Another pochven for tests
30_000_301 => %{solar_system_id: 30_000_301, system_class: @pochven},
# Jita (prohibited system - highsec)
30_000_142 => %{solar_system_id: 30_000_142, system_class: @hs}
}
@@ -230,7 +234,8 @@ defmodule WandererApp.Map.Server.MapScopesTest do
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
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
@@ -243,18 +248,19 @@ defmodule WandererApp.Map.Server.MapScopesTest do
test "connection with multiple scopes" do
# With [:wormholes, :hi]:
# - WH to WH: valid (both match :wormholes)
# - HS to HS: valid (both match :hi)
# - HS to HS: valid (both match :hi, or wormhole if no stargate)
# - 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
# 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
# LS to NS with [:wormholes, :hi] - if no stargate exists, it's a wormhole connection
# With :wormholes enabled, wormhole connections are valid
assert ConnectionsImpl.is_connection_valid(scopes, @ls_system_id, @ns_system_id) == true
# 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
# HS to LS with [:wormholes, :hi] - if no stargate exists, it's a wormhole connection
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, @ls_system_id) == true
end
test "all scopes allows any connection" do
@@ -356,31 +362,43 @@ defmodule WandererApp.Map.Server.MapScopesTest do
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
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
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
test "K-SPACE ONLY: Hi-Sec->Hi-Sec with [:wormholes] is VALID when no stargate exists" do
# If no stargate exists between two k-space systems, it's a wormhole connection
# (The test systems don't have stargate data, so this is treated as a wormhole)
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == true
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
test "K-SPACE ONLY: Null->Hi-Sec with [:wormholes, :null] is VALID when no stargate exists" do
# If no stargate exists, this is a wormhole connection through k-space
# With [:wormholes] enabled, wormhole connections are valid
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@ns_system_id,
@hs_system_id
) ==
true
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
test "K-SPACE ONLY: Hi-Sec->Low-Sec with [:wormholes, :null] is VALID when no stargate exists" do
# If no stargate exists, this is a wormhole connection
# With [:wormholes] enabled, wormhole connections are valid
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@hs_system_id,
@ls_system_id
) ==
true
end
test "K-SPACE ONLY: Low-Sec->Hi-Sec with [:low] is REJECTED (no border for k-space)" do
@@ -402,7 +420,11 @@ defmodule WandererApp.Map.Server.MapScopesTest do
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) ==
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :pochven],
@pochven_id,
@wh_system_id
) ==
true
end
@@ -414,25 +436,177 @@ defmodule WandererApp.Map.Server.MapScopesTest do
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
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
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
test "k-space chain with [:wormholes] scope is VALID when no stargates exist" do
# If no stargates exist between k-space systems, they're wormhole connections
# With [:wormholes] scope, these should be tracked
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == true
assert ConnectionsImpl.is_connection_valid([:wormholes], 30_000_002, @ls_system_id) == true
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
test "k-space chain with [:wormholes, :null] - wormhole connections are tracked" do
# If no stargates exist, these are wormhole connections through k-space
# With [:wormholes] enabled, all wormhole connections are tracked
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@ns_system_id,
@hs_system_id
) ==
true
# Hi-Sec to Low-Sec is also a wormhole connection (no stargate in test data)
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@hs_system_id,
@ls_system_id
) ==
true
end
end
describe "wormhole connections in k-space (unknown connections)" do
@moduledoc """
These tests verify the behavior for k-space to k-space connections that are
NOT known stargates. Such connections should be treated as wormhole connections.
Scenario: A player jumps from Low-Sec to Hi-Sec. If there's no stargate between
these systems, the jump must have been through a wormhole. With [:wormholes] scope,
this connection SHOULD be valid.
The connection TYPE (stargate vs wormhole) is determined separately in
maybe_add_connection using is_connection_valid(:stargates, ...).
"""
test "Low-Sec to Hi-Sec with [:wormholes] is valid when no stargate exists (wormhole connection)" do
# When there's no stargate between low-sec and hi-sec, the jump must be through a wormhole
# With [:wormholes] scope, this wormhole connection should be valid
#
# The test systems @ls_system_id and @hs_system_id don't have a known stargate between them
# (they're test systems not in the EVE jump database), so this should be treated as a wormhole
result = ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @hs_system_id)
# Connection is valid because no stargate exists - it's a wormhole connection
assert result == true,
"K-space to K-space with [:wormholes] should be valid when no stargate exists"
end
test "Hi-Sec to Low-Sec with [:wormholes] is valid when no stargate exists" do
# Test the reverse direction
result = ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @ls_system_id)
assert result == true,
"Hi-Sec to Low-Sec with [:wormholes] should be valid when no stargate exists"
end
test "Null-Sec to Hi-Sec with [:wormholes] is valid when no stargate exists" do
# Null to Hi-Sec through wormhole
result = ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @hs_system_id)
assert result == true,
"Null-Sec to Hi-Sec with [:wormholes] should be valid when no stargate exists"
end
test "Low-Sec to Null-Sec with [:wormholes] is valid when no stargate exists" do
# Low to Null through wormhole
result = ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @ns_system_id)
assert result == true,
"Low-Sec to Null-Sec with [:wormholes] should be valid when no stargate exists"
end
test "Pochven to Hi-Sec with [:wormholes] is valid when no stargate exists" do
# Pochven has special wormhole connections to k-space
result = ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @hs_system_id)
assert result == true,
"Pochven to Hi-Sec with [:wormholes] should be valid when no stargate exists"
end
# Same-space-type wormhole connections
# These verify that jumps within the same security class are valid when no stargate exists
test "Low-Sec to Low-Sec with [:wormholes] is valid when no stargate exists" do
# A wormhole can connect two low-sec systems
# With [:wormholes] scope and no known stargate, this should be tracked
result = ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, 30_000_101)
assert result == true,
"Low-Sec to Low-Sec with [:wormholes] should be valid when no stargate exists"
end
test "Hi-Sec to Hi-Sec with [:wormholes] is valid when no stargate exists" do
# A wormhole can connect two hi-sec systems
# With [:wormholes] scope and no known stargate, this should be tracked
result = ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002)
assert result == true,
"Hi-Sec to Hi-Sec with [:wormholes] should be valid when no stargate exists"
end
test "Null-Sec to Null-Sec with [:wormholes] is valid when no stargate exists" do
# A wormhole can connect two null-sec systems
# With [:wormholes] scope and no known stargate, this should be tracked
result = ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, 30_000_201)
assert result == true,
"Null-Sec to Null-Sec with [:wormholes] should be valid when no stargate exists"
end
test "Pochven to Pochven with [:wormholes] is valid when no stargate exists" do
# A wormhole can connect two Pochven systems
# With [:wormholes] scope and no known stargate, this should be tracked
result = ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, 30_000_301)
assert result == true,
"Pochven to Pochven with [:wormholes] should be valid when no stargate exists"
end
# Cross-space-type comprehensive tests
# Verify all k-space combinations work correctly
test "all k-space combinations with [:wormholes] are valid when no stargate exists" do
# Test all combinations of k-space security types
# All should be valid because no stargates exist in test data = wormhole connections
# Hi-Sec combinations
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @ls_system_id) == true,
"Hi->Low should be valid"
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @ns_system_id) == true,
"Hi->Null should be valid"
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @pochven_id) == true,
"Hi->Pochven should be valid"
# Low-Sec combinations
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @hs_system_id) == true,
"Low->Hi should be valid"
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @ns_system_id) == true,
"Low->Null should be valid"
assert ConnectionsImpl.is_connection_valid([:wormholes], @ls_system_id, @pochven_id) == true,
"Low->Pochven should be valid"
# Null-Sec combinations
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @hs_system_id) == true,
"Null->Hi should be valid"
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @ls_system_id) == true,
"Null->Low should be valid"
assert ConnectionsImpl.is_connection_valid([:wormholes], @ns_system_id, @pochven_id) == true,
"Null->Pochven should be valid"
# Pochven combinations
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @hs_system_id) == true,
"Pochven->Hi should be valid"
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @ls_system_id) == true,
"Pochven->Low should be valid"
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @ns_system_id) == true,
"Pochven->Null should be valid"
end
end
end

View File

@@ -0,0 +1,213 @@
defmodule WandererApp.Map.Server.SignatureConnectionCascadeTest do
@moduledoc """
Tests for the signature-connection cascade behavior fix.
This test suite verifies that:
1. System's linked_sig_eve_id can be updated and cleared
2. The data model relationships work correctly
"""
use WandererApp.DataCase, async: false
import Mox
alias WandererApp.Api.MapSystem
alias WandererAppWeb.Factory
setup :verify_on_exit!
setup do
# Set up mocks in global mode for GenServer processes
Mox.set_mox_global()
# Setup DDRT mocks
Test.DDRTMock
|> stub(:init_tree, fn _name, _opts -> :ok end)
|> stub(:insert, fn _data, _tree_name -> {:ok, %{}} end)
|> stub(:update, fn _id, _data, _tree_name -> {:ok, %{}} end)
|> stub(:delete, fn _ids, _tree_name -> {:ok, %{}} end)
|> stub(:query, fn _bbox, _tree_name -> {:ok, []} end)
# Setup CachedInfo mocks for test systems
WandererApp.CachedInfo.Mock
|> stub(:get_system_static_info, fn
30_000_142 ->
{:ok,
%{
solar_system_id: 30_000_142,
solar_system_name: "Jita",
system_class: 7,
security: "0.9"
}}
30_000_143 ->
{:ok,
%{
solar_system_id: 30_000_143,
solar_system_name: "Perimeter",
system_class: 7,
security: "0.9"
}}
_ ->
{:error, :not_found}
end)
# Create test data using Factory
character = Factory.create_character()
map = Factory.create_map(%{owner_id: character.id})
%{map: map, character: character}
end
describe "linked_sig_eve_id management" do
test "system linked_sig_eve_id can be set and cleared", %{map: map} do
# Create a system without linked_sig_eve_id
{:ok, system} =
MapSystem.create(%{
map_id: map.id,
solar_system_id: 30_000_142,
name: "Jita"
})
# Initially nil
assert is_nil(system.linked_sig_eve_id)
# Update to a signature eve_id (simulating connection creation)
{:ok, updated_system} =
MapSystem.update_linked_sig_eve_id(system, %{linked_sig_eve_id: "SIG-123"})
assert updated_system.linked_sig_eve_id == "SIG-123"
# Clear it back to nil (simulating connection deletion - our fix)
{:ok, cleared_system} =
MapSystem.update_linked_sig_eve_id(updated_system, %{linked_sig_eve_id: nil})
assert is_nil(cleared_system.linked_sig_eve_id)
end
test "system can distinguish between different linked signatures", %{map: map} do
# Create system B (target) with linked_sig_eve_id = SIG-NEW
{:ok, system_b} =
MapSystem.create(%{
map_id: map.id,
solar_system_id: 30_000_143,
name: "Perimeter",
linked_sig_eve_id: "SIG-NEW"
})
# Verify the signature is correctly set
assert system_b.linked_sig_eve_id == "SIG-NEW"
# This verifies the logic: an old signature with eve_id="SIG-OLD"
# would NOT match system_b.linked_sig_eve_id
old_sig_eve_id = "SIG-OLD"
refute system_b.linked_sig_eve_id == old_sig_eve_id
# The new signature DOES match
new_sig_eve_id = "SIG-NEW"
assert system_b.linked_sig_eve_id == new_sig_eve_id
end
end
describe "is_active_signature_for_target? logic verification" do
@doc """
These tests verify the core logic of the fix:
- A signature is "active" only if target_system.linked_sig_eve_id == signature.eve_id
- If they don't match, the signature is "orphan" and should NOT cascade to connections
"""
test "active signature: linked_sig_eve_id matches signature eve_id", %{map: map} do
sig_eve_id = "ABC-123"
# System has linked_sig_eve_id pointing to our signature
{:ok, target_system} =
MapSystem.create(%{
map_id: map.id,
solar_system_id: 30_000_143,
name: "Perimeter",
linked_sig_eve_id: sig_eve_id
})
# This is what is_active_signature_for_target? checks
assert target_system.linked_sig_eve_id == sig_eve_id
end
test "orphan signature: linked_sig_eve_id points to different signature", %{map: map} do
# System has linked_sig_eve_id pointing to a NEWER signature
{:ok, target_system} =
MapSystem.create(%{
map_id: map.id,
solar_system_id: 30_000_143,
name: "Perimeter",
linked_sig_eve_id: "NEW-SIG-456"
})
# Old signature has different eve_id
old_sig_eve_id = "OLD-SIG-123"
# This would return false in is_active_signature_for_target?
refute target_system.linked_sig_eve_id == old_sig_eve_id
end
test "orphan signature: linked_sig_eve_id is nil", %{map: map} do
# System has nil linked_sig_eve_id (connection was already deleted)
{:ok, target_system} =
MapSystem.create(%{
map_id: map.id,
solar_system_id: 30_000_143,
name: "Perimeter"
})
assert is_nil(target_system.linked_sig_eve_id)
# Any signature would be orphan
old_sig_eve_id = "OLD-SIG-123"
refute target_system.linked_sig_eve_id == old_sig_eve_id
end
end
describe "scenario simulation" do
test "simulated scenario: re-entering WH after connection deleted", %{map: map} do
# This simulates the bug scenario:
# 1. User enters WH A → B, creates connection, signature SIG-OLD links B
# 2. Connection is deleted - linked_sig_eve_id should be cleared (our fix)
# 3. User re-enters, creates new connection, SIG-NEW links B
# 4. User deletes SIG-OLD - should NOT delete the new connection
# Step 1: Initial state - B has linked_sig_eve_id = SIG-OLD
{:ok, system_b} =
MapSystem.create(%{
map_id: map.id,
solar_system_id: 30_000_143,
name: "Perimeter",
linked_sig_eve_id: "SIG-OLD"
})
assert system_b.linked_sig_eve_id == "SIG-OLD"
# Step 2: Connection deleted - linked_sig_eve_id cleared (our fix in action)
{:ok, system_b_after_conn_delete} =
MapSystem.update_linked_sig_eve_id(system_b, %{linked_sig_eve_id: nil})
assert is_nil(system_b_after_conn_delete.linked_sig_eve_id)
# Step 3: New connection created - SIG-NEW links B
{:ok, system_b_after_new_conn} =
MapSystem.update_linked_sig_eve_id(system_b_after_conn_delete, %{
linked_sig_eve_id: "SIG-NEW"
})
assert system_b_after_new_conn.linked_sig_eve_id == "SIG-NEW"
# Step 4: Now when user tries to delete SIG-OLD:
# is_active_signature_for_target? would check:
# system_b.linked_sig_eve_id ("SIG-NEW") == old_sig.eve_id ("SIG-OLD")
# This returns FALSE, so connection deletion is SKIPPED
old_sig_eve_id = "SIG-OLD"
refute system_b_after_new_conn.linked_sig_eve_id == old_sig_eve_id
# The fix works!
end
end
end