mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-01-13 10:20:18 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a857422c46 | ||
|
|
ec6717d0ef | ||
|
|
56dacdcbbd | ||
|
|
c8e17b1691 | ||
|
|
19c7fe59ee | ||
|
|
682100c231 | ||
|
|
f9ac79cdcc | ||
|
|
f09f220645 | ||
|
|
e585cdfd20 | ||
|
|
3a3180f7b3 | ||
|
|
53abc580e5 | ||
|
|
8710d172a0 | ||
|
|
301a380a4b | ||
|
|
8c911f89e0 | ||
|
|
d7e09fc94e | ||
|
|
3b7e191898 | ||
|
|
f351fbaf20 | ||
|
|
016e793ba7 | ||
|
|
db483fd253 | ||
|
|
911ba231cd | ||
|
|
b3053f325d | ||
|
|
4ab47334fc | ||
|
|
e163f02526 | ||
|
|
9e22dba8f1 | ||
|
|
9631406def | ||
|
|
f6ae448c3b | ||
|
|
46345ef596 | ||
|
|
1625f16c8f | ||
|
|
b4ef9ae983 | ||
|
|
3b9c2dd996 | ||
|
|
8a0f9a58d0 | ||
|
|
5fe8caac0d | ||
|
|
f18f567727 | ||
|
|
91acc49980 | ||
|
|
ae3873a225 | ||
|
|
b351c6cc26 | ||
|
|
698244d945 | ||
|
|
2c7dd9dc5b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -2,6 +2,80 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v1.91.9](https://github.com/wanderer-industries/wanderer/compare/v1.91.8...v1.91.9) (2026-01-06)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed rally point cancel logic
|
||||
|
||||
## [v1.91.8](https://github.com/wanderer-industries/wanderer/compare/v1.91.7...v1.91.8) (2026-01-06)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed rally point cancel logic
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
|
||||
|
||||
const {
|
||||
storedSettings: { interfaceSettings },
|
||||
data: { systemSignatures: mapSystemSignatures },
|
||||
data: { systemSignatures: mapSystemSignatures, pings },
|
||||
} = useMapRootState();
|
||||
|
||||
const systemStaticInfo = useMemo(() => {
|
||||
@@ -108,7 +108,6 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
|
||||
visibleNodes,
|
||||
showKSpaceBG,
|
||||
isThickConnections,
|
||||
pings,
|
||||
systemHighlighted,
|
||||
},
|
||||
outCommand,
|
||||
|
||||
@@ -121,6 +121,7 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!ping) {
|
||||
setIsShow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,27 +162,26 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
|
||||
};
|
||||
}, [interfaceSettings]);
|
||||
|
||||
if (!ping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isShowSelectedSystem = selectedSystem != null && selectedSystem !== ping.solar_system_id;
|
||||
const isShowSelectedSystem = ping && selectedSystem != null && selectedSystem !== ping.solar_system_id;
|
||||
|
||||
// Only render Toast when there's a ping
|
||||
return (
|
||||
<>
|
||||
<Toast
|
||||
position={placement as never}
|
||||
className={clsx('!max-w-[initial] w-[500px]', hasLeftOffset ? offsets.withLeftMenu : offsets.default)}
|
||||
ref={toast}
|
||||
content={({ message }) => (
|
||||
<section
|
||||
className={clsx(
|
||||
'flex flex-col p-3 w-full border border-stone-800 shadow-md animate-fadeInDown rounded-[5px]',
|
||||
'bg-gradient-to-tr from-transparent to-sky-700/60 bg-stone-900/70',
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<i className={clsx('pi text-yellow-500 text-2xl', 'relative top-[2px]', ICONS[ping.type])}></i>
|
||||
{ping && (
|
||||
<Toast
|
||||
key={ping.id}
|
||||
position={placement as never}
|
||||
className={clsx('!max-w-[initial] w-[500px]', hasLeftOffset ? offsets.withLeftMenu : offsets.default)}
|
||||
ref={toast}
|
||||
content={({ message }) => (
|
||||
<section
|
||||
className={clsx(
|
||||
'flex flex-col p-3 w-full border border-stone-800 shadow-md animate-fadeInDown rounded-[5px]',
|
||||
'bg-gradient-to-tr from-transparent to-sky-700/60 bg-stone-900/70',
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<i className={clsx('pi text-yellow-500 text-2xl', 'relative top-[2px]', ICONS[ping.type])}></i>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
@@ -253,28 +253,33 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
|
||||
{/*/>*/}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
></Toast>
|
||||
)}
|
||||
></Toast>
|
||||
)}
|
||||
|
||||
<WdButton
|
||||
icon="pi pi-bell"
|
||||
severity="warning"
|
||||
aria-label="Notification"
|
||||
size="small"
|
||||
className="w-[33px] h-[33px]"
|
||||
outlined
|
||||
onClick={handleClickShow}
|
||||
disabled={isShow}
|
||||
/>
|
||||
{ping && (
|
||||
<>
|
||||
<WdButton
|
||||
icon="pi pi-bell"
|
||||
severity="warning"
|
||||
aria-label="Notification"
|
||||
size="small"
|
||||
className="w-[33px] h-[33px]"
|
||||
outlined
|
||||
onClick={handleClickShow}
|
||||
disabled={isShow}
|
||||
/>
|
||||
|
||||
<ConfirmPopup
|
||||
target={cfRef.current}
|
||||
visible={cfVisible}
|
||||
onHide={cfHide}
|
||||
message="Are you sure you want to delete ping?"
|
||||
icon="pi pi-exclamation-triangle text-orange-400"
|
||||
accept={removePing}
|
||||
/>
|
||||
<ConfirmPopup
|
||||
target={cfRef.current}
|
||||
visible={cfVisible}
|
||||
onHide={cfHide}
|
||||
message="Are you sure you want to delete ping?"
|
||||
icon="pi pi-exclamation-triangle text-orange-400"
|
||||
accept={removePing}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from './useCommandComments';
|
||||
export * from './useGetCacheCharacter';
|
||||
export * from './useCommandsActivity';
|
||||
export * from './useCommandPings';
|
||||
export * from './useCommandPingBlocked';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useToast } from '@/hooks/Mapper/ToastProvider';
|
||||
import { CommandPingBlocked } from '@/hooks/Mapper/types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const useCommandPingBlocked = () => {
|
||||
const { show } = useToast();
|
||||
|
||||
const pingBlocked = useCallback(
|
||||
({ message }: CommandPingBlocked) => {
|
||||
show({
|
||||
severity: 'warn',
|
||||
summary: 'Cannot create ping',
|
||||
detail: message,
|
||||
life: 5000,
|
||||
});
|
||||
},
|
||||
[show],
|
||||
);
|
||||
|
||||
return { pingBlocked };
|
||||
};
|
||||
@@ -14,8 +14,8 @@ export const useCommandPings = () => {
|
||||
ref.current.update({ pings });
|
||||
}, []);
|
||||
|
||||
const pingCancelled = useCallback(({ type, id }: CommandPingCancelled) => {
|
||||
const newPings = ref.current.pings.filter(x => x.id !== id && x.type !== type);
|
||||
const pingCancelled = useCallback(({ id }: CommandPingCancelled) => {
|
||||
const newPings = ref.current.pings.filter(x => x.id !== id);
|
||||
ref.current.update({ pings: newPings });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
|
||||
|
||||
const removeComment = useCallback((systemId: number, commentId: string) => {
|
||||
const cSystem = commentBySystemsRef.current.get(systemId);
|
||||
console.log('cSystem', cSystem);
|
||||
if (!cSystem) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommandLinkSignatureToSystem,
|
||||
CommandMapUpdated,
|
||||
CommandPingAdded,
|
||||
CommandPingBlocked,
|
||||
CommandPingCancelled,
|
||||
CommandPresentCharacters,
|
||||
CommandRemoveConnections,
|
||||
@@ -29,6 +30,7 @@ import { ForwardedRef, useImperativeHandle } from 'react';
|
||||
|
||||
import {
|
||||
useCommandComments,
|
||||
useCommandPingBlocked,
|
||||
useCommandPings,
|
||||
useCommandsCharacters,
|
||||
useCommandsConnections,
|
||||
@@ -61,6 +63,7 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
const mapUserRoutes = useUserRoutes();
|
||||
const { addComment, removeComment } = useCommandComments();
|
||||
const { pingAdded, pingCancelled } = useCommandPings();
|
||||
const { pingBlocked } = useCommandPingBlocked();
|
||||
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
@@ -172,6 +175,9 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
case Commands.pingCancelled:
|
||||
pingCancelled(data as CommandPingCancelled);
|
||||
break;
|
||||
case Commands.pingBlocked:
|
||||
pingBlocked(data as CommandPingBlocked);
|
||||
break;
|
||||
default:
|
||||
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
|
||||
break;
|
||||
|
||||
@@ -41,6 +41,7 @@ export enum Commands {
|
||||
refreshTrackingData = 'refresh_tracking_data',
|
||||
pingAdded = 'ping_added',
|
||||
pingCancelled = 'ping_cancelled',
|
||||
pingBlocked = 'ping_blocked',
|
||||
}
|
||||
|
||||
export type Command =
|
||||
@@ -77,7 +78,8 @@ export type Command =
|
||||
| Commands.showTracking
|
||||
| Commands.refreshTrackingData
|
||||
| Commands.pingAdded
|
||||
| Commands.pingCancelled;
|
||||
| Commands.pingCancelled
|
||||
| Commands.pingBlocked;
|
||||
|
||||
export type CommandInit = {
|
||||
systems: SolarSystemRawType[];
|
||||
@@ -161,6 +163,10 @@ export type CommandUpdateTracking = {
|
||||
};
|
||||
export type CommandPingAdded = PingData[];
|
||||
export type CommandPingCancelled = Pick<PingData, 'type' | 'id'>;
|
||||
export type CommandPingBlocked = {
|
||||
reason: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface UserSettings {
|
||||
primaryCharacterId?: string;
|
||||
@@ -212,6 +218,7 @@ export interface CommandData {
|
||||
[Commands.refreshTrackingData]: CommandRefreshTrackingData;
|
||||
[Commands.pingAdded]: CommandPingAdded;
|
||||
[Commands.pingCancelled]: CommandPingCancelled;
|
||||
[Commands.pingBlocked]: CommandPingBlocked;
|
||||
}
|
||||
|
||||
export interface MapHandlers {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
BIN
assets/static/images/news/2026/01-01-roadmap/cover.webp
Normal file
BIN
assets/static/images/news/2026/01-01-roadmap/cover.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp
Normal file
BIN
assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
|
||||
@@ -80,6 +80,10 @@ defmodule WandererApp.Api.MapPing do
|
||||
|
||||
filter(expr(inserted_at <= ^arg(:inserted_before)))
|
||||
end
|
||||
|
||||
# Admin action for cleanup - no actor filtering
|
||||
read :all_pings do
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
||||
@@ -16,7 +16,7 @@ defmodule WandererApp.Map.Manager do
|
||||
@maps_queue :maps_queue
|
||||
@check_maps_queue_interval :timer.seconds(1)
|
||||
|
||||
@pings_cleanup_interval :timer.minutes(10)
|
||||
@pings_cleanup_interval :timer.minutes(5)
|
||||
@pings_expire_minutes 60
|
||||
|
||||
# Test-aware async task runner
|
||||
@@ -99,6 +99,7 @@ defmodule WandererApp.Map.Manager do
|
||||
def handle_info(:cleanup_pings, state) do
|
||||
try do
|
||||
cleanup_expired_pings()
|
||||
cleanup_orphaned_pings()
|
||||
{:noreply, state}
|
||||
rescue
|
||||
e ->
|
||||
@@ -141,6 +142,51 @@ defmodule WandererApp.Map.Manager do
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_orphaned_pings() do
|
||||
case WandererApp.MapPingsRepo.get_orphaned_pings() do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, orphaned_pings} ->
|
||||
Logger.info(
|
||||
"[cleanup_orphaned_pings] Found #{length(orphaned_pings)} orphaned pings, cleaning up..."
|
||||
)
|
||||
|
||||
Enum.each(orphaned_pings, fn %{id: ping_id, map_id: map_id, type: type, system: system} = ping ->
|
||||
reason =
|
||||
cond do
|
||||
is_nil(ping.system) -> "system deleted"
|
||||
is_nil(ping.character) -> "character deleted"
|
||||
is_nil(ping.map) -> "map deleted"
|
||||
not is_nil(system) and system.visible == false -> "system hidden (visible=false)"
|
||||
true -> "unknown"
|
||||
end
|
||||
|
||||
Logger.warning(
|
||||
"[cleanup_orphaned_pings] Destroying orphaned ping #{ping_id} (map_id: #{map_id}, reason: #{reason})"
|
||||
)
|
||||
|
||||
# Broadcast cancellation if map_id is still valid
|
||||
if map_id do
|
||||
Server.Impl.broadcast!(map_id, :ping_cancelled, %{
|
||||
id: ping_id,
|
||||
solar_system_id: nil,
|
||||
type: type
|
||||
})
|
||||
end
|
||||
|
||||
Ash.destroy!(ping)
|
||||
end)
|
||||
|
||||
Logger.info("[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings")
|
||||
:ok
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("Failed to fetch orphaned pings: #{inspect(error)}")
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp start_maps() do
|
||||
chunks =
|
||||
@maps_queue
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -780,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
|
||||
@@ -865,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) ->
|
||||
@@ -898,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
|
||||
|
||||
@@ -72,17 +72,24 @@ defmodule WandererApp.Map.Server.PingsImpl do
|
||||
type: type
|
||||
} = _ping_info
|
||||
) do
|
||||
case WandererApp.MapPingsRepo.get_by_id(ping_id) do
|
||||
|
||||
result = WandererApp.MapPingsRepo.get_by_id(ping_id)
|
||||
|
||||
case result do
|
||||
{:ok,
|
||||
%{system: %{id: system_id, name: system_name, solar_system_id: solar_system_id}} = ping} ->
|
||||
with {:ok, character} <- WandererApp.Character.get_character(character_id),
|
||||
:ok <- WandererApp.MapPingsRepo.destroy(ping) do
|
||||
Logger.debug("Ping #{ping_id} destroyed successfully, broadcasting :ping_cancelled")
|
||||
|
||||
Impl.broadcast!(map_id, :ping_cancelled, %{
|
||||
id: ping_id,
|
||||
solar_system_id: solar_system_id,
|
||||
type: type
|
||||
})
|
||||
|
||||
Logger.debug("Broadcast :ping_cancelled sent for ping #{ping_id}")
|
||||
|
||||
# Broadcast rally point removal events to external clients (webhooks/SSE)
|
||||
if type == 1 do
|
||||
WandererApp.ExternalEvents.broadcast(map_id, :rally_point_removed, %{
|
||||
@@ -107,18 +114,45 @@ defmodule WandererApp.Map.Server.PingsImpl do
|
||||
Logger.error("Failed to destroy ping: #{inspect(error, pretty: true)}")
|
||||
end
|
||||
|
||||
# Handle case where ping exists but system was deleted (nil)
|
||||
{:ok, %{system: nil} = ping} ->
|
||||
case WandererApp.MapPingsRepo.destroy(ping) do
|
||||
:ok ->
|
||||
Impl.broadcast!(map_id, :ping_cancelled, %{
|
||||
id: ping_id,
|
||||
solar_system_id: nil,
|
||||
type: type
|
||||
})
|
||||
|
||||
error ->
|
||||
Logger.error("Failed to destroy orphaned ping: #{inspect(error, pretty: true)}")
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
# Ping already deleted (possibly by cascade deletion from map/system/character removal,
|
||||
# auto-expiry, or concurrent cancellation). This is not an error - the desired state
|
||||
# (ping is gone) is already achieved. Just broadcast the cancellation event.
|
||||
Logger.debug(
|
||||
"Ping #{ping_id} not found during cancellation - already deleted, skipping broadcast"
|
||||
)
|
||||
# auto-expiry, or concurrent cancellation). Broadcast cancellation so frontend updates.
|
||||
Impl.broadcast!(map_id, :ping_cancelled, %{
|
||||
id: ping_id,
|
||||
solar_system_id: nil,
|
||||
type: type
|
||||
})
|
||||
|
||||
:ok
|
||||
|
||||
error ->
|
||||
Logger.error("Failed to fetch ping for cancellation: #{inspect(error, pretty: true)}")
|
||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||
# Same as above, but Ash wraps NotFound inside Invalid in some cases
|
||||
Impl.broadcast!(map_id, :ping_cancelled, %{
|
||||
id: ping_id,
|
||||
solar_system_id: nil,
|
||||
type: type
|
||||
})
|
||||
|
||||
:ok
|
||||
|
||||
other ->
|
||||
Logger.error(
|
||||
"Failed to cancel ping #{ping_id}: unexpected result from get_by_id: #{inspect(other, pretty: true)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -547,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
|
||||
|
||||
|
||||
@@ -29,6 +29,34 @@ defmodule WandererApp.MapPingsRepo do
|
||||
def get_by_inserted_before(inserted_before_date),
|
||||
do: WandererApp.Api.MapPing.by_inserted_before(inserted_before_date)
|
||||
|
||||
@doc """
|
||||
Returns all pings that have orphaned relationships (nil system, character, or map)
|
||||
or where the system has been soft-deleted (visible = false).
|
||||
These pings should be cleaned up as they can no longer be properly displayed or cancelled.
|
||||
"""
|
||||
def get_orphaned_pings() do
|
||||
# Use :all_pings action which has no actor filtering (unlike primary :read)
|
||||
case WandererApp.Api.MapPing |> Ash.Query.for_read(:all_pings) |> Ash.read() do
|
||||
{:ok, pings} ->
|
||||
# Load relationships and filter for orphaned ones
|
||||
orphaned =
|
||||
pings
|
||||
|> Enum.map(fn ping ->
|
||||
{:ok, loaded} = ping |> Ash.load([:system, :character, :map], authorize?: false)
|
||||
loaded
|
||||
end)
|
||||
|> Enum.filter(fn ping ->
|
||||
is_nil(ping.system) or is_nil(ping.character) or is_nil(ping.map) or
|
||||
(not is_nil(ping.system) and ping.system.visible == false)
|
||||
end)
|
||||
|
||||
{:ok, orphaned}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def create(ping), do: ping |> WandererApp.Api.MapPing.new()
|
||||
def create!(ping), do: ping |> WandererApp.Api.MapPing.new!()
|
||||
|
||||
@@ -38,4 +66,24 @@ defmodule WandererApp.MapPingsRepo do
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes all pings for a given map. Use with caution - for cleanup purposes.
|
||||
"""
|
||||
def delete_all_for_map(map_id) do
|
||||
case get_by_map(map_id) do
|
||||
{:ok, pings} ->
|
||||
Logger.info("[MapPingsRepo] Deleting #{length(pings)} pings for map #{map_id}")
|
||||
|
||||
Enum.each(pings, fn ping ->
|
||||
Logger.info("[MapPingsRepo] Deleting ping #{ping.id} (type: #{ping.type})")
|
||||
Ash.destroy!(ping)
|
||||
end)
|
||||
|
||||
{:ok, length(pings)}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
{@post.description}
|
||||
</h4>
|
||||
<!--Post Content-->
|
||||
{raw(@post.body)}
|
||||
<div class="post-content">
|
||||
{raw(@post.body)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--/container-->
|
||||
|
||||
@@ -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">
|
||||
|
||||
273
lib/wanderer_app_web/live/admin/admin_maps_live.ex
Normal file
273
lib/wanderer_app_web/live/admin/admin_maps_live.ex
Normal 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
|
||||
240
lib/wanderer_app_web/live/admin/admin_maps_live.html.heex
Normal file
240
lib/wanderer_app_web/live/admin/admin_maps_live.html.heex
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -51,14 +51,18 @@ defmodule WandererAppWeb.MapPingsEventHandler do
|
||||
map_ui_ping(ping_info)
|
||||
])
|
||||
|
||||
def handle_server_event(%{event: :ping_cancelled, payload: ping_info}, socket),
|
||||
do:
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("ping_cancelled", %{
|
||||
id: ping_info.id,
|
||||
solar_system_id: ping_info.solar_system_id,
|
||||
type: ping_info.type
|
||||
})
|
||||
def handle_server_event(%{event: :ping_cancelled, payload: ping_info}, socket) do
|
||||
Logger.debug(
|
||||
"handle_server_event :ping_cancelled - id: #{ping_info.id}, is_version_valid?: #{inspect(socket.assigns[:is_version_valid?])}"
|
||||
)
|
||||
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("ping_cancelled", %{
|
||||
id: ping_info.id,
|
||||
solar_system_id: ping_info.solar_system_id,
|
||||
type: ping_info.type
|
||||
})
|
||||
end
|
||||
|
||||
def handle_server_event(event, socket),
|
||||
do: MapCoreEventHandler.handle_server_event(event, socket)
|
||||
@@ -81,12 +85,41 @@ defmodule WandererAppWeb.MapPingsEventHandler do
|
||||
when not is_nil(main_character_id) do
|
||||
{:ok, pings} = WandererApp.MapPingsRepo.get_by_map(map_id)
|
||||
|
||||
no_exisiting_pings =
|
||||
# Filter out orphaned pings (system/character deleted or system hidden)
|
||||
# These should not block new ping creation
|
||||
valid_pings =
|
||||
pings
|
||||
|> Enum.filter(fn ping ->
|
||||
not is_nil(ping.system) and not is_nil(ping.character) and
|
||||
(is_nil(ping.system.visible) or ping.system.visible == true)
|
||||
end)
|
||||
|
||||
existing_rally_pings =
|
||||
valid_pings
|
||||
|> Enum.filter(fn %{type: type} ->
|
||||
type == 1
|
||||
end)
|
||||
|> Enum.empty?()
|
||||
|
||||
no_exisiting_pings = Enum.empty?(existing_rally_pings)
|
||||
orphaned_count = length(pings) - length(valid_pings)
|
||||
|
||||
# Log detailed info about existing pings for debugging
|
||||
if length(existing_rally_pings) > 0 do
|
||||
ping_details =
|
||||
existing_rally_pings
|
||||
|> Enum.map(fn p ->
|
||||
"id=#{p.id}, type=#{p.type}, system_id=#{inspect(p.system_id)}, character_id=#{inspect(p.character_id)}, inserted_at=#{p.inserted_at}"
|
||||
end)
|
||||
|> Enum.join("; ")
|
||||
|
||||
Logger.warning(
|
||||
"add_ping BLOCKED: map_id=#{map_id}, existing_rally_pings=#{length(existing_rally_pings)}: [#{ping_details}]"
|
||||
)
|
||||
else
|
||||
Logger.debug(
|
||||
"add_ping check: map_id=#{map_id}, total_pings=#{length(pings)}, valid_pings=#{length(valid_pings)}, orphaned=#{orphaned_count}, rally_pings=0, can_create=true"
|
||||
)
|
||||
end
|
||||
|
||||
if no_exisiting_pings do
|
||||
map_id
|
||||
@@ -97,9 +130,16 @@ defmodule WandererAppWeb.MapPingsEventHandler do
|
||||
character_id: main_character_id,
|
||||
user_id: current_user.id
|
||||
})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
{:noreply, socket}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("ping_blocked", %{
|
||||
reason: "rally_point_exists",
|
||||
message: "A rally point already exists on this map"
|
||||
})}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_ui_event(
|
||||
@@ -128,6 +168,80 @@ defmodule WandererAppWeb.MapPingsEventHandler do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Catch add_ping when main_character_id is nil
|
||||
def handle_ui_event(
|
||||
"add_ping",
|
||||
_event,
|
||||
%{assigns: %{main_character_id: nil}} = socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("ping_blocked", %{
|
||||
reason: "no_main_character",
|
||||
message: "Please select a main character to create pings"
|
||||
})}
|
||||
end
|
||||
|
||||
# Catch add_ping when has_tracked_characters? is false
|
||||
def handle_ui_event(
|
||||
"add_ping",
|
||||
_event,
|
||||
%{assigns: %{has_tracked_characters?: false}} = socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("ping_blocked", %{
|
||||
reason: "no_tracked_characters",
|
||||
message: "Please add a tracked character to create pings"
|
||||
})}
|
||||
end
|
||||
|
||||
# Catch add_ping when subscription is not active
|
||||
def handle_ui_event(
|
||||
"add_ping",
|
||||
_event,
|
||||
%{assigns: %{is_subscription_active?: false}} = socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("ping_blocked", %{
|
||||
reason: "subscription_inactive",
|
||||
message: "Map subscription is not active"
|
||||
})}
|
||||
end
|
||||
|
||||
# Catch add_ping when user doesn't have update_system permission
|
||||
def handle_ui_event(
|
||||
"add_ping",
|
||||
_event,
|
||||
%{assigns: %{user_permissions: %{update_system: false}}} = socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> MapEventHandler.push_map_event("ping_blocked", %{
|
||||
reason: "no_permission",
|
||||
message: "You don't have permission to create pings on this map"
|
||||
})}
|
||||
end
|
||||
|
||||
# Catch cancel_ping failures with feedback
|
||||
def handle_ui_event(
|
||||
"cancel_ping",
|
||||
_event,
|
||||
%{assigns: %{main_character_id: nil}} = socket
|
||||
) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Catch-all for cancel_ping to debug why it doesn't match
|
||||
def handle_ui_event(
|
||||
"cancel_ping",
|
||||
event,
|
||||
%{assigns: assigns} = socket
|
||||
) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_ui_event(event, body, socket),
|
||||
do: MapCoreEventHandler.handle_ui_event(event, body, socket)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
mix.exs
2
mix.exs
@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
|
||||
|
||||
@source_url "https://github.com/wanderer-industries/wanderer"
|
||||
|
||||
@version "1.90.13"
|
||||
@version "1.91.9"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
%{
|
||||
title: "Event: Christmas Giveaway Challenge",
|
||||
author: "Wanderer Team",
|
||||
cover_image_uri: "/images/news/2025/12-18-advent-giveaway/cover.jpg",
|
||||
tags: ~w(event giveaway challenge christmas advent partnership),
|
||||
description: "Join our Advent Christmas Giveaway Challenge! Win exclusive partnership codes every day for a week. Be the fastest to claim your reward!"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
|
||||

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

|
||||
|
||||
### 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.
@@ -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
|
||||
|
||||
187
test/unit/map/acl_scopes_propagation_test.exs
Normal file
187
test/unit/map/acl_scopes_propagation_test.exs
Normal 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
|
||||
@@ -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}
|
||||
}
|
||||
@@ -244,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
|
||||
@@ -368,30 +373,32 @@ defmodule WandererApp.Map.Server.MapScopesTest do
|
||||
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
|
||||
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
|
||||
) ==
|
||||
false
|
||||
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
|
||||
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
|
||||
) ==
|
||||
false
|
||||
true
|
||||
end
|
||||
|
||||
test "K-SPACE ONLY: Low-Sec->Hi-Sec with [:low] is REJECTED (no border for k-space)" do
|
||||
@@ -437,29 +444,169 @@ defmodule WandererApp.Map.Server.MapScopesTest do
|
||||
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
|
||||
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
|
||||
) ==
|
||||
false
|
||||
true
|
||||
|
||||
# Hi-Sec to Low-Sec also rejected (neither matches)
|
||||
# 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
|
||||
) ==
|
||||
false
|
||||
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
|
||||
|
||||
213
test/unit/map/server/signature_connection_cascade_test.exs
Normal file
213
test/unit/map/server/signature_connection_cascade_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user