mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-02-01 03:26:03 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25a3d8951e | ||
|
|
89d7df0ba2 | ||
|
|
ba0c10d2e4 | ||
|
|
996c88d839 | ||
|
|
80e998cf79 | ||
|
|
d2bcb89fa1 | ||
|
|
922f296f17 | ||
|
|
71dc20c933 | ||
|
|
80f7d34d3d | ||
|
|
113fe1c695 | ||
|
|
5550844912 | ||
|
|
0228e68a1d | ||
|
|
3424667af1 | ||
|
|
6c7b28a6c1 | ||
|
|
3988079cd3 | ||
|
|
f5d407fee0 | ||
|
|
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 | ||
|
|
36934cce0b | ||
|
|
b7da7e4ecb | ||
|
|
6471ea5590 | ||
|
|
b46bcac642 | ||
|
|
52d90361e9 | ||
|
|
1c902d3319 | ||
|
|
8f671a359b | ||
|
|
840c416684 | ||
|
|
56e29ad30a | ||
|
|
cd8f8b5801 | ||
|
|
70e013fa3d | ||
|
|
d6bfaf8008 | ||
|
|
95944199a0 | ||
|
|
3bd5db8cf3 | ||
|
|
a245330ada | ||
|
|
1226b6abf3 | ||
|
|
7a1f5c0966 | ||
|
|
e5afa1d5bc | ||
|
|
1473fe8646 | ||
|
|
7039ced11e | ||
|
|
42b5bb337f | ||
|
|
1dbb24f6ec | ||
|
|
c242f510e0 | ||
|
|
c59d51636e | ||
|
|
c5a8aa1b4d | ||
|
|
cba050a9e7 | ||
|
|
59fcbef3b1 | ||
|
|
2f1eb6eeaa | ||
|
|
71ae326cf7 | ||
|
|
07829caf0f | ||
|
|
a5850b5a8d | ||
|
|
9f6849209b | ||
|
|
7bd295cbad | ||
|
|
078e5fc19e | ||
|
|
3877e121c3 | ||
|
|
dcb2a0cdb2 |
@@ -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
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/
|
||||
|
||||
187
CHANGELOG.md
187
CHANGELOG.md
@@ -2,6 +2,193 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v1.92.0](https://github.com/wanderer-industries/wanderer/compare/v1.91.11...v1.92.0) (2026-01-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Added ability to select a range of wh classes for k162.
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: Show c1/c2/c3 or c4/c5 or link signature modal
|
||||
|
||||
## [v1.91.11](https://github.com/wanderer-industries/wanderer/compare/v1.91.10...v1.91.11) (2026-01-13)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* allow sig api when map relay is off
|
||||
|
||||
## [v1.91.10](https://github.com/wanderer-industries/wanderer/compare/v1.91.9...v1.91.10) (2026-01-07)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* remove actor context requirement from sig api
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed scopes
|
||||
|
||||
## [v1.90.6](https://github.com/wanderer-industries/wanderer/compare/v1.90.5...v1.90.6) (2025-12-12)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map scopes
|
||||
|
||||
## [v1.90.5](https://github.com/wanderer-industries/wanderer/compare/v1.90.4...v1.90.5) (2025-12-12)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map scopes
|
||||
|
||||
## [v1.90.4](https://github.com/wanderer-industries/wanderer/compare/v1.90.3...v1.90.4) (2025-12-12)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* core: fixed map scopes & signatures clean up behaviour
|
||||
|
||||
## [v1.90.3](https://github.com/wanderer-industries/wanderer/compare/v1.90.2...v1.90.3) (2025-12-11)
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -8,3 +8,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ContextMenu {
|
||||
width: max-content;
|
||||
min-width: unset;
|
||||
|
||||
:global {
|
||||
.p-submenu-list {
|
||||
width: max-content;
|
||||
min-width: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React, { RefObject, useMemo } from 'react';
|
||||
import React, { RefObject, useCallback, useMemo } from 'react';
|
||||
import { ContextMenu } from 'primereact/contextmenu';
|
||||
import { PrimeIcons } from 'primereact/api';
|
||||
import { MenuItem } from 'primereact/menuitem';
|
||||
import { SolarSystemRawType, SolarSystemStaticInfoRaw } from '@/hooks/Mapper/types';
|
||||
import { CharacterTypeRaw, SolarSystemRawType, SolarSystemStaticInfoRaw } from '@/hooks/Mapper/types';
|
||||
import classes from './ContextMenuSystemInfo.module.scss';
|
||||
import { getSystemById } from '@/hooks/Mapper/helpers';
|
||||
import { useWaypointMenu } from '@/hooks/Mapper/components/contexts/hooks';
|
||||
import { WaypointSetContextHandler } from '@/hooks/Mapper/components/contexts/types.ts';
|
||||
import { FastSystemActions } from '@/hooks/Mapper/components/contexts/components';
|
||||
import { useJumpPlannerMenu } from '@/hooks/Mapper/components/contexts/hooks';
|
||||
import { Route } from '@/hooks/Mapper/types/routes.ts';
|
||||
import { Route, RouteStationSummary } from '@/hooks/Mapper/types/routes.ts';
|
||||
import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace.ts';
|
||||
import { MapAddIcon, MapDeleteIcon } from '@/hooks/Mapper/icons';
|
||||
import { useRouteProvider } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/RoutesProvider.tsx';
|
||||
import { useGetOwnOnlineCharacters } from '@/hooks/Mapper/components/hooks/useGetOwnOnlineCharacters.ts';
|
||||
|
||||
export interface ContextMenuSystemInfoProps {
|
||||
systemStatics: Map<number, SolarSystemStaticInfoRaw>;
|
||||
hubs: string[];
|
||||
contextMenuRef: RefObject<ContextMenu>;
|
||||
systemId: string | undefined;
|
||||
systemIdFrom?: string | undefined;
|
||||
@@ -37,11 +38,104 @@ export const ContextMenuSystemInfo: React.FC<ContextMenuSystemInfoProps> = ({
|
||||
onWaypointSet,
|
||||
systemId,
|
||||
systemIdFrom,
|
||||
hubs,
|
||||
routes,
|
||||
}) => {
|
||||
const getWaypointMenu = useWaypointMenu(onWaypointSet);
|
||||
const getJumpPlannerMenu = useJumpPlannerMenu(systems, systemIdFrom);
|
||||
const { toggleHubCommand, hubs } = useRouteProvider();
|
||||
const getOwnOnlineCharacters = useGetOwnOnlineCharacters();
|
||||
|
||||
const getStationWaypointItems = useCallback(
|
||||
(destinationId: string, chars: CharacterTypeRaw[]): MenuItem[] => [
|
||||
{
|
||||
label: 'Set Destination',
|
||||
icon: PrimeIcons.SEND,
|
||||
command: () => {
|
||||
onWaypointSet({
|
||||
fromBeginning: true,
|
||||
clearWay: true,
|
||||
destination: destinationId,
|
||||
charIds: chars.map(char => char.eve_id),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Add Waypoint',
|
||||
icon: PrimeIcons.DIRECTIONS_ALT,
|
||||
command: () => {
|
||||
onWaypointSet({
|
||||
fromBeginning: false,
|
||||
clearWay: false,
|
||||
destination: destinationId,
|
||||
charIds: chars.map(char => char.eve_id),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Add Waypoint Front',
|
||||
icon: PrimeIcons.DIRECTIONS,
|
||||
command: () => {
|
||||
onWaypointSet({
|
||||
fromBeginning: true,
|
||||
clearWay: false,
|
||||
destination: destinationId,
|
||||
charIds: chars.map(char => char.eve_id),
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
[onWaypointSet],
|
||||
);
|
||||
|
||||
const getStationsMenu = useCallback(
|
||||
(stations: RouteStationSummary[]) => {
|
||||
const chars = getOwnOnlineCharacters().filter(x => x.online);
|
||||
if (chars.length === 0) {
|
||||
return [
|
||||
{
|
||||
label: 'Stations',
|
||||
icon: PrimeIcons.MAP_MARKER,
|
||||
items: [{ label: 'No online characters', disabled: true }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Stations',
|
||||
icon: PrimeIcons.MAP_MARKER,
|
||||
items: stations.map(station => {
|
||||
const destinationId = station.station_id.toString();
|
||||
|
||||
if (chars.length === 1) {
|
||||
return {
|
||||
label: station.station_name,
|
||||
items: getStationWaypointItems(destinationId, chars.slice(0, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: station.station_name,
|
||||
className: 'w-[500px]',
|
||||
items: [
|
||||
{
|
||||
label: 'All',
|
||||
icon: PrimeIcons.USERS,
|
||||
items: getStationWaypointItems(destinationId, chars),
|
||||
},
|
||||
...chars.map(char => ({
|
||||
label: char.name,
|
||||
icon: PrimeIcons.USER,
|
||||
items: getStationWaypointItems(destinationId, [char]),
|
||||
})),
|
||||
],
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
},
|
||||
[getOwnOnlineCharacters, getStationWaypointItems],
|
||||
);
|
||||
|
||||
const items: MenuItem[] = useMemo(() => {
|
||||
const system = systemId ? systemStatics.get(parseInt(systemId)) : undefined;
|
||||
@@ -50,6 +144,10 @@ export const ContextMenuSystemInfo: React.FC<ContextMenuSystemInfoProps> = ({
|
||||
if (!systemId || !system) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const route = routes.find(x => x.destination?.toString() === systemId);
|
||||
const stationItems = route?.stations?.length ? getStationsMenu(route.stations) : [];
|
||||
|
||||
return [
|
||||
{
|
||||
className: classes.FastActions,
|
||||
@@ -69,15 +167,20 @@ export const ContextMenuSystemInfo: React.FC<ContextMenuSystemInfoProps> = ({
|
||||
{ separator: true },
|
||||
...getJumpPlannerMenu(system, routes),
|
||||
...getWaypointMenu(systemId, system.system_class),
|
||||
{
|
||||
label: !hubs.includes(systemId) ? 'Add Route' : 'Remove Route',
|
||||
icon: !hubs.includes(systemId) ? (
|
||||
<MapAddIcon className="mr-1 relative left-[-2px]" />
|
||||
) : (
|
||||
<MapDeleteIcon className="mr-1 relative left-[-2px]" />
|
||||
),
|
||||
command: onHubToggle,
|
||||
},
|
||||
...stationItems,
|
||||
...(toggleHubCommand
|
||||
? [
|
||||
{
|
||||
label: !hubs.includes(systemId) ? 'Add Route' : 'Remove Route',
|
||||
icon: !hubs.includes(systemId) ? (
|
||||
<MapAddIcon className="mr-1 relative left-[-2px]" />
|
||||
) : (
|
||||
<MapDeleteIcon className="mr-1 relative left-[-2px]" />
|
||||
),
|
||||
command: onHubToggle,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!systemOnMap
|
||||
? [
|
||||
{
|
||||
@@ -94,15 +197,18 @@ export const ContextMenuSystemInfo: React.FC<ContextMenuSystemInfoProps> = ({
|
||||
systems,
|
||||
getJumpPlannerMenu,
|
||||
getWaypointMenu,
|
||||
getStationsMenu,
|
||||
hubs,
|
||||
onHubToggle,
|
||||
onAddSystem,
|
||||
onOpenSettings,
|
||||
toggleHubCommand,
|
||||
routes,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu model={items} ref={contextMenuRef} breakpoint="767px" />
|
||||
<ContextMenu className={classes.ContextMenu} model={items} ref={contextMenuRef} breakpoint="767px" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export const useContextMenuSystemInfoHandlers = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.current.toggleHubCommand(system);
|
||||
ref.current.toggleHubCommand?.(system);
|
||||
setSystem(undefined);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useSystemInfo } from '@/hooks/Mapper/components/hooks';
|
||||
import {
|
||||
SOLAR_SYSTEM_CLASS_IDS,
|
||||
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
|
||||
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
|
||||
SOLAR_SYSTEM_CLASS_IDS,
|
||||
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
|
||||
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
|
||||
} from '@/hooks/Mapper/components/map/constants.ts';
|
||||
import { SystemSignaturesContent } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent';
|
||||
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
|
||||
@@ -91,7 +91,7 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
|
||||
|
||||
if (k162TypeInfo) {
|
||||
// Check if the k162Type matches our target system class
|
||||
return customInfo.k162Type === targetSystemClassGroup;
|
||||
return k162TypeInfo.value.includes(targetSystemClassGroup);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SystemStructures,
|
||||
WRoutesPublic,
|
||||
WRoutesUser,
|
||||
WRoutesBy,
|
||||
WSystemKills,
|
||||
} from '@/hooks/Mapper/components/mapInterface/widgets';
|
||||
|
||||
@@ -18,6 +19,7 @@ export enum WidgetsIds {
|
||||
signatures = 'signatures',
|
||||
local = 'local',
|
||||
routes = 'routes',
|
||||
routesBy = 'routesBy',
|
||||
structures = 'structures',
|
||||
kills = 'kills',
|
||||
comments = 'comments',
|
||||
@@ -60,6 +62,13 @@ export const DEFAULT_WIDGETS: WindowProps[] = [
|
||||
zIndex: 0,
|
||||
content: () => <WRoutesPublic />,
|
||||
},
|
||||
{
|
||||
id: WidgetsIds.routesBy,
|
||||
position: { x: 10, y: 740 },
|
||||
size: { width: 510, height: 200 },
|
||||
zIndex: 0,
|
||||
content: () => <WRoutesBy />,
|
||||
},
|
||||
{
|
||||
id: WidgetsIds.userRoutes,
|
||||
position: { x: 10, y: 10 },
|
||||
@@ -112,6 +121,10 @@ export const WIDGETS_CHECKBOXES_PROPS: WidgetsCheckboxesType = [
|
||||
id: WidgetsIds.routes,
|
||||
label: 'Routes',
|
||||
},
|
||||
{
|
||||
id: WidgetsIds.routesBy,
|
||||
label: 'Routes By',
|
||||
},
|
||||
{
|
||||
id: WidgetsIds.userRoutes,
|
||||
label: 'User Routes',
|
||||
|
||||
@@ -86,6 +86,13 @@ export const RoutesWidgetContent = () => {
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
|
||||
// useEffect(() => {
|
||||
// // eslint-disable-next-line no-console
|
||||
// console.log('JOipP', `loading`, loading);
|
||||
// }, [loading]);
|
||||
|
||||
|
||||
if (isRestricted && !isSubscriptionActive) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
@@ -108,6 +115,7 @@ export const RoutesWidgetContent = () => {
|
||||
return <div className="w-full h-full flex justify-center items-center select-none">Routes not set</div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingWrapper loading={loading}>
|
||||
@@ -129,7 +137,6 @@ export const RoutesWidgetContent = () => {
|
||||
offset: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
<SystemView
|
||||
systemId={route.destination.toString()}
|
||||
className={clsx('select-none text-center cursor-context-menu')}
|
||||
@@ -138,7 +145,7 @@ export const RoutesWidgetContent = () => {
|
||||
showCustomName
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right pl-1">{route.has_connection ? route.systems?.length ?? 2 : ''}</div>
|
||||
<div className="text-right pl-1">{route.has_connection ? (route.systems?.length ?? 2) : ''}</div>
|
||||
<div className="pl-2 pb-0.5">
|
||||
<RoutesList data={route} onContextMenu={handleContextMenu} />
|
||||
</div>
|
||||
@@ -147,9 +154,7 @@ export const RoutesWidgetContent = () => {
|
||||
})}
|
||||
</div>
|
||||
</LoadingWrapper>
|
||||
|
||||
<ContextMenuSystemInfo
|
||||
hubs={hubs}
|
||||
routes={preparedRoutes}
|
||||
systems={systems}
|
||||
systemStatics={systemStatics}
|
||||
@@ -162,9 +167,10 @@ export const RoutesWidgetContent = () => {
|
||||
|
||||
type RoutesWidgetCompProps = {
|
||||
title: ReactNode | string;
|
||||
renderContent?: (content: ReactNode, compact: boolean) => ReactNode;
|
||||
};
|
||||
|
||||
export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => {
|
||||
export const RoutesWidgetComp = ({ title, renderContent }: RoutesWidgetCompProps) => {
|
||||
const [routeSettingsVisible, setRouteSettingsVisible] = useState(false);
|
||||
const { data, update, addHubCommand } = useRouteProvider();
|
||||
|
||||
@@ -183,7 +189,7 @@ export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => {
|
||||
const onAddSystem = useCallback(() => setOpenAddSystem(true), []);
|
||||
|
||||
const handleSubmitAddSystem: SearchOnSubmitCallback = useCallback(
|
||||
async item => addHubCommand(item.value.toString()),
|
||||
async item => addHubCommand?.(item.value.toString()),
|
||||
[addHubCommand],
|
||||
);
|
||||
|
||||
@@ -191,15 +197,17 @@ export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => {
|
||||
<Widget
|
||||
label={
|
||||
<div className="flex justify-between items-center text-xs w-full" ref={ref}>
|
||||
<span className="select-none">{title}</span>
|
||||
<div className="select-none flex items-center gap-2">{title}</div>
|
||||
<LayoutEventBlocker className="flex items-center gap-2">
|
||||
<WdImgButton
|
||||
className={PrimeIcons.PLUS_CIRCLE}
|
||||
onClick={onAddSystem}
|
||||
tooltip={{
|
||||
content: 'Click here to add new system to routes',
|
||||
}}
|
||||
/>
|
||||
{addHubCommand && (
|
||||
<WdImgButton
|
||||
className={PrimeIcons.PLUS_CIRCLE}
|
||||
onClick={onAddSystem}
|
||||
tooltip={{
|
||||
content: 'Click here to add new system to routes',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WdTooltipWrapper content="Show shortest route" position={TooltipPosition.top}>
|
||||
<WdCheckbox
|
||||
@@ -223,24 +231,38 @@ export const RoutesWidgetComp = ({ title }: RoutesWidgetCompProps) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RoutesWidgetContent />
|
||||
{renderContent ? (
|
||||
renderContent(
|
||||
<div className="h-full overflow-auto bg-opacity-5 custom-scrollbar">
|
||||
<RoutesWidgetContent />
|
||||
</div>,
|
||||
compact,
|
||||
)
|
||||
) : (
|
||||
<div className="h-full overflow-auto bg-opacity-5 custom-scrollbar">
|
||||
<RoutesWidgetContent />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RoutesSettingsDialog visible={routeSettingsVisible} setVisible={setRouteSettingsVisible} />
|
||||
|
||||
<AddSystemDialog
|
||||
title="Add system to routes"
|
||||
visible={openAddSystem}
|
||||
setVisible={() => setOpenAddSystem(false)}
|
||||
onSubmit={handleSubmitAddSystem}
|
||||
/>
|
||||
{addHubCommand && (
|
||||
<AddSystemDialog
|
||||
title="Add system to routes"
|
||||
visible={openAddSystem}
|
||||
setVisible={() => setOpenAddSystem(false)}
|
||||
onSubmit={handleSubmitAddSystem}
|
||||
/>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoutesWidget = forwardRef<RoutesImperativeHandle, RoutesWidgetProps & RoutesWidgetCompProps>(
|
||||
({ title, ...props }, ref) => {
|
||||
({ title, renderContent, ...props }, ref) => {
|
||||
return (
|
||||
<RoutesProvider {...props} ref={ref}>
|
||||
<RoutesWidgetComp title={title} />
|
||||
<RoutesWidgetComp title={title} renderContent={renderContent} />
|
||||
</RoutesProvider>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './useLoadRoutes';
|
||||
export * from './useLoadRoutesBy';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { RoutesType } from '@/hooks/Mapper/mapRootProvider/types.ts';
|
||||
import { LoadRoutesCommand } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts';
|
||||
import { RoutesList } from '@/hooks/Mapper/types/routes.ts';
|
||||
import { flattenValues } from '@/hooks/Mapper/utils/flattenValues.ts';
|
||||
import { useMapEventListener } from '@/hooks/Mapper/events';
|
||||
import { Commands } from '@/hooks/Mapper/types';
|
||||
|
||||
function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
type UseLoadRoutesByProps = {
|
||||
loadRoutesCommand: LoadRoutesCommand;
|
||||
routesList: RoutesList | undefined;
|
||||
data: RoutesType;
|
||||
deps?: unknown[];
|
||||
};
|
||||
|
||||
export const useLoadRoutesBy = ({
|
||||
data: routesSettings,
|
||||
loadRoutesCommand,
|
||||
routesList,
|
||||
deps = [],
|
||||
}: UseLoadRoutesByProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
data: { selectedSystems },
|
||||
} = useMapRootState();
|
||||
|
||||
const prevSys = usePrevious(selectedSystems);
|
||||
const ref = useRef({ prevSys, selectedSystems });
|
||||
ref.current = { prevSys, selectedSystems };
|
||||
|
||||
const loadRoutes = useCallback(
|
||||
(systemId: string, settings: RoutesType) => {
|
||||
loadRoutesCommand(systemId, settings);
|
||||
setLoading(true);
|
||||
},
|
||||
[loadRoutesCommand],
|
||||
);
|
||||
|
||||
useMapEventListener(event => {
|
||||
if (event.name === Commands.routesListBy) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [routesList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSystems.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [systemId] = selectedSystems;
|
||||
loadRoutes(systemId, routesSettings);
|
||||
}, [loadRoutes, selectedSystems, ...flattenValues(routesSettings), ...deps]);
|
||||
|
||||
return { loading, loadRoutes, setLoading };
|
||||
};
|
||||
@@ -12,8 +12,8 @@ export type RoutesWidgetProps = {
|
||||
routesList: RoutesList | undefined;
|
||||
loading: boolean;
|
||||
|
||||
addHubCommand: AddHubCommand;
|
||||
toggleHubCommand: ToggleHubCommand;
|
||||
addHubCommand?: AddHubCommand;
|
||||
toggleHubCommand?: ToggleHubCommand;
|
||||
isRestricted?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { RoutesWidget } from '@/hooks/Mapper/components/mapInterface/widgets';
|
||||
import { LoadRoutesCommand } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/types.ts';
|
||||
import { useLoadRoutesBy } from '@/hooks/Mapper/components/mapInterface/widgets/RoutesWidget/hooks';
|
||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||
import { OutCommand } from '@/hooks/Mapper/types';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { SelectItemOptionsType } from 'primereact/selectitem';
|
||||
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth.ts';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type RoutesByType = 'blueLoot' | 'redLoot';
|
||||
export type RoutesBySecurityType = 'both' | 'low' | 'high';
|
||||
|
||||
type WRoutesByProps = {
|
||||
type?: RoutesByType;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const ROUTES_BY_OPTIONS: SelectItemOptionsType = [
|
||||
{
|
||||
label: 'Blue Loot',
|
||||
value: 'blueLoot',
|
||||
icon: 'images/30747_64.png',
|
||||
},
|
||||
{
|
||||
label: 'Red Loot',
|
||||
value: 'redLoot',
|
||||
icon: 'images/89219_64.png',
|
||||
},
|
||||
];
|
||||
const ROUTES_BY_SECURITY_OPTIONS = [
|
||||
{ label: 'All', value: 'both' },
|
||||
{ label: 'High', value: 'high' },
|
||||
{ label: 'Low', value: 'low' },
|
||||
];
|
||||
|
||||
export const WRoutesBy = ({ type = 'blueLoot', title = 'Routes By' }: WRoutesByProps) => {
|
||||
const {
|
||||
outCommand,
|
||||
storedSettings: { settingsRoutes, settingsRoutesUpdate },
|
||||
data,
|
||||
} = useMapRootState();
|
||||
|
||||
const [criteriaType, setCriteriaType] = useState<RoutesByType>(type);
|
||||
const [securityType, setSecurityType] = useState<RoutesBySecurityType>('both');
|
||||
const routesListBy = data.routesListBy;
|
||||
|
||||
const loadRoutesCommand: LoadRoutesCommand = useCallback(
|
||||
async (systemId, routesSettings) => {
|
||||
await outCommand({
|
||||
type: OutCommand.getRoutesBy,
|
||||
data: {
|
||||
system_id: systemId,
|
||||
type: criteriaType,
|
||||
securityType: securityType || 'both',
|
||||
routes_settings: routesSettings,
|
||||
},
|
||||
});
|
||||
},
|
||||
[outCommand, criteriaType, securityType],
|
||||
);
|
||||
|
||||
const hubs = useMemo(() => routesListBy?.routes?.map(route => route.destination.toString()) ?? [], [routesListBy]);
|
||||
|
||||
const { loading: internalLoading } = useLoadRoutesBy({
|
||||
data: settingsRoutes,
|
||||
loadRoutesCommand,
|
||||
routesList: routesListBy,
|
||||
deps: [criteriaType, securityType],
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const compactSmall = useMaxWidth(ref, 180);
|
||||
const compactMiddle = useMaxWidth(ref, 245);
|
||||
|
||||
return (
|
||||
<RoutesWidget
|
||||
title={title}
|
||||
renderContent={(content /*, compact*/) => (
|
||||
<div className="h-full grid grid-rows-[1fr_auto]" ref={ref}>
|
||||
{content}
|
||||
<div className="flex items-center gap-2 justify-end mb-2 px-2 pt-2">
|
||||
{!compactSmall && (
|
||||
<Dropdown
|
||||
value={securityType}
|
||||
options={ROUTES_BY_SECURITY_OPTIONS}
|
||||
onChange={e => setSecurityType(e.value)}
|
||||
className="w-[90px] [&_span]:!text-[12px]"
|
||||
/>
|
||||
)}
|
||||
<Dropdown
|
||||
value={criteriaType}
|
||||
itemTemplate={e => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={e.icon} height="18" width="18"></img>
|
||||
<span className="text-[12px]">{e.label}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
valueTemplate={e => {
|
||||
if (compactMiddle) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[50px]">
|
||||
<img src={e.icon} height="18" width="18"></img>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={e.icon} height="18" width="18"></img>
|
||||
<span className="text-[12px]">{e.label}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
options={ROUTES_BY_OPTIONS}
|
||||
onChange={e => setCriteriaType(e.value)}
|
||||
className={clsx({
|
||||
['w-[130px]']: !compactMiddle,
|
||||
['w-[65px]']: compactMiddle,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
data={settingsRoutes}
|
||||
update={settingsRoutesUpdate}
|
||||
hubs={hubs}
|
||||
routesList={routesListBy}
|
||||
loading={internalLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { WRoutesBy } from './WRoutesBy';
|
||||
export type { RoutesByType } from './WRoutesBy';
|
||||
@@ -6,4 +6,5 @@ export * from './SystemStructures';
|
||||
export * from './WSystemKills';
|
||||
export * from './WRoutesUser';
|
||||
export * from './WRoutesPublic';
|
||||
export * from './WRoutesBy';
|
||||
export * from './CommentsWidget';
|
||||
|
||||
@@ -13,6 +13,26 @@ export const renderK162Type = (option: K162Type) => {
|
||||
return renderNoValue();
|
||||
}
|
||||
|
||||
if (['c1_c2_c3', 'c4_c5'].includes(value)) {
|
||||
const arr = whClassName.split('_');
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 items-center">
|
||||
{arr.map(x => (
|
||||
<WHClassView
|
||||
key={x}
|
||||
classNameWh="!text-[11px] !font-bold"
|
||||
hideWhClassName
|
||||
hideTooltip
|
||||
whClassName={x}
|
||||
noOffset
|
||||
useShortTitle
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WHClassView
|
||||
classNameWh="!text-[11px] !font-bold"
|
||||
|
||||
@@ -13,7 +13,7 @@ export type SystemViewProps = {
|
||||
|
||||
export const SystemView = ({ systemId, systemInfo: customSystemInfo, showCustomName, ...rest }: SystemViewProps) => {
|
||||
const memSystems = useMemo(() => [systemId], [systemId]);
|
||||
const { systems, loading } = useLoadSystemStatic({ systems: memSystems });
|
||||
const { systems, lastUpdateKey, loading } = useLoadSystemStatic({ systems: memSystems });
|
||||
|
||||
const {
|
||||
data: { systems: mapSystems },
|
||||
@@ -23,9 +23,10 @@ export const SystemView = ({ systemId, systemInfo: customSystemInfo, showCustomN
|
||||
if (!systemId) {
|
||||
return customSystemInfo;
|
||||
}
|
||||
|
||||
return systems.get(parseInt(systemId));
|
||||
// eslint-disable-next-line
|
||||
}, [customSystemInfo, systemId, systems, loading]);
|
||||
}, [customSystemInfo, systemId, systems, lastUpdateKey, loading]);
|
||||
|
||||
const mapSystemInfo = useMemo(() => {
|
||||
if (!showCustomName) {
|
||||
|
||||
@@ -88,6 +88,16 @@ export const K162_TYPES: K162Type[] = [
|
||||
value: 'ns',
|
||||
whClassName: 'C248',
|
||||
},
|
||||
{
|
||||
label: 'C1/C2/C3',
|
||||
value: 'c1_c2_c3',
|
||||
whClassName: 'E004_D382_L477',
|
||||
},
|
||||
{
|
||||
label: 'C4/C5',
|
||||
value: 'c4_c5',
|
||||
whClassName: 'M001_L614',
|
||||
},
|
||||
{
|
||||
label: 'C1',
|
||||
value: 'c1',
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MapUnionTypes,
|
||||
OutCommandHandler,
|
||||
SolarSystemConnection,
|
||||
StringBoolean,
|
||||
TrackingCharacter,
|
||||
UseCharactersCacheData,
|
||||
UseCommentsData,
|
||||
@@ -76,6 +75,7 @@ const INITIAL_DATA: MapRootData = {
|
||||
userHubs: [],
|
||||
routes: undefined,
|
||||
userRoutes: undefined,
|
||||
routesListBy: undefined,
|
||||
kills: [],
|
||||
connections: [],
|
||||
detailedKills: {},
|
||||
|
||||
@@ -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 });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -112,3 +112,23 @@ export const useUserRoutes = () => {
|
||||
update({ userRoutes: value });
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const useRoutesListBy = () => {
|
||||
const {
|
||||
update,
|
||||
data: { routesListBy },
|
||||
} = useMapRootState();
|
||||
|
||||
const ref = useRef({ update, routesListBy });
|
||||
ref.current = { update, routesListBy };
|
||||
|
||||
return useCallback((value: CommandRoutes) => {
|
||||
const { update, routesListBy } = ref.current;
|
||||
|
||||
if (areRoutesListsEqual(routesListBy, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
update({ routesListBy: value });
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -36,6 +38,7 @@ import {
|
||||
useMapInit,
|
||||
useMapUpdated,
|
||||
useRoutes,
|
||||
useRoutesListBy,
|
||||
useUserRoutes,
|
||||
} from './api';
|
||||
|
||||
@@ -59,8 +62,10 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
const mapUpdated = useMapUpdated();
|
||||
const mapRoutes = useRoutes();
|
||||
const mapUserRoutes = useUserRoutes();
|
||||
const mapRoutesListBy = useRoutesListBy();
|
||||
const { addComment, removeComment } = useCommandComments();
|
||||
const { pingAdded, pingCancelled } = useCommandPings();
|
||||
const { pingBlocked } = useCommandPingBlocked();
|
||||
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
@@ -112,6 +117,9 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
|
||||
case Commands.userRoutes:
|
||||
mapUserRoutes(data as CommandRoutes);
|
||||
break;
|
||||
case Commands.routesListBy:
|
||||
mapRoutesListBy(data as CommandRoutes);
|
||||
break;
|
||||
|
||||
case Commands.signaturesUpdated: // USED
|
||||
updateSystemSignatures(data as CommandSignaturesUpdated);
|
||||
@@ -172,6 +180,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;
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum Commands {
|
||||
detailedKillsUpdated = 'detailed_kills_updated',
|
||||
routes = 'routes',
|
||||
userRoutes = 'user_routes',
|
||||
routesListBy = 'routes_list_by',
|
||||
centerSystem = 'center_system',
|
||||
selectSystem = 'select_system',
|
||||
selectSystems = 'select_systems',
|
||||
@@ -41,6 +42,7 @@ export enum Commands {
|
||||
refreshTrackingData = 'refresh_tracking_data',
|
||||
pingAdded = 'ping_added',
|
||||
pingCancelled = 'ping_cancelled',
|
||||
pingBlocked = 'ping_blocked',
|
||||
}
|
||||
|
||||
export type Command =
|
||||
@@ -61,6 +63,7 @@ export type Command =
|
||||
| Commands.detailedKillsUpdated
|
||||
| Commands.routes
|
||||
| Commands.userRoutes
|
||||
| Commands.routesListBy
|
||||
| Commands.selectSystem
|
||||
| Commands.selectSystems
|
||||
| Commands.centerSystem
|
||||
@@ -77,7 +80,8 @@ export type Command =
|
||||
| Commands.showTracking
|
||||
| Commands.refreshTrackingData
|
||||
| Commands.pingAdded
|
||||
| Commands.pingCancelled;
|
||||
| Commands.pingCancelled
|
||||
| Commands.pingBlocked;
|
||||
|
||||
export type CommandInit = {
|
||||
systems: SolarSystemRawType[];
|
||||
@@ -119,6 +123,7 @@ export type CommandSignaturesUpdated = string;
|
||||
export type CommandMapUpdated = Partial<CommandInit>;
|
||||
export type CommandRoutes = RoutesList;
|
||||
export type CommandUserRoutes = RoutesList;
|
||||
export type CommandRoutesListBy = RoutesList;
|
||||
export type CommandKillsUpdated = Kill[];
|
||||
export type CommandDetailedKillsUpdated = Record<string, DetailedKill[]>;
|
||||
export type CommandSelectSystem = string | undefined;
|
||||
@@ -161,6 +166,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;
|
||||
@@ -193,6 +202,7 @@ export interface CommandData {
|
||||
[Commands.mapUpdated]: CommandMapUpdated;
|
||||
[Commands.routes]: CommandRoutes;
|
||||
[Commands.userRoutes]: CommandUserRoutes;
|
||||
[Commands.routesListBy]: CommandRoutesListBy;
|
||||
[Commands.killsUpdated]: CommandKillsUpdated;
|
||||
[Commands.detailedKillsUpdated]: CommandDetailedKillsUpdated;
|
||||
[Commands.selectSystem]: CommandSelectSystem;
|
||||
@@ -212,6 +222,7 @@ export interface CommandData {
|
||||
[Commands.refreshTrackingData]: CommandRefreshTrackingData;
|
||||
[Commands.pingAdded]: CommandPingAdded;
|
||||
[Commands.pingCancelled]: CommandPingCancelled;
|
||||
[Commands.pingBlocked]: CommandPingBlocked;
|
||||
}
|
||||
|
||||
export interface MapHandlers {
|
||||
@@ -225,6 +236,7 @@ export enum OutCommand {
|
||||
deleteUserHub = 'delete_user_hub',
|
||||
getRoutes = 'get_routes',
|
||||
getUserRoutes = 'get_user_routes',
|
||||
getRoutesBy = 'get_routes_by',
|
||||
getCharacterJumps = 'get_character_jumps',
|
||||
getStructures = 'get_structures',
|
||||
getSignatures = 'get_signatures',
|
||||
|
||||
@@ -20,6 +20,7 @@ export type MapUnionTypes = {
|
||||
systemSignatures: Record<string, SystemSignature[]>;
|
||||
routes?: RoutesList;
|
||||
userRoutes?: RoutesList;
|
||||
routesListBy?: RoutesList;
|
||||
kills: Record<number, number>;
|
||||
connections: SolarSystemConnection[];
|
||||
userPermissions: Partial<UserPermissions>;
|
||||
|
||||
@@ -13,12 +13,18 @@ export type SystemStaticInfoShort = Pick<
|
||||
|
||||
type MappedSystem = SolarSystemStaticInfoRaw | undefined;
|
||||
|
||||
export type RouteStationSummary = {
|
||||
station_id: number;
|
||||
station_name: string;
|
||||
};
|
||||
|
||||
export type Route = {
|
||||
destination: number;
|
||||
has_connection: boolean;
|
||||
origin: number;
|
||||
systems?: number[];
|
||||
mapped_systems?: MappedSystem[];
|
||||
stations?: RouteStationSummary[];
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export default {
|
||||
};
|
||||
|
||||
refreshZone.addEventListener('click', handleUpdate);
|
||||
refreshZone.addEventListener('mouseover', handleUpdate);
|
||||
// refreshZone.addEventListener('mouseover', handleUpdate);
|
||||
|
||||
this.updated();
|
||||
},
|
||||
|
||||
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,10 @@ wanderer_kills_base_url =
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_KILLS_BASE_URL", "ws://wanderer-kills:4004")
|
||||
|
||||
route_builder_base_url =
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_ROUTE_BUILDER_BASE_URL", "http://localhost:2001")
|
||||
|
||||
map_subscriptions_enabled =
|
||||
config_dir
|
||||
|> get_var_from_path_or_env("WANDERER_MAP_SUBSCRIPTIONS_ENABLED", "false")
|
||||
@@ -92,6 +96,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)
|
||||
@@ -149,6 +178,7 @@ config :wanderer_app,
|
||||
character_api_disabled: character_api_disabled,
|
||||
wanderer_kills_service_enabled: wanderer_kills_service_enabled,
|
||||
wanderer_kills_base_url: wanderer_kills_base_url,
|
||||
route_builder_base_url: route_builder_base_url,
|
||||
map_subscriptions_enabled: map_subscriptions_enabled,
|
||||
map_connection_auto_expire_hours: map_connection_auto_expire_hours,
|
||||
map_connection_auto_eol_hours: map_connection_auto_eol_hours,
|
||||
@@ -176,7 +206,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ defmodule WandererApp.Env do
|
||||
def base_url(), do: get_key(:web_app_url, "<BASE_URL>")
|
||||
def base_metrics_only(), do: get_key(:base_metrics_only, false)
|
||||
def custom_route_base_url(), do: get_key(:custom_route_base_url, "<CUSTOM_ROUTE_BASE_URL>")
|
||||
def route_builder_base_url(), do: get_key(:route_builder_base_url, "http://localhost:2001")
|
||||
def invites(), do: get_key(:invites, false)
|
||||
|
||||
def map_subscriptions_enabled?(), do: get_key(:map_subscriptions_enabled, false)
|
||||
@@ -42,6 +43,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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@ defmodule WandererApp.Map do
|
||||
defstruct map_id: nil,
|
||||
name: nil,
|
||||
scope: :none,
|
||||
scopes: nil,
|
||||
owner_id: nil,
|
||||
characters: [],
|
||||
systems: Map.new(),
|
||||
@@ -22,11 +23,18 @@ defmodule WandererApp.Map do
|
||||
characters_limit: nil,
|
||||
hubs_limit: nil
|
||||
|
||||
def new(%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs}) do
|
||||
def new(
|
||||
%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs} =
|
||||
input
|
||||
) do
|
||||
# Extract the new scopes array field if present (nil if not set)
|
||||
scopes = Map.get(input, :scopes)
|
||||
|
||||
map =
|
||||
struct!(__MODULE__,
|
||||
map_id: map_id,
|
||||
scope: scope,
|
||||
scopes: scopes,
|
||||
owner_id: owner_id,
|
||||
name: name,
|
||||
acls: acls,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -78,7 +78,8 @@ defmodule WandererApp.Map.Operations.Signatures do
|
||||
)
|
||||
when is_integer(solar_system_id) do
|
||||
with {:ok, validated_char_uuid} <- validate_character_eve_id(params, char_id),
|
||||
{:ok, system} <- MapSystem.by_map_id_and_solar_system_id(map_id, solar_system_id) do
|
||||
{:ok, system} <-
|
||||
MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: solar_system_id}) do
|
||||
attrs =
|
||||
params
|
||||
|> Map.put("system_id", system.id)
|
||||
|
||||
295
lib/wanderer_app/map/routes_by.ex
Normal file
295
lib/wanderer_app/map/routes_by.ex
Normal file
@@ -0,0 +1,295 @@
|
||||
defmodule WandererApp.Map.RoutesBy do
|
||||
@moduledoc """
|
||||
Routes-by helper that uses the local route builder service.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@minimum_route_attrs [
|
||||
:system_class,
|
||||
:class_title,
|
||||
:security,
|
||||
:triglavian_invasion_status,
|
||||
:solar_system_id,
|
||||
:solar_system_name,
|
||||
:region_name,
|
||||
:is_shattered
|
||||
]
|
||||
|
||||
@default_routes_settings %{
|
||||
path_type: "shortest",
|
||||
include_mass_crit: true,
|
||||
include_eol: false,
|
||||
include_frig: true,
|
||||
include_cruise: true,
|
||||
avoid_wormholes: false,
|
||||
avoid_pochven: false,
|
||||
avoid_edencom: false,
|
||||
avoid_triglavian: false,
|
||||
include_thera: true,
|
||||
avoid: []
|
||||
}
|
||||
|
||||
@zarzakh_system 30_100_000
|
||||
@default_avoid_systems [@zarzakh_system]
|
||||
@get_link_pairs_advanced_params [
|
||||
:include_mass_crit,
|
||||
:include_eol,
|
||||
:include_frig
|
||||
]
|
||||
|
||||
def find(map_id, origin, routes_settings, type) do
|
||||
origin = parse_origin(origin)
|
||||
routes_settings = @default_routes_settings |> Map.merge(routes_settings || %{})
|
||||
|
||||
connections = build_connections(map_id, routes_settings)
|
||||
|
||||
avoidance_list = build_avoidance_list(routes_settings)
|
||||
|
||||
security_type =
|
||||
routes_settings
|
||||
|> Map.get(:security_type, "both")
|
||||
|> normalize_security_type()
|
||||
|
||||
payload = %{
|
||||
origin: origin,
|
||||
flag: routes_settings.path_type,
|
||||
connections: connections,
|
||||
avoid: avoidance_list,
|
||||
count: 40,
|
||||
type: type,
|
||||
security_type: security_type
|
||||
}
|
||||
|
||||
stations_by_system = WandererApp.RouteBuilderClient.stations_for(type)
|
||||
|
||||
case WandererApp.RouteBuilderClient.find_closest(payload) do
|
||||
{:ok, body} ->
|
||||
routes = normalize_routes(body, origin)
|
||||
routes = attach_stations(routes, stations_by_system)
|
||||
systems_static_data = fetch_systems_static_data(routes)
|
||||
{:ok, %{routes: routes, systems_static_data: systems_static_data}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[RoutesBy] Failed to fetch routes by: #{inspect(reason)}")
|
||||
{:ok, %{routes: [], systems_static_data: []}}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_origin(origin) when is_integer(origin), do: origin
|
||||
|
||||
defp parse_origin(origin) when is_binary(origin) do
|
||||
case Integer.parse(origin) do
|
||||
{id, _} -> id
|
||||
:error -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_origin(_), do: 0
|
||||
|
||||
defp normalize_routes(%{"routes" => routes}, origin) when is_list(routes),
|
||||
do: normalize_routes(routes, origin)
|
||||
|
||||
defp normalize_routes(routes, _origin) when is_list(routes) do
|
||||
routes
|
||||
|> Enum.map(&map_route_info/1)
|
||||
|> Enum.filter(fn route_info -> not is_nil(route_info) end)
|
||||
end
|
||||
|
||||
defp normalize_routes(_body, _origin), do: []
|
||||
|
||||
defp attach_stations(routes, stations_by_system) do
|
||||
Enum.map(routes, fn route ->
|
||||
system_key = to_string(route.destination)
|
||||
stations = Map.get(stations_by_system, system_key, [])
|
||||
|
||||
normalized_stations =
|
||||
stations
|
||||
|> Enum.filter(&is_map/1)
|
||||
|> Enum.map(fn station ->
|
||||
%{
|
||||
station_id: Map.get(station, "station_id") || Map.get(station, :station_id),
|
||||
station_name: Map.get(station, "name") || Map.get(station, :name)
|
||||
}
|
||||
end)
|
||||
|> Enum.filter(fn station ->
|
||||
is_integer(station.station_id) and is_binary(station.station_name)
|
||||
end)
|
||||
|
||||
Map.put(route, :stations, normalized_stations)
|
||||
end)
|
||||
end
|
||||
|
||||
defp map_route_info(%{
|
||||
"origin" => origin,
|
||||
"destination" => destination,
|
||||
"systems" => result_systems,
|
||||
"success" => success
|
||||
}) do
|
||||
map_route_info(%{
|
||||
origin: origin,
|
||||
destination: destination,
|
||||
systems: result_systems,
|
||||
success: success
|
||||
})
|
||||
end
|
||||
|
||||
defp map_route_info(
|
||||
%{origin: origin, destination: destination, systems: result_systems, success: success} =
|
||||
_route_info
|
||||
) do
|
||||
systems =
|
||||
case result_systems do
|
||||
[] -> []
|
||||
_ -> result_systems |> Enum.reject(fn system_id -> system_id == origin end)
|
||||
end
|
||||
|
||||
%{
|
||||
has_connection: result_systems != [],
|
||||
systems: systems,
|
||||
origin: origin,
|
||||
destination: destination,
|
||||
success: success
|
||||
}
|
||||
end
|
||||
|
||||
defp map_route_info(_), do: nil
|
||||
|
||||
defp fetch_systems_static_data(routes) do
|
||||
routes
|
||||
|> Enum.map(fn route_info -> route_info.systems end)
|
||||
|> List.flatten()
|
||||
|> Enum.uniq()
|
||||
|> Task.async_stream(
|
||||
fn system_id ->
|
||||
case WandererApp.CachedInfo.get_system_static_info(system_id) do
|
||||
{:ok, nil} -> nil
|
||||
{:ok, system} -> system |> Map.take(@minimum_route_attrs)
|
||||
end
|
||||
end,
|
||||
max_concurrency: System.schedulers_online() * 4
|
||||
)
|
||||
|> Enum.map(fn {:ok, val} -> val end)
|
||||
end
|
||||
|
||||
defp build_avoidance_list(routes_settings) do
|
||||
{:ok, trig_systems} = WandererApp.CachedInfo.get_trig_systems()
|
||||
|
||||
pochven_solar_systems =
|
||||
trig_systems
|
||||
|> Enum.filter(fn s -> s.triglavian_invasion_status == "Final" end)
|
||||
|> Enum.map(& &1.solar_system_id)
|
||||
|
||||
triglavian_solar_systems =
|
||||
trig_systems
|
||||
|> Enum.filter(fn s -> s.triglavian_invasion_status == "Triglavian" end)
|
||||
|> Enum.map(& &1.solar_system_id)
|
||||
|
||||
edencom_solar_systems =
|
||||
trig_systems
|
||||
|> Enum.filter(fn s -> s.triglavian_invasion_status == "Edencom" end)
|
||||
|> Enum.map(& &1.solar_system_id)
|
||||
|
||||
avoidance_list =
|
||||
case routes_settings.avoid_edencom do
|
||||
true -> edencom_solar_systems
|
||||
false -> []
|
||||
end
|
||||
|
||||
avoidance_list =
|
||||
case routes_settings.avoid_triglavian do
|
||||
true -> [avoidance_list | triglavian_solar_systems]
|
||||
false -> avoidance_list
|
||||
end
|
||||
|
||||
avoidance_list =
|
||||
case routes_settings.avoid_pochven do
|
||||
true -> [avoidance_list | pochven_solar_systems]
|
||||
false -> avoidance_list
|
||||
end
|
||||
|
||||
(@default_avoid_systems ++ [routes_settings.avoid | avoidance_list])
|
||||
|> List.flatten()
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp normalize_security_type("high"), do: "high"
|
||||
defp normalize_security_type(:high), do: "high"
|
||||
defp normalize_security_type("low"), do: "low"
|
||||
defp normalize_security_type(:low), do: "low"
|
||||
defp normalize_security_type(_), do: "both"
|
||||
|
||||
defp build_connections(map_id, routes_settings) do
|
||||
if routes_settings.avoid_wormholes do
|
||||
[]
|
||||
else
|
||||
map_chains =
|
||||
routes_settings
|
||||
|> Map.take(@get_link_pairs_advanced_params)
|
||||
|> Map.put_new(:map_id, map_id)
|
||||
|> WandererApp.Api.MapConnection.get_link_pairs_advanced!()
|
||||
|> Enum.map(fn %{
|
||||
solar_system_source: solar_system_source,
|
||||
solar_system_target: solar_system_target
|
||||
} ->
|
||||
%{
|
||||
first: solar_system_source,
|
||||
second: solar_system_target
|
||||
}
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
|
||||
{:ok, thera_chains} =
|
||||
case routes_settings.include_thera do
|
||||
true ->
|
||||
WandererApp.Server.TheraDataFetcher.get_chain_pairs(routes_settings)
|
||||
|
||||
false ->
|
||||
{:ok, []}
|
||||
end
|
||||
|
||||
chains = remove_intersection([map_chains | thera_chains] |> List.flatten())
|
||||
|
||||
chains =
|
||||
case routes_settings.include_cruise do
|
||||
false ->
|
||||
{:ok, wh_class_a_systems} = WandererApp.CachedInfo.get_wh_class_a_systems()
|
||||
|
||||
chains
|
||||
|> Enum.filter(fn x ->
|
||||
not Enum.member?(wh_class_a_systems, x.first) and
|
||||
not Enum.member?(wh_class_a_systems, x.second)
|
||||
end)
|
||||
|
||||
_ ->
|
||||
chains
|
||||
end
|
||||
|
||||
chains
|
||||
|> Enum.map(fn chain ->
|
||||
["#{chain.first}|#{chain.second}", "#{chain.second}|#{chain.first}"]
|
||||
end)
|
||||
|> List.flatten()
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_intersection(pairs_arr) do
|
||||
tuples = pairs_arr |> Enum.map(fn x -> {x.first, x.second} end)
|
||||
|
||||
tuples
|
||||
|> Enum.reduce([], fn {first, second} = x, acc ->
|
||||
if Enum.member?(tuples, {second, first}) do
|
||||
acc
|
||||
else
|
||||
[x | acc]
|
||||
end
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn {first, second} ->
|
||||
%{
|
||||
first: first,
|
||||
second: second
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
_ ->
|
||||
@@ -822,15 +828,25 @@ defmodule WandererApp.Map.Server.CharactersImpl do
|
||||
) do
|
||||
scopes = get_effective_scopes(map)
|
||||
|
||||
ConnectionsImpl.is_connection_valid(
|
||||
scopes,
|
||||
old_location.solar_system_id,
|
||||
location.solar_system_id
|
||||
is_valid =
|
||||
ConnectionsImpl.is_connection_valid(
|
||||
scopes,
|
||||
old_location.solar_system_id,
|
||||
location.solar_system_id
|
||||
)
|
||||
|
||||
Logger.debug(
|
||||
"[CharacterTracking] update_location: map=#{map_id}, " <>
|
||||
"from=#{old_location.solar_system_id}, to=#{location.solar_system_id}, " <>
|
||||
"scopes=#{inspect(scopes)}, map.scopes=#{inspect(map[:scopes])}, " <>
|
||||
"map.scope=#{inspect(map[:scope])}, is_valid=#{is_valid}"
|
||||
)
|
||||
|> case do
|
||||
|
||||
case is_valid do
|
||||
true ->
|
||||
# Add new location system
|
||||
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts) do
|
||||
# Connection is valid (at least one system matches scopes)
|
||||
# Add systems that match the map's scopes - individual system filtering by maybe_add_system
|
||||
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts, scopes) do
|
||||
:ok ->
|
||||
:ok
|
||||
|
||||
@@ -840,8 +856,8 @@ defmodule WandererApp.Map.Server.CharactersImpl do
|
||||
)
|
||||
end
|
||||
|
||||
# Add old location system (in case it wasn't on map)
|
||||
case SystemsImpl.maybe_add_system(map_id, old_location, location, map_opts) do
|
||||
# Add old location system (in case it wasn't on map) - only if it matches scopes
|
||||
case SystemsImpl.maybe_add_system(map_id, old_location, location, map_opts, scopes) do
|
||||
:ok ->
|
||||
:ok
|
||||
|
||||
@@ -881,13 +897,16 @@ defmodule WandererApp.Map.Server.CharactersImpl do
|
||||
defp is_character_in_space?(%{station_id: station_id, structure_id: structure_id} = _location),
|
||||
do: is_nil(structure_id) && is_nil(station_id)
|
||||
|
||||
# Get effective scopes from map, with fallback to legacy scope
|
||||
defp get_effective_scopes(%{scopes: scopes}) when is_list(scopes) and scopes != [], do: scopes
|
||||
@doc """
|
||||
Get effective scopes from map, with fallback to legacy scope.
|
||||
Returns the scopes array that should be used for filtering.
|
||||
"""
|
||||
def get_effective_scopes(%{scopes: scopes}) when is_list(scopes) and scopes != [], do: scopes
|
||||
|
||||
defp get_effective_scopes(%{scope: scope}) when is_atom(scope),
|
||||
def get_effective_scopes(%{scope: scope}) when is_atom(scope),
|
||||
do: legacy_scope_to_scopes(scope)
|
||||
|
||||
defp get_effective_scopes(_), do: [:wormholes]
|
||||
def get_effective_scopes(_), do: [:wormholes]
|
||||
|
||||
# Legacy scope to new scopes array conversion
|
||||
defp legacy_scope_to_scopes(:wormholes), do: [:wormholes]
|
||||
@@ -940,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
|
||||
|
||||
@@ -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
|
||||
@@ -744,15 +769,55 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
|
||||
when is_list(scopes) and from_solar_system_id != to_solar_system_id do
|
||||
with {:ok, from_system_static_info} <- get_system_static_info(from_solar_system_id),
|
||||
{:ok, to_system_static_info} <- get_system_static_info(to_solar_system_id) do
|
||||
# Connection is valid if:
|
||||
# 1. Neither system is prohibited
|
||||
# 2. At least one system matches one of the selected scopes
|
||||
not is_prohibited_system_class?(from_system_static_info.system_class) and
|
||||
not is_prohibited_system_class?(to_system_static_info.system_class) and
|
||||
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
|
||||
not (@prohibited_systems |> Enum.member?(to_solar_system_id)) and
|
||||
(system_matches_any_scope?(from_system_static_info.system_class, scopes) or
|
||||
system_matches_any_scope?(to_system_static_info.system_class, scopes))
|
||||
# First check: neither system is prohibited
|
||||
not_prohibited =
|
||||
not is_prohibited_system_class?(from_system_static_info.system_class) and
|
||||
not is_prohibited_system_class?(to_system_static_info.system_class) and
|
||||
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
|
||||
not (@prohibited_systems |> Enum.member?(to_solar_system_id))
|
||||
|
||||
if not_prohibited do
|
||||
from_is_wormhole = from_system_static_info.system_class in @wh_space
|
||||
to_is_wormhole = to_system_static_info.system_class in @wh_space
|
||||
wormholes_enabled = :wormholes in scopes
|
||||
|
||||
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
|
||||
end
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
@@ -823,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) ->
|
||||
@@ -856,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
|
||||
|
||||
@@ -167,19 +167,26 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|
||||
updated_count: length(updated_ids),
|
||||
removed_count: length(removed_ids)
|
||||
})
|
||||
|
||||
# Always return :ok - external event failures should not affect the main operation
|
||||
:ok
|
||||
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 +197,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,
|
||||
@@ -256,6 +273,37 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|
||||
|
||||
defp maybe_update_connection_mass_status(_map_id, _old_sig, _updated_sig), do: :ok
|
||||
|
||||
@doc """
|
||||
Wrapper for updating a signature's linked_system_id with logging.
|
||||
Logs all unlink operations (when linked_system_id is set to nil) with context
|
||||
to help diagnose unexpected unlinking issues.
|
||||
"""
|
||||
def update_signature_linked_system(signature, %{linked_system_id: nil} = params) do
|
||||
# Log all unlink operations with context for debugging
|
||||
Logger.warning(
|
||||
"[Signature Unlink] eve_id=#{signature.eve_id} " <>
|
||||
"system_id=#{signature.system_id} " <>
|
||||
"old_linked_system_id=#{signature.linked_system_id} " <>
|
||||
"stacktrace=#{format_stacktrace()}"
|
||||
)
|
||||
|
||||
MapSystemSignature.update_linked_system(signature, params)
|
||||
end
|
||||
|
||||
def update_signature_linked_system(signature, params) do
|
||||
MapSystemSignature.update_linked_system(signature, params)
|
||||
end
|
||||
|
||||
defp format_stacktrace do
|
||||
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
|
||||
|
||||
stacktrace
|
||||
|> Enum.take(10)
|
||||
|> Enum.map_join(" <- ", fn {mod, fun, arity, _} ->
|
||||
"#{inspect(mod)}.#{fun}/#{arity}"
|
||||
end)
|
||||
end
|
||||
|
||||
defp track_activity(event, map_id, solar_system_id, user_id, character_id, signatures) do
|
||||
ActivityTracker.track_map_event(event, %{
|
||||
map_id: map_id,
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Map.Server.Impl
|
||||
alias WandererApp.Map.Server.SignaturesImpl
|
||||
|
||||
@ddrt Application.compile_env(:wanderer_app, :ddrt)
|
||||
@system_auto_expire_minutes 15
|
||||
@@ -146,6 +147,30 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
end
|
||||
|
||||
def cleanup_systems(map_id) do
|
||||
# Defensive check: Skip cleanup if cache appears invalid
|
||||
# This prevents incorrectly deleting systems when cache is empty due to
|
||||
# race conditions during map restart or cache corruption
|
||||
case WandererApp.Map.get_map(map_id) do
|
||||
{:error, :not_found} ->
|
||||
Logger.warning(
|
||||
"[cleanup_systems] Skipping map #{map_id} - cache miss detected, " <>
|
||||
"map data not found in cache"
|
||||
)
|
||||
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :map, :cleanup_systems, :cache_miss],
|
||||
%{system_time: System.system_time()},
|
||||
%{map_id: map_id}
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
{:ok, _map} ->
|
||||
do_cleanup_systems(map_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_cleanup_systems(map_id) do
|
||||
expired_systems =
|
||||
map_id
|
||||
|> WandererApp.Map.list_systems!()
|
||||
@@ -403,64 +428,77 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
end)
|
||||
end
|
||||
|
||||
# When destination systems are deleted, unlink signatures instead of destroying them.
|
||||
# This preserves the user's scan data while removing the stale link.
|
||||
defp cleanup_linked_signatures(map_id, removed_solar_system_ids) do
|
||||
removed_solar_system_ids
|
||||
|> Enum.map(fn solar_system_id ->
|
||||
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
|
||||
end)
|
||||
|> List.flatten()
|
||||
|> Enum.uniq_by(& &1.system_id)
|
||||
|> Enum.each(fn s ->
|
||||
try do
|
||||
{:ok, %{eve_id: eve_id, system: system}} = s |> Ash.load([:system])
|
||||
# Group signatures by their source system for efficient broadcasting
|
||||
signatures_by_system =
|
||||
removed_solar_system_ids
|
||||
|> Enum.flat_map(fn solar_system_id ->
|
||||
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
|
||||
end)
|
||||
|> Enum.uniq_by(& &1.id)
|
||||
|> Enum.group_by(fn sig -> sig.system_id end)
|
||||
|
||||
# Use Ash.destroy (not destroy!) to handle already-deleted signatures gracefully
|
||||
case Ash.destroy(s) do
|
||||
:ok ->
|
||||
# Handle case where parent system was already deleted
|
||||
case system do
|
||||
nil ->
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] signature #{eve_id} destroyed (parent system already deleted)"
|
||||
end)
|
||||
signatures_by_system
|
||||
|> Enum.each(fn {_system_id, signatures} ->
|
||||
signatures
|
||||
|> Enum.each(fn sig ->
|
||||
try do
|
||||
{:ok, %{eve_id: eve_id, system: system}} = sig |> Ash.load([:system])
|
||||
|
||||
%{solar_system_id: solar_system_id} ->
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] for system #{solar_system_id}: #{inspect(eve_id)}"
|
||||
end)
|
||||
# Clear the linked_system_id instead of destroying the signature
|
||||
# Use the wrapper to log unlink operations
|
||||
case SignaturesImpl.update_signature_linked_system(sig, %{
|
||||
linked_system_id: nil
|
||||
}) do
|
||||
{:ok, _updated_sig} ->
|
||||
case system do
|
||||
nil ->
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] signature #{eve_id} unlinked (parent system already deleted)"
|
||||
end)
|
||||
|
||||
# Audit logging for cascade deletion (no user/character context)
|
||||
WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{
|
||||
character_id: nil,
|
||||
user_id: nil,
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_id,
|
||||
signatures: [eve_id]
|
||||
})
|
||||
%{solar_system_id: solar_system_id} ->
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] unlinked signature #{eve_id} in system #{solar_system_id}"
|
||||
end)
|
||||
|
||||
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
|
||||
end
|
||||
# Audit logging for cascade unlink (no user/character context)
|
||||
WandererApp.User.ActivityTracker.track_map_event(:signatures_unlinked, %{
|
||||
character_id: nil,
|
||||
user_id: nil,
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_id,
|
||||
signatures: [eve_id]
|
||||
})
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} ->
|
||||
# Check if this is a StaleRecord error (signature already deleted)
|
||||
if Enum.any?(errors, &match?(%Ash.Error.Changes.StaleRecord{}, &1)) do
|
||||
Logger.debug(fn ->
|
||||
"[cleanup_linked_signatures] signature #{eve_id} already deleted (StaleRecord)"
|
||||
end)
|
||||
else
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"[cleanup_linked_signatures] Failed to destroy signature #{eve_id}: #{inspect(errors)}"
|
||||
"[cleanup_linked_signatures] Failed to unlink signature #{sig.eve_id}: #{inspect(error)}"
|
||||
)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"[cleanup_linked_signatures] Failed to destroy signature: #{inspect(error)}"
|
||||
)
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
|
||||
end)
|
||||
|
||||
# Broadcast once per source system after all its signatures are processed
|
||||
case List.first(signatures) do
|
||||
%{system: %{solar_system_id: solar_system_id}} ->
|
||||
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
|
||||
|
||||
_ ->
|
||||
# Try to get the system info if not preloaded
|
||||
case List.first(signatures) |> Ash.load([:system]) do
|
||||
{:ok, %{system: %{solar_system_id: solar_system_id}}} ->
|
||||
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -485,8 +523,62 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
end)
|
||||
end
|
||||
|
||||
def maybe_add_system(map_id, location, old_location, map_opts)
|
||||
def maybe_add_system(map_id, location, old_location, map_opts, scopes \\ nil)
|
||||
|
||||
def maybe_add_system(map_id, location, old_location, map_opts, scopes)
|
||||
when not is_nil(location) do
|
||||
alias WandererApp.Map.Server.ConnectionsImpl
|
||||
|
||||
# Check if the system matches the map's configured scopes before adding
|
||||
should_add =
|
||||
case scopes do
|
||||
nil ->
|
||||
true
|
||||
|
||||
[] ->
|
||||
true
|
||||
|
||||
scopes when is_list(scopes) ->
|
||||
# First check: does the location directly match scopes?
|
||||
if ConnectionsImpl.can_add_location(scopes, location.solar_system_id) do
|
||||
true
|
||||
else
|
||||
# Second check: wormhole border behavior
|
||||
# If :wormholes scope is enabled AND old_location is a wormhole,
|
||||
# allow this system to be added as a border system (so you can see
|
||||
# where your wormhole exits to)
|
||||
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
|
||||
|
||||
if should_add do
|
||||
do_add_system_from_location(map_id, location, old_location, map_opts)
|
||||
else
|
||||
# System filtered out by scope settings - this is expected behavior
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def maybe_add_system(_map_id, _location, _old_location, _map_opts, _scopes), do: :ok
|
||||
|
||||
defp do_add_system_from_location(map_id, location, old_location, map_opts) do
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :map, :system_addition, :start],
|
||||
%{system_time: System.system_time()},
|
||||
@@ -694,8 +786,6 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
end
|
||||
end
|
||||
|
||||
def maybe_add_system(_map_id, _location, _old_location, _map_opts), do: :ok
|
||||
|
||||
defp do_add_system(
|
||||
map_id,
|
||||
%{
|
||||
|
||||
@@ -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
|
||||
|
||||
107
lib/wanderer_app/route_builder_client.ex
Normal file
107
lib/wanderer_app/route_builder_client.ex
Normal file
@@ -0,0 +1,107 @@
|
||||
defmodule WandererApp.RouteBuilderClient do
|
||||
@moduledoc """
|
||||
HTTP client for the local route builder service.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@timeout_opts [pool_timeout: 5_000, receive_timeout: :timer.seconds(30)]
|
||||
@loot_dir Path.join(["repo", "data", "route_by_systems"])
|
||||
|
||||
def find_closest(%{
|
||||
origin: origin,
|
||||
flag: flag,
|
||||
connections: connections,
|
||||
avoid: avoid,
|
||||
count: count,
|
||||
type: type,
|
||||
security_type: security_type
|
||||
}) do
|
||||
url = "#{WandererApp.Env.route_builder_base_url()}/route/findClosest"
|
||||
|
||||
destinations = destinations_for(type, security_type)
|
||||
|
||||
payload = %{
|
||||
origin: origin,
|
||||
flag: flag,
|
||||
connections: connections || [],
|
||||
avoid: avoid || [],
|
||||
destinations: destinations,
|
||||
count: count || 1
|
||||
}
|
||||
|
||||
case Req.post(url, Keyword.merge([json: payload], @timeout_opts)) do
|
||||
{:ok, %{status: status, body: body}} when status in [200, 201] ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
Logger.warning("[RouteBuilderClient] Unexpected status: #{status}")
|
||||
{:error, {:unexpected_status, status, body}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[RouteBuilderClient] Request failed: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp destinations_for(type, security_type) do
|
||||
case load_loot_data(type) do
|
||||
{:ok, %{"system_ids_by_band" => by_band}} ->
|
||||
high = Map.get(by_band, "high", [])
|
||||
low = Map.get(by_band, "low", [])
|
||||
pick_by_band(high, low, security_type)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[RouteBuilderClient] Failed to load loot data: #{inspect(reason)}")
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def stations_for(type) do
|
||||
case load_loot_data(type) do
|
||||
{:ok, %{"system_stations" => system_stations}} when is_map(system_stations) ->
|
||||
system_stations
|
||||
|
||||
{:ok, _} ->
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[RouteBuilderClient] Failed to load loot stations: #{inspect(reason)}")
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp pick_by_band(high, _low, "high"), do: high
|
||||
defp pick_by_band(high, _low, :high), do: high
|
||||
defp pick_by_band(high, _low, "hight"), do: high
|
||||
defp pick_by_band(high, _low, :hight), do: high
|
||||
defp pick_by_band(_high, low, "low"), do: low
|
||||
defp pick_by_band(_high, low, :low), do: low
|
||||
defp pick_by_band(high, low, _), do: high ++ low
|
||||
|
||||
defp load_loot_data("blueLoot"), do: load_loot_file("blueloot.json")
|
||||
defp load_loot_data(:blueLoot), do: load_loot_file("blueloot.json")
|
||||
defp load_loot_data("redLoot"), do: load_loot_file("redloot.json")
|
||||
defp load_loot_data(:redLoot), do: load_loot_file("redloot.json")
|
||||
defp load_loot_data(_), do: load_loot_file("blueloot.json")
|
||||
|
||||
defp load_loot_file(filename) do
|
||||
key = {__MODULE__, :loot_data, filename}
|
||||
|
||||
case :persistent_term.get(key, :missing) do
|
||||
:missing ->
|
||||
path = Path.join([:code.priv_dir(:wanderer_app), @loot_dir, filename])
|
||||
|
||||
with {:ok, body} <- File.read(path),
|
||||
{:ok, json} <- Jason.decode(body) do
|
||||
:persistent_term.put(key, json)
|
||||
{:ok, json}
|
||||
else
|
||||
error -> error
|
||||
end
|
||||
|
||||
cached ->
|
||||
{:ok, cached}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -42,12 +42,18 @@ defmodule WandererAppWeb.AuthController do
|
||||
|
||||
WandererApp.Character.update_character(character.id, character_update)
|
||||
|
||||
# Update corporation/alliance data from ESI to ensure access control is current
|
||||
update_character_affiliation(character)
|
||||
|
||||
{:ok, character}
|
||||
|
||||
{:error, _error} ->
|
||||
{:ok, character} = WandererApp.Api.Character.create(character_data)
|
||||
:telemetry.execute([:wanderer_app, :user, :character, :registered], %{count: 1})
|
||||
|
||||
# Fetch initial corporation/alliance data for new characters
|
||||
update_character_affiliation(character)
|
||||
|
||||
{:ok, character}
|
||||
end
|
||||
|
||||
@@ -113,4 +119,102 @@ defmodule WandererAppWeb.AuthController do
|
||||
end
|
||||
|
||||
def maybe_update_character_user_id(_character, _user_id), do: :ok
|
||||
|
||||
# Updates character's corporation and alliance data from ESI.
|
||||
# This ensures ACL-based access control uses current corporation membership,
|
||||
# even for characters not actively being tracked on any map.
|
||||
defp update_character_affiliation(%{id: character_id, eve_id: eve_id} = character) do
|
||||
# Run async to not block the SSO callback
|
||||
Task.start(fn ->
|
||||
character_eve_id = eve_id |> String.to_integer()
|
||||
|
||||
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
|
||||
{:ok, [affiliation_info]} when is_map(affiliation_info) ->
|
||||
new_corporation_id = Map.get(affiliation_info, "corporation_id")
|
||||
new_alliance_id = Map.get(affiliation_info, "alliance_id")
|
||||
|
||||
# Check if corporation changed
|
||||
corporation_changed = character.corporation_id != new_corporation_id
|
||||
alliance_changed = character.alliance_id != new_alliance_id
|
||||
|
||||
if corporation_changed or alliance_changed do
|
||||
update_affiliation_data(character_id, character, new_corporation_id, new_alliance_id)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"[AuthController] Failed to fetch affiliation for character #{character_id}: #{inspect(error)}"
|
||||
)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_character_affiliation(_character), do: :ok
|
||||
|
||||
defp update_affiliation_data(character_id, character, corporation_id, alliance_id) do
|
||||
# Fetch corporation info
|
||||
corporation_update =
|
||||
case WandererApp.Esi.get_corporation_info(corporation_id) do
|
||||
{:ok, %{"name" => corp_name, "ticker" => corp_ticker}} ->
|
||||
%{
|
||||
corporation_id: corporation_id,
|
||||
corporation_name: corp_name,
|
||||
corporation_ticker: corp_ticker
|
||||
}
|
||||
|
||||
_ ->
|
||||
%{corporation_id: corporation_id}
|
||||
end
|
||||
|
||||
# Fetch alliance info if present
|
||||
alliance_update =
|
||||
case alliance_id do
|
||||
nil ->
|
||||
%{alliance_id: nil, alliance_name: nil, alliance_ticker: nil}
|
||||
|
||||
_ ->
|
||||
case WandererApp.Esi.get_alliance_info(alliance_id) do
|
||||
{:ok, %{"name" => alliance_name, "ticker" => alliance_ticker}} ->
|
||||
%{
|
||||
alliance_id: alliance_id,
|
||||
alliance_name: alliance_name,
|
||||
alliance_ticker: alliance_ticker
|
||||
}
|
||||
|
||||
_ ->
|
||||
%{alliance_id: alliance_id}
|
||||
end
|
||||
end
|
||||
|
||||
full_update = Map.merge(corporation_update, alliance_update)
|
||||
|
||||
# Update database
|
||||
case character.corporation_id != corporation_id do
|
||||
true ->
|
||||
{:ok, _} = WandererApp.Api.Character.update_corporation(character, corporation_update)
|
||||
|
||||
false ->
|
||||
:ok
|
||||
end
|
||||
|
||||
case character.alliance_id != alliance_id do
|
||||
true ->
|
||||
{:ok, _} = WandererApp.Api.Character.update_alliance(character, alliance_update)
|
||||
|
||||
false ->
|
||||
:ok
|
||||
end
|
||||
|
||||
# Update cache
|
||||
WandererApp.Character.update_character(character_id, full_update)
|
||||
|
||||
Logger.info(
|
||||
"[AuthController] Updated affiliation for character #{character_id}: " <>
|
||||
"corp #{character.corporation_id} -> #{corporation_id}, " <>
|
||||
"alliance #{character.alliance_id} -> #{alliance_id}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
{@post.description}
|
||||
</h4>
|
||||
<!--Post Content-->
|
||||
{raw(@post.body)}
|
||||
<div class="post-content">
|
||||
{raw(@post.body)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--/container-->
|
||||
|
||||
27
lib/wanderer_app_web/controllers/route_builder_controller.ex
Normal file
27
lib/wanderer_app_web/controllers/route_builder_controller.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule WandererAppWeb.RouteBuilderController do
|
||||
use WandererAppWeb, :controller
|
||||
|
||||
require Logger
|
||||
|
||||
def find_closest(conn, params) do
|
||||
payload = %{
|
||||
origin: Map.get(params, "origin") || Map.get(params, :origin),
|
||||
flag: Map.get(params, "flag") || Map.get(params, :flag) || "shortest",
|
||||
connections: Map.get(params, "connections") || Map.get(params, :connections) || [],
|
||||
avoid: Map.get(params, "avoid") || Map.get(params, :avoid) || [],
|
||||
count: Map.get(params, "count") || Map.get(params, :count) || 1,
|
||||
type: Map.get(params, "type") || Map.get(params, :type) || "blueLoot"
|
||||
}
|
||||
|
||||
case WandererApp.RouteBuilderClient.find_closest(payload) do
|
||||
{:ok, body} ->
|
||||
json(conn, body)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[RouteBuilderController] find_closest failed: #{inspect(reason)}")
|
||||
conn
|
||||
|> put_status(:bad_gateway)
|
||||
|> json(%{error: "route_builder_failed"})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -43,6 +43,25 @@ defmodule WandererAppWeb.MapRoutesEventHandler do
|
||||
}
|
||||
)
|
||||
|
||||
def handle_server_event(
|
||||
%{
|
||||
event: :routes_list_by,
|
||||
payload: {solar_system_id, %{routes: routes, systems_static_data: systems_static_data}}
|
||||
},
|
||||
socket
|
||||
),
|
||||
do:
|
||||
socket
|
||||
|> MapEventHandler.push_map_event(
|
||||
"routes_list_by",
|
||||
%{
|
||||
solar_system_id: solar_system_id,
|
||||
loading: false,
|
||||
routes: routes,
|
||||
systems_static_data: systems_static_data
|
||||
}
|
||||
)
|
||||
|
||||
def handle_server_event(event, socket),
|
||||
do: MapCoreEventHandler.handle_server_event(event, socket)
|
||||
|
||||
@@ -142,6 +161,33 @@ defmodule WandererAppWeb.MapRoutesEventHandler do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_ui_event(
|
||||
"get_routes_by",
|
||||
%{"system_id" => solar_system_id, "routes_settings" => routes_settings} = event,
|
||||
%{assigns: %{map_id: map_id, map_loaded?: true}} = socket
|
||||
) do
|
||||
routes_type = Map.get(event, "type", "blueLoot")
|
||||
security_type = Map.get(event, "securityType", "both")
|
||||
routes_settings =
|
||||
routes_settings
|
||||
|> get_routes_settings()
|
||||
|> Map.put(:security_type, security_type)
|
||||
|
||||
Task.async(fn ->
|
||||
{:ok, routes} =
|
||||
WandererApp.Map.RoutesBy.find(
|
||||
map_id,
|
||||
solar_system_id,
|
||||
routes_settings,
|
||||
routes_type
|
||||
)
|
||||
|
||||
{:routes_list_by, {solar_system_id, routes}}
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_ui_event(
|
||||
"add_hub",
|
||||
%{"system_id" => solar_system_id} = _event,
|
||||
|
||||
@@ -363,8 +363,8 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
||||
linked_sig_eve_id: nil
|
||||
})
|
||||
|
||||
s
|
||||
|> WandererApp.Api.MapSystemSignature.update_linked_system(%{
|
||||
# Use the wrapper to log unlink operations
|
||||
WandererApp.Map.Server.SignaturesImpl.update_signature_linked_system(s, %{
|
||||
linked_system_id: nil
|
||||
})
|
||||
end)
|
||||
|
||||
@@ -101,11 +101,13 @@ defmodule WandererAppWeb.MapEventHandler do
|
||||
|
||||
@map_routes_events [
|
||||
:routes,
|
||||
:user_routes
|
||||
:user_routes,
|
||||
:routes_list_by
|
||||
]
|
||||
|
||||
@map_routes_ui_events [
|
||||
"get_routes",
|
||||
"get_routes_by",
|
||||
"get_user_routes",
|
||||
"set_autopilot_waypoint",
|
||||
"add_hub",
|
||||
|
||||
@@ -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"])}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: %{}
|
||||
|
||||
|
||||
@@ -341,6 +341,11 @@ defmodule WandererAppWeb.Router do
|
||||
get "/system-static-info", CommonAPIController, :show_system_static
|
||||
end
|
||||
|
||||
scope "/route", WandererAppWeb do
|
||||
pipe_through [:api]
|
||||
post "/findClosest", RouteBuilderController, :find_closest
|
||||
end
|
||||
|
||||
scope "/api" do
|
||||
pipe_through [:api_spec]
|
||||
get "/openapi", OpenApiSpex.Plug.RenderSpec, :show
|
||||
@@ -503,6 +508,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.3"
|
||||
@version "1.92.0"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
||||
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**
|
||||
|
||||
---
|
||||
947
priv/repo/data/route_by_systems/blueloot.json
Normal file
947
priv/repo/data/route_by_systems/blueloot.json
Normal file
@@ -0,0 +1,947 @@
|
||||
{
|
||||
"type_id": 30747,
|
||||
"generated_at": "2026-01-30T13:53:23.834Z",
|
||||
"bands": [
|
||||
"high",
|
||||
"low"
|
||||
],
|
||||
"system_ids_by_band": {
|
||||
"high": [
|
||||
30000055,
|
||||
30000053,
|
||||
30000159,
|
||||
30000160,
|
||||
30000187,
|
||||
30000133,
|
||||
30000181,
|
||||
30001359,
|
||||
30001391,
|
||||
30001395,
|
||||
30001397,
|
||||
30001676,
|
||||
30001679,
|
||||
30001677,
|
||||
30002558,
|
||||
30002569,
|
||||
30002571,
|
||||
30002568,
|
||||
30002572,
|
||||
30002771,
|
||||
30002800,
|
||||
30002815,
|
||||
30002816,
|
||||
30002988,
|
||||
30002992,
|
||||
30002993,
|
||||
30003017,
|
||||
30003018,
|
||||
30003024,
|
||||
30003025,
|
||||
30003029,
|
||||
30003030,
|
||||
30003048,
|
||||
30003053,
|
||||
30003055,
|
||||
30003389,
|
||||
30003394,
|
||||
30003402,
|
||||
30003409,
|
||||
30003412,
|
||||
30003447,
|
||||
30003449,
|
||||
30003469,
|
||||
30003404,
|
||||
30003413,
|
||||
30002191,
|
||||
30002193,
|
||||
30002252,
|
||||
30003553,
|
||||
30003554,
|
||||
30003555,
|
||||
30002190,
|
||||
30002187,
|
||||
30004077,
|
||||
30004078,
|
||||
30004079,
|
||||
30004083,
|
||||
30004084,
|
||||
30004111,
|
||||
30004112,
|
||||
30004114,
|
||||
30005009,
|
||||
30005011,
|
||||
30005017,
|
||||
30005018,
|
||||
30005039,
|
||||
30005040,
|
||||
30005043,
|
||||
30005052,
|
||||
30005054,
|
||||
30005204,
|
||||
30005199
|
||||
],
|
||||
"low": [
|
||||
30000017,
|
||||
30000040,
|
||||
30000041,
|
||||
30000072,
|
||||
30000074,
|
||||
30000162,
|
||||
30000163,
|
||||
30000164,
|
||||
30000196,
|
||||
30000197,
|
||||
30001390,
|
||||
30001361,
|
||||
30002414,
|
||||
30002415,
|
||||
30002418,
|
||||
30002559,
|
||||
30002560,
|
||||
30002769,
|
||||
30002975,
|
||||
30002977,
|
||||
30002980,
|
||||
30002059,
|
||||
30002065,
|
||||
30003467,
|
||||
30002058,
|
||||
30002067,
|
||||
30003556,
|
||||
30004239,
|
||||
30004240,
|
||||
30004241,
|
||||
30004288,
|
||||
30004291,
|
||||
30004296,
|
||||
30005010,
|
||||
30005020,
|
||||
30005030,
|
||||
30005031,
|
||||
30005034,
|
||||
30005035,
|
||||
30005275,
|
||||
30005276
|
||||
]
|
||||
},
|
||||
"system_stations": {
|
||||
"30000017": [
|
||||
{
|
||||
"station_id": 60014071,
|
||||
"name": "Futzchag IX - Moon 9 - Thukker Mix Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60014074,
|
||||
"name": "Futzchag II - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30000040": [
|
||||
{
|
||||
"station_id": 60014095,
|
||||
"name": "Uzistoon VII - Moon 2 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30000041": [
|
||||
{
|
||||
"station_id": 60014098,
|
||||
"name": "Bairshir IV - Moon 11 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30000053": [
|
||||
{
|
||||
"station_id": 60014137,
|
||||
"name": "Ibaria III - Thukker Mix Warehouse"
|
||||
}
|
||||
],
|
||||
"30000055": [
|
||||
{
|
||||
"station_id": 60014140,
|
||||
"name": "Zemalu IX - Moon 2 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30000072": [
|
||||
{
|
||||
"station_id": 60014068,
|
||||
"name": "Nakah I - Moon 1 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30000074": [
|
||||
{
|
||||
"station_id": 60014065,
|
||||
"name": "Hasateem VI - Moon 12 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30000133": [
|
||||
{
|
||||
"station_id": 60001810,
|
||||
"name": "Hirtamon VII - Moon 6 - Zainou Biotech Production"
|
||||
},
|
||||
{
|
||||
"station_id": 60001807,
|
||||
"name": "Hirtamon VII - Moon 5 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30000159": [
|
||||
{
|
||||
"station_id": 60010195,
|
||||
"name": "Ikami X - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30000160": [
|
||||
{
|
||||
"station_id": 60010192,
|
||||
"name": "Reisen VI - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30000162": [
|
||||
{
|
||||
"station_id": 60001801,
|
||||
"name": "Maila IV - Zainou Biotech Production"
|
||||
},
|
||||
{
|
||||
"station_id": 60001804,
|
||||
"name": "Maila VI - Moon 1 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30000163": [
|
||||
{
|
||||
"station_id": 60010198,
|
||||
"name": "Akora IX - Moon 19 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30000164": [
|
||||
{
|
||||
"station_id": 60010201,
|
||||
"name": "Messoya VIII - Moon 6 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30000181": [
|
||||
{
|
||||
"station_id": 60001783,
|
||||
"name": "Korsiki III - Moon 15 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30000187": [
|
||||
{
|
||||
"station_id": 60001786,
|
||||
"name": "Wuos VI - Zainou Biotech Research Center"
|
||||
}
|
||||
],
|
||||
"30000196": [
|
||||
{
|
||||
"station_id": 60001798,
|
||||
"name": "Otosela V - Moon 13 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30000197": [
|
||||
{
|
||||
"station_id": 60001795,
|
||||
"name": "Uemon VIII - Moon 10 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30001359": [
|
||||
{
|
||||
"station_id": 60001780,
|
||||
"name": "Semiki IV - Zainou Biohazard Containment Facility"
|
||||
}
|
||||
],
|
||||
"30001361": [
|
||||
{
|
||||
"station_id": 60001777,
|
||||
"name": "Aurohunen III - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30001390": [
|
||||
{
|
||||
"station_id": 60001816,
|
||||
"name": "Pakkonen IV - Moon 11 - Zainou Biotech Research Center"
|
||||
}
|
||||
],
|
||||
"30001391": [
|
||||
{
|
||||
"station_id": 60001813,
|
||||
"name": "Piekura VIII - Moon 15 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30001395": [
|
||||
{
|
||||
"station_id": 60001768,
|
||||
"name": "Ylandoki II - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30001397": [
|
||||
{
|
||||
"station_id": 60001765,
|
||||
"name": "Isseras IV - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30001676": [
|
||||
{
|
||||
"station_id": 60008527,
|
||||
"name": "Mimen X - Emperor Family Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60008521,
|
||||
"name": "Mimen VIII - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30001677": [
|
||||
{
|
||||
"station_id": 60008518,
|
||||
"name": "Thashkarai VII - Moon 1 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30001679": [
|
||||
{
|
||||
"station_id": 60008524,
|
||||
"name": "Unkah VI - Moon 7 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30002058": [
|
||||
{
|
||||
"station_id": 60014134,
|
||||
"name": "Ardar IV - Moon 2 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30002059": [
|
||||
{
|
||||
"station_id": 60014131,
|
||||
"name": "Auner VIII - Moon 10 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30002065": [
|
||||
{
|
||||
"station_id": 60014143,
|
||||
"name": "Lasleinur IV - Moon 16 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30002067": [
|
||||
{
|
||||
"station_id": 60014146,
|
||||
"name": "Brin V - Moon 7 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30002187": [
|
||||
{
|
||||
"station_id": 60008494,
|
||||
"name": "Amarr VIII (Oris) - Emperor Family Academy"
|
||||
}
|
||||
],
|
||||
"30002190": [
|
||||
{
|
||||
"station_id": 60008500,
|
||||
"name": "Mabnen IV - Moon 1 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30002191": [
|
||||
{
|
||||
"station_id": 60008503,
|
||||
"name": "Toshabia VI - Moon 6 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30002193": [
|
||||
{
|
||||
"station_id": 60008497,
|
||||
"name": "Kehour VIII - Moon 1 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30002252": [
|
||||
{
|
||||
"station_id": 60010240,
|
||||
"name": "Bika III - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010243,
|
||||
"name": "Bika VIII - Moon 11 - CreoDron Warehouse"
|
||||
},
|
||||
{
|
||||
"station_id": 60010246,
|
||||
"name": "Bika V - Moon 1 - CreoDron Warehouse"
|
||||
},
|
||||
{
|
||||
"station_id": 60010249,
|
||||
"name": "Bika VII - Moon 1 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30002414": [
|
||||
{
|
||||
"station_id": 60010183,
|
||||
"name": "Klingt IX - Moon 11 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30002415": [
|
||||
{
|
||||
"station_id": 60010180,
|
||||
"name": "Weld IV - Moon 4 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30002418": [
|
||||
{
|
||||
"station_id": 60010186,
|
||||
"name": "Hegfunden VIII - Moon 14 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010189,
|
||||
"name": "Hegfunden VIII - Moon 26 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30002558": [
|
||||
{
|
||||
"station_id": 60010168,
|
||||
"name": "Endrulf VIII - Moon 1 - CreoDron Warehouse"
|
||||
},
|
||||
{
|
||||
"station_id": 60010171,
|
||||
"name": "Endrulf IV - Moon 1 - CreoDron Warehouse"
|
||||
}
|
||||
],
|
||||
"30002559": [
|
||||
{
|
||||
"station_id": 60010174,
|
||||
"name": "Ingunn V - Moon 21 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30002560": [
|
||||
{
|
||||
"station_id": 60010177,
|
||||
"name": "Gultratren V - Moon 22 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30002568": [
|
||||
{
|
||||
"station_id": 60010297,
|
||||
"name": "Onga X - Moon 11 - CreoDron Warehouse"
|
||||
},
|
||||
{
|
||||
"station_id": 60010291,
|
||||
"name": "Onga VI - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30002569": [
|
||||
{
|
||||
"station_id": 60010294,
|
||||
"name": "Osaumuni VII - Moon 16 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30002571": [
|
||||
{
|
||||
"station_id": 60010288,
|
||||
"name": "Oremmulf IX - Moon 6 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60014110,
|
||||
"name": "Oremmulf V - Moon 20 - Thukker Mix Warehouse"
|
||||
}
|
||||
],
|
||||
"30002572": [
|
||||
{
|
||||
"station_id": 60014107,
|
||||
"name": "Hurjafren VII - Moon 25 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30002769": [
|
||||
{
|
||||
"station_id": 60001819,
|
||||
"name": "Enderailen IV - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30002771": [
|
||||
{
|
||||
"station_id": 60001822,
|
||||
"name": "Kulelen V - Moon 8 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30002800": [
|
||||
{
|
||||
"station_id": 60001792,
|
||||
"name": "Haatomo VI - Moon 6 - Zainou Biotech Production"
|
||||
},
|
||||
{
|
||||
"station_id": 60001789,
|
||||
"name": "Haatomo VII - Moon 7 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30002815": [
|
||||
{
|
||||
"station_id": 60001774,
|
||||
"name": "Isenairos V - Moon 7 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30002816": [
|
||||
{
|
||||
"station_id": 60001771,
|
||||
"name": "Saila VIII - Moon 16 - Zainou Biotech Production"
|
||||
}
|
||||
],
|
||||
"30002975": [
|
||||
{
|
||||
"station_id": 60008551,
|
||||
"name": "Roushzar II - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30002977": [
|
||||
{
|
||||
"station_id": 60008542,
|
||||
"name": "Arayar VII - Moon 16 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30002980": [
|
||||
{
|
||||
"station_id": 60008545,
|
||||
"name": "Sosan II - Emperor Family Academy"
|
||||
},
|
||||
{
|
||||
"station_id": 60008548,
|
||||
"name": "Sosan III - Moon 4 - Emperor Family Academy"
|
||||
}
|
||||
],
|
||||
"30002988": [
|
||||
{
|
||||
"station_id": 60008536,
|
||||
"name": "Nakatre II - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30002992": [
|
||||
{
|
||||
"station_id": 60008530,
|
||||
"name": "Akes V - Moon 2 - Emperor Family Academy"
|
||||
}
|
||||
],
|
||||
"30002993": [
|
||||
{
|
||||
"station_id": 60008533,
|
||||
"name": "Riavayed IX - Moon 2 - Emperor Family Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60008539,
|
||||
"name": "Riavayed II - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30003017": [
|
||||
{
|
||||
"station_id": 60010258,
|
||||
"name": "Harerget VIII - Moon 1 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010252,
|
||||
"name": "Harerget V - Moon 1 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003018": [
|
||||
{
|
||||
"station_id": 60010261,
|
||||
"name": "Azer III - Moon 6 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010255,
|
||||
"name": "Azer VI - Moon 1 - CreoDron Warehouse"
|
||||
}
|
||||
],
|
||||
"30003024": [
|
||||
{
|
||||
"station_id": 60010219,
|
||||
"name": "Marosier IV - Moon 2 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003025": [
|
||||
{
|
||||
"station_id": 60010216,
|
||||
"name": "Lirsautton I - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003029": [
|
||||
{
|
||||
"station_id": 60010222,
|
||||
"name": "Jaschercis IV - Moon 2 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003030": [
|
||||
{
|
||||
"station_id": 60010225,
|
||||
"name": "Ardallabier III - Moon 14 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003048": [
|
||||
{
|
||||
"station_id": 60010120,
|
||||
"name": "Carirgnottin VIII - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003053": [
|
||||
{
|
||||
"station_id": 60010126,
|
||||
"name": "Avele V - Moon 11 - CreoDron Warehouse"
|
||||
}
|
||||
],
|
||||
"30003055": [
|
||||
{
|
||||
"station_id": 60010129,
|
||||
"name": "Aydoteaux II - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010123,
|
||||
"name": "Aydoteaux VIII - Moon 12 - CreoDron Warehouse"
|
||||
}
|
||||
],
|
||||
"30003389": [
|
||||
{
|
||||
"station_id": 60014077,
|
||||
"name": "Altrinur XI - Moon 3 - Thukker Mix Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60014080,
|
||||
"name": "Altrinur XII - Moon 2 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30003394": [
|
||||
{
|
||||
"station_id": 60014125,
|
||||
"name": "Freatlidur V - Moon 4 - Thukker Mix Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60014128,
|
||||
"name": "Freatlidur VII - Moon 3 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30003402": [
|
||||
{
|
||||
"station_id": 60014083,
|
||||
"name": "Totkubad III - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30003404": [
|
||||
{
|
||||
"station_id": 60014086,
|
||||
"name": "Agtver VI - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30003409": [
|
||||
{
|
||||
"station_id": 60014101,
|
||||
"name": "Leurtmar III - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30003412": [
|
||||
{
|
||||
"station_id": 60005719,
|
||||
"name": "Elgoi VI - Moon 1 - Eifyr and Co. Biotech Production"
|
||||
},
|
||||
{
|
||||
"station_id": 60014104,
|
||||
"name": "Elgoi VIII - Moon 19 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30003413": [
|
||||
{
|
||||
"station_id": 60005722,
|
||||
"name": "Eram V - Moon 2 - Eifyr and Co. Biotech Production"
|
||||
}
|
||||
],
|
||||
"30003447": [
|
||||
{
|
||||
"station_id": 60010144,
|
||||
"name": "Josekorn IV - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010150,
|
||||
"name": "Josekorn VIII - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010153,
|
||||
"name": "Josekorn X - Moon 1 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003449": [
|
||||
{
|
||||
"station_id": 60010147,
|
||||
"name": "Hakeri XI - Moon 5 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003467": [
|
||||
{
|
||||
"station_id": 60014116,
|
||||
"name": "Frulegur IX - Moon 5 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30003469": [
|
||||
{
|
||||
"station_id": 60014113,
|
||||
"name": "Hodrold VII - Moon 8 - Thukker Mix Factory"
|
||||
}
|
||||
],
|
||||
"30003553": [
|
||||
{
|
||||
"station_id": 60010141,
|
||||
"name": "Warouh VII - Moon 1 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003554": [
|
||||
{
|
||||
"station_id": 60010132,
|
||||
"name": "Jambu VI - Moon 3 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003555": [
|
||||
{
|
||||
"station_id": 60010138,
|
||||
"name": "Bittanshal VII - Moon 9 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30003556": [
|
||||
{
|
||||
"station_id": 60010135,
|
||||
"name": "Arton II - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30004077": [
|
||||
{
|
||||
"station_id": 60008509,
|
||||
"name": "Hiroudeh VIII - Moon 3 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30004078": [
|
||||
{
|
||||
"station_id": 60008515,
|
||||
"name": "Dresi I - Moon 18 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30004079": [
|
||||
{
|
||||
"station_id": 60008506,
|
||||
"name": "Aphend VII - Moon 4 - Emperor Family Academy"
|
||||
}
|
||||
],
|
||||
"30004083": [
|
||||
{
|
||||
"station_id": 60008512,
|
||||
"name": "Gensela X - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30004084": [
|
||||
{
|
||||
"station_id": 60010228,
|
||||
"name": "Ghesis V - Moon 2 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010231,
|
||||
"name": "Ghesis V - Moon 9 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010234,
|
||||
"name": "Ghesis V - Moon 13 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010237,
|
||||
"name": "Ghesis V - Moon 3 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30004111": [
|
||||
{
|
||||
"station_id": 60010279,
|
||||
"name": "Yarebap VII - Moon 8 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30004112": [
|
||||
{
|
||||
"station_id": 60010276,
|
||||
"name": "Mandoo III - Moon 11 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010282,
|
||||
"name": "Mandoo III - Moon 5 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30004114": [
|
||||
{
|
||||
"station_id": 60010285,
|
||||
"name": "Peyiri XI - Moon 21 - CreoDron Warehouse"
|
||||
}
|
||||
],
|
||||
"30004239": [
|
||||
{
|
||||
"station_id": 60008566,
|
||||
"name": "Kamih VII - Moon 4 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30004240": [
|
||||
{
|
||||
"station_id": 60008569,
|
||||
"name": "Hier IV - Moon 3 - Emperor Family Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60008572,
|
||||
"name": "Hier VII - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30004241": [
|
||||
{
|
||||
"station_id": 60008575,
|
||||
"name": "Jasson I - Moon 4 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30004288": [
|
||||
{
|
||||
"station_id": 60010267,
|
||||
"name": "Ghekon V - Moon 5 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010273,
|
||||
"name": "Ghekon II - Moon 2 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30004291": [
|
||||
{
|
||||
"station_id": 60010270,
|
||||
"name": "Anohel VI - Moon 14 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30004296": [
|
||||
{
|
||||
"station_id": 60010264,
|
||||
"name": "Bapraya IV - Moon 1 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30005009": [
|
||||
{
|
||||
"station_id": 60010159,
|
||||
"name": "Allebin VIII - Moon 4 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30005010": [
|
||||
{
|
||||
"station_id": 60010162,
|
||||
"name": "Atlulle VIII - Moon 6 - CreoDron Factory"
|
||||
},
|
||||
{
|
||||
"station_id": 60010165,
|
||||
"name": "Atlulle III - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30005011": [
|
||||
{
|
||||
"station_id": 60010156,
|
||||
"name": "Droselory VI - Moon 17 - CreoDron Warehouse"
|
||||
}
|
||||
],
|
||||
"30005017": [
|
||||
{
|
||||
"station_id": 60010207,
|
||||
"name": "Yona VI - Moon 5 - CreoDron Factory"
|
||||
}
|
||||
],
|
||||
"30005018": [
|
||||
{
|
||||
"station_id": 60010210,
|
||||
"name": "Noghere VII - Moon 15 - CreoDron Warehouse"
|
||||
},
|
||||
{
|
||||
"station_id": 60010213,
|
||||
"name": "Noghere VIII - Moon 18 - CreoDron Warehouse"
|
||||
}
|
||||
],
|
||||
"30005020": [
|
||||
{
|
||||
"station_id": 60010204,
|
||||
"name": "Seyllin VIII - Moon 14 - CreoDron Warehouse"
|
||||
}
|
||||
],
|
||||
"30005030": [
|
||||
{
|
||||
"station_id": 60008578,
|
||||
"name": "Fensi V - Moon 1 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30005031": [
|
||||
{
|
||||
"station_id": 60008584,
|
||||
"name": "Nebian VIII - Moon 4 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30005034": [
|
||||
{
|
||||
"station_id": 60008581,
|
||||
"name": "Bridi II - Moon 1 - Emperor Family Academy"
|
||||
}
|
||||
],
|
||||
"30005035": [
|
||||
{
|
||||
"station_id": 60008587,
|
||||
"name": "Ami XI - Moon 1 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30005039": [
|
||||
{
|
||||
"station_id": 60008611,
|
||||
"name": "Leva II - Emperor Family Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60008602,
|
||||
"name": "Leva XI - Moon 8 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30005040": [
|
||||
{
|
||||
"station_id": 60008608,
|
||||
"name": "Nishah VII - Moon 5 - Emperor Family Treasury"
|
||||
}
|
||||
],
|
||||
"30005043": [
|
||||
{
|
||||
"station_id": 60008605,
|
||||
"name": "Nakregde VII - Moon 1 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30005052": [
|
||||
{
|
||||
"station_id": 60008554,
|
||||
"name": "Soumi V - Moon 4 - Emperor Family Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60008557,
|
||||
"name": "Soumi I - Moon 1 - Emperor Family Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60008563,
|
||||
"name": "Soumi VII - Moon 1 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30005054": [
|
||||
{
|
||||
"station_id": 60008560,
|
||||
"name": "Nare VI - Moon 16 - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30005199": [
|
||||
{
|
||||
"station_id": 60012739,
|
||||
"name": "Tar III - Secure Commerce Commission Depository"
|
||||
}
|
||||
],
|
||||
"30005204": [
|
||||
{
|
||||
"station_id": 60012736,
|
||||
"name": "Yulai III - Moon 1 - Secure Commerce Commission Depository"
|
||||
}
|
||||
],
|
||||
"30005275": [
|
||||
{
|
||||
"station_id": 60008596,
|
||||
"name": "Azedi III - Emperor Family Bureau"
|
||||
}
|
||||
],
|
||||
"30005276": [
|
||||
{
|
||||
"station_id": 60008599,
|
||||
"name": "Sharza VII - Moon 3 - Emperor Family Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60008590,
|
||||
"name": "Sharza VII - Moon 5 - Emperor Family Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60008593,
|
||||
"name": "Sharza VI - Moon 4 - Emperor Family Bureau"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
839
priv/repo/data/route_by_systems/redloot.json
Normal file
839
priv/repo/data/route_by_systems/redloot.json
Normal file
@@ -0,0 +1,839 @@
|
||||
{
|
||||
"type_id": 89219,
|
||||
"generated_at": "2026-01-30T13:54:13.047Z",
|
||||
"bands": [
|
||||
"high",
|
||||
"low"
|
||||
],
|
||||
"system_ids_by_band": {
|
||||
"high": [
|
||||
30000009,
|
||||
30000010,
|
||||
30000024,
|
||||
30000025,
|
||||
30000030,
|
||||
30000051,
|
||||
30000052,
|
||||
30000084,
|
||||
30000087,
|
||||
30000201,
|
||||
30000202,
|
||||
30001380,
|
||||
30001384,
|
||||
30001644,
|
||||
30001646,
|
||||
30001669,
|
||||
30001671,
|
||||
30001674,
|
||||
30001689,
|
||||
30001690,
|
||||
30001693,
|
||||
30002724,
|
||||
30002762,
|
||||
30002763,
|
||||
30002764,
|
||||
30002766,
|
||||
30003058,
|
||||
30003374,
|
||||
30003375,
|
||||
30003376,
|
||||
30003378,
|
||||
30003428,
|
||||
30003429,
|
||||
30003430,
|
||||
30003431,
|
||||
30003432,
|
||||
30002242,
|
||||
30002259,
|
||||
30002260,
|
||||
30002262,
|
||||
30003859,
|
||||
30003860,
|
||||
30003861,
|
||||
30003862,
|
||||
30004248,
|
||||
30004249,
|
||||
30004250,
|
||||
30004251,
|
||||
30004253,
|
||||
30005069,
|
||||
30005078,
|
||||
30005198,
|
||||
30005199,
|
||||
30005200,
|
||||
30005204,
|
||||
30005205,
|
||||
30005206,
|
||||
30005315,
|
||||
30005319,
|
||||
30005322,
|
||||
30005323
|
||||
],
|
||||
"low": [
|
||||
30000012,
|
||||
30000014,
|
||||
30000015,
|
||||
30000205,
|
||||
30001385,
|
||||
30002402,
|
||||
30002404,
|
||||
30002406,
|
||||
30002407,
|
||||
30002414,
|
||||
30002419,
|
||||
30002420,
|
||||
30002725,
|
||||
30002726,
|
||||
30002728,
|
||||
30002730,
|
||||
30003057,
|
||||
30003059,
|
||||
30003061,
|
||||
30002060,
|
||||
30002062,
|
||||
30002065,
|
||||
30002246,
|
||||
30002249,
|
||||
30003818,
|
||||
30003819,
|
||||
30004280,
|
||||
30004281,
|
||||
30004284,
|
||||
30005079,
|
||||
30005080,
|
||||
30005328
|
||||
]
|
||||
},
|
||||
"system_stations": {
|
||||
"30000009": [
|
||||
{
|
||||
"station_id": 60012295,
|
||||
"name": "Sooma X - CONCORD Academy"
|
||||
}
|
||||
],
|
||||
"30000010": [
|
||||
{
|
||||
"station_id": 60012304,
|
||||
"name": "Chidah V - CONCORD Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012307,
|
||||
"name": "Chidah VIII - Moon 17 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000012": [
|
||||
{
|
||||
"station_id": 60012292,
|
||||
"name": "Asabona IX - Moon 5 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30000014": [
|
||||
{
|
||||
"station_id": 60012298,
|
||||
"name": "Shamahi IX - Moon 12 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30000015": [
|
||||
{
|
||||
"station_id": 60012301,
|
||||
"name": "Sendaya V - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30000024": [
|
||||
{
|
||||
"station_id": 60013027,
|
||||
"name": "Kiereend VII - Moon 3 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000025": [
|
||||
{
|
||||
"station_id": 60013030,
|
||||
"name": "Rashy VI - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000030": [
|
||||
{
|
||||
"station_id": 60013024,
|
||||
"name": "Kasrasi IX - Moon 7 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000051": [
|
||||
{
|
||||
"station_id": 60012949,
|
||||
"name": "Juddi VII - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30000052": [
|
||||
{
|
||||
"station_id": 60012943,
|
||||
"name": "Maspah V - Moon 6 - DED Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012946,
|
||||
"name": "Maspah IV - Moon 7 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000084": [
|
||||
{
|
||||
"station_id": 60013012,
|
||||
"name": "Asghatil IX - Moon 3 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000087": [
|
||||
{
|
||||
"station_id": 60013006,
|
||||
"name": "Gelhan V - Moon 10 - DED Logistic Support"
|
||||
},
|
||||
{
|
||||
"station_id": 60013009,
|
||||
"name": "Gelhan V - Moon 1 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000201": [
|
||||
{
|
||||
"station_id": 60012313,
|
||||
"name": "Uchoshi I - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012322,
|
||||
"name": "Uchoshi IX - Moon 2 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000202": [
|
||||
{
|
||||
"station_id": 60012325,
|
||||
"name": "Mastakomon IX - Moon 2 - CONCORD Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60013042,
|
||||
"name": "Mastakomon IX - Moon 3 - DED Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012310,
|
||||
"name": "Mastakomon V - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012316,
|
||||
"name": "Mastakomon IX - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012319,
|
||||
"name": "Mastakomon VIII - Moon 1 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60013048,
|
||||
"name": "Mastakomon XI - Moon 2 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30000205": [
|
||||
{
|
||||
"station_id": 60013045,
|
||||
"name": "Obe VI - Moon 2 - DED Testing Facilities"
|
||||
}
|
||||
],
|
||||
"30001380": [
|
||||
{
|
||||
"station_id": 60012328,
|
||||
"name": "Vellaine V - Moon 2 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30001384": [
|
||||
{
|
||||
"station_id": 60012331,
|
||||
"name": "Autaris VIII - Moon 5 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012340,
|
||||
"name": "Autaris IV - CONCORD Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012343,
|
||||
"name": "Autaris I - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30001385": [
|
||||
{
|
||||
"station_id": 60012334,
|
||||
"name": "Jan VI - Moon 21 - CONCORD Academy"
|
||||
}
|
||||
],
|
||||
"30001644": [
|
||||
{
|
||||
"station_id": 60013033,
|
||||
"name": "Tividu IV - Moon 10 - DED Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60013036,
|
||||
"name": "Tividu IV - Moon 3 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30001646": [
|
||||
{
|
||||
"station_id": 60013039,
|
||||
"name": "Goram VII - Moon 4 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30001669": [
|
||||
{
|
||||
"station_id": 60012982,
|
||||
"name": "Pimebeka VII - Moon 13 - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30001671": [
|
||||
{
|
||||
"station_id": 60012985,
|
||||
"name": "Tash-Murkon Prime III - Moon 1 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30001674": [
|
||||
{
|
||||
"station_id": 60012979,
|
||||
"name": "Hilaban II - Moon 5 - DED Testing Facilities"
|
||||
}
|
||||
],
|
||||
"30001689": [
|
||||
{
|
||||
"station_id": 60012964,
|
||||
"name": "Asesamy VI - Moon 8 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30001690": [
|
||||
{
|
||||
"station_id": 60012967,
|
||||
"name": "Hostni VII - Moon 18 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30001693": [
|
||||
{
|
||||
"station_id": 60012961,
|
||||
"name": "Perdan VI - Moon 16 - DED Testing Facilities"
|
||||
}
|
||||
],
|
||||
"30002060": [
|
||||
{
|
||||
"station_id": 60012970,
|
||||
"name": "Evati IX - Moon 1 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30002062": [
|
||||
{
|
||||
"station_id": 60012976,
|
||||
"name": "Todifrauan VII - Moon 8 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30002065": [
|
||||
{
|
||||
"station_id": 60012973,
|
||||
"name": "Lasleinur VI - Moon 17 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30002242": [
|
||||
{
|
||||
"station_id": 60012937,
|
||||
"name": "Mamenkhanar IX - Moon 11 - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30002246": [
|
||||
{
|
||||
"station_id": 60012940,
|
||||
"name": "Neziel I - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30002249": [
|
||||
{
|
||||
"station_id": 60012934,
|
||||
"name": "Ruchy I - DED Testing Facilities"
|
||||
}
|
||||
],
|
||||
"30002259": [
|
||||
{
|
||||
"station_id": 60012958,
|
||||
"name": "Sahdil III - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30002260": [
|
||||
{
|
||||
"station_id": 60012952,
|
||||
"name": "Esteban VIII - Moon 4 - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30002262": [
|
||||
{
|
||||
"station_id": 60012955,
|
||||
"name": "Nalu VII - Moon 7 - DED Testing Facilities"
|
||||
}
|
||||
],
|
||||
"30002402": [
|
||||
{
|
||||
"station_id": 60012457,
|
||||
"name": "Istodard IX - Moon 5 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012460,
|
||||
"name": "Istodard IX - Moon 16 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30002404": [
|
||||
{
|
||||
"station_id": 60012469,
|
||||
"name": "Half VII - Moon 4 - CONCORD Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012454,
|
||||
"name": "Half VII - Moon 1 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30002406": [
|
||||
{
|
||||
"station_id": 60012463,
|
||||
"name": "Hedaleolfarber VII - Moon 17 - CONCORD Treasury"
|
||||
}
|
||||
],
|
||||
"30002407": [
|
||||
{
|
||||
"station_id": 60012466,
|
||||
"name": "Altbrard IX - Moon 8 - CONCORD Testing Facilities"
|
||||
}
|
||||
],
|
||||
"30002414": [
|
||||
{
|
||||
"station_id": 60012430,
|
||||
"name": "Klingt VIII - CONCORD Logistic Support"
|
||||
},
|
||||
{
|
||||
"station_id": 60012433,
|
||||
"name": "Klingt IX - CONCORD Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012418,
|
||||
"name": "Klingt III - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30002419": [
|
||||
{
|
||||
"station_id": 60012421,
|
||||
"name": "Aeditide V - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30002420": [
|
||||
{
|
||||
"station_id": 60012424,
|
||||
"name": "Egbinger XII - CONCORD Academy"
|
||||
},
|
||||
{
|
||||
"station_id": 60012427,
|
||||
"name": "Egbinger V - CONCORD Treasury"
|
||||
}
|
||||
],
|
||||
"30002724": [
|
||||
{
|
||||
"station_id": 60012502,
|
||||
"name": "Assiettes IV - Moon 1 - CONCORD Logistic Support"
|
||||
}
|
||||
],
|
||||
"30002725": [
|
||||
{
|
||||
"station_id": 60012499,
|
||||
"name": "Goinard III - Moon 2 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012490,
|
||||
"name": "Goinard IV - Moon 2 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30002726": [
|
||||
{
|
||||
"station_id": 60012505,
|
||||
"name": "Raeghoscon VIII - CONCORD Logistic Support"
|
||||
}
|
||||
],
|
||||
"30002728": [
|
||||
{
|
||||
"station_id": 60012496,
|
||||
"name": "Lermireve VIII - Moon 15 - CONCORD Treasury"
|
||||
}
|
||||
],
|
||||
"30002730": [
|
||||
{
|
||||
"station_id": 60012493,
|
||||
"name": "Esmes IV - Moon 2 - CONCORD Treasury"
|
||||
}
|
||||
],
|
||||
"30002762": [
|
||||
{
|
||||
"station_id": 60012511,
|
||||
"name": "Yashunen VII - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012514,
|
||||
"name": "Yashunen VII - Moon 2 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30002763": [
|
||||
{
|
||||
"station_id": 60012523,
|
||||
"name": "Tennen VIII - Moon 4 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30002764": [
|
||||
{
|
||||
"station_id": 60012517,
|
||||
"name": "Hatakani VI - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012508,
|
||||
"name": "Hatakani VI - Moon 10 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30002766": [
|
||||
{
|
||||
"station_id": 60012520,
|
||||
"name": "Iivinen VIII - Moon 10 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30003057": [
|
||||
{
|
||||
"station_id": 60012373,
|
||||
"name": "Groothese X - Moon 13 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30003058": [
|
||||
{
|
||||
"station_id": 60012367,
|
||||
"name": "Olide VI - Moon 10 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012370,
|
||||
"name": "Olide VI - Moon 14 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012379,
|
||||
"name": "Olide IV - Moon 7 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30003059": [
|
||||
{
|
||||
"station_id": 60012364,
|
||||
"name": "Adeel VIII - Moon 1 - CONCORD Treasury"
|
||||
}
|
||||
],
|
||||
"30003061": [
|
||||
{
|
||||
"station_id": 60012376,
|
||||
"name": "Mormelot I - CONCORD Testing Facilities"
|
||||
}
|
||||
],
|
||||
"30003374": [
|
||||
{
|
||||
"station_id": 60012355,
|
||||
"name": "Arlulf III - Moon 10 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012358,
|
||||
"name": "Arlulf VI - Moon 1 - CONCORD Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012361,
|
||||
"name": "Arlulf III - Moon 11 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30003375": [
|
||||
{
|
||||
"station_id": 60012346,
|
||||
"name": "Brundakur IV - Moon 1 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30003376": [
|
||||
{
|
||||
"station_id": 60012349,
|
||||
"name": "Stirht VII - Moon 14 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30003378": [
|
||||
{
|
||||
"station_id": 60012352,
|
||||
"name": "Nedegulf VII - Moon 4 - CONCORD Academy"
|
||||
}
|
||||
],
|
||||
"30003428": [
|
||||
{
|
||||
"station_id": 60012286,
|
||||
"name": "Hilfhurmur VIII - Moon 6 - CONCORD Logistic Support"
|
||||
}
|
||||
],
|
||||
"30003429": [
|
||||
{
|
||||
"station_id": 60012277,
|
||||
"name": "Geffur VII - Moon 8 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30003430": [
|
||||
{
|
||||
"station_id": 60012289,
|
||||
"name": "Oppold III - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30003431": [
|
||||
{
|
||||
"station_id": 60012280,
|
||||
"name": "Tratokard II - Moon 1 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30003432": [
|
||||
{
|
||||
"station_id": 60012283,
|
||||
"name": "Lumegen III - CONCORD Academy"
|
||||
},
|
||||
{
|
||||
"station_id": 60012274,
|
||||
"name": "Lumegen IV - Moon 2 - CONCORD Academy"
|
||||
}
|
||||
],
|
||||
"30003818": [
|
||||
{
|
||||
"station_id": 60012439,
|
||||
"name": "Aulbres X - Moon 2 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012442,
|
||||
"name": "Aulbres VII - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012445,
|
||||
"name": "Aulbres X - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012448,
|
||||
"name": "Aulbres VII - Moon 16 - CONCORD Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012451,
|
||||
"name": "Aulbres V - Moon 4 - CONCORD Logistic Support"
|
||||
}
|
||||
],
|
||||
"30003819": [
|
||||
{
|
||||
"station_id": 60012436,
|
||||
"name": "Barleguet IV - Moon 2 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30003859": [
|
||||
{
|
||||
"station_id": 60012403,
|
||||
"name": "Neyi VII - Moon 7 - CONCORD Academy"
|
||||
}
|
||||
],
|
||||
"30003860": [
|
||||
{
|
||||
"station_id": 60012406,
|
||||
"name": "Kihtaled VIII - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012409,
|
||||
"name": "Kihtaled VIII - Moon 17 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30003861": [
|
||||
{
|
||||
"station_id": 60012400,
|
||||
"name": "Ipref IV - Moon 5 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012415,
|
||||
"name": "Ipref II - Moon 1 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30003862": [
|
||||
{
|
||||
"station_id": 60012412,
|
||||
"name": "Agil VI - Moon 2 - CONCORD Logistic Support"
|
||||
}
|
||||
],
|
||||
"30004248": [
|
||||
{
|
||||
"station_id": 60012382,
|
||||
"name": "Haimeh IX - Moon 16 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30004249": [
|
||||
{
|
||||
"station_id": 60012394,
|
||||
"name": "Avada V - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30004250": [
|
||||
{
|
||||
"station_id": 60012391,
|
||||
"name": "Chibi VI - Moon 15 - CONCORD Treasury"
|
||||
}
|
||||
],
|
||||
"30004251": [
|
||||
{
|
||||
"station_id": 60012397,
|
||||
"name": "Mishi VIII - CONCORD Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012385,
|
||||
"name": "Mishi VII - Moon 4 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30004253": [
|
||||
{
|
||||
"station_id": 60012388,
|
||||
"name": "Pahineh V - Moon 1 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30004280": [
|
||||
{
|
||||
"station_id": 60013003,
|
||||
"name": "Nalnifan IV - Moon 2 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30004281": [
|
||||
{
|
||||
"station_id": 60013000,
|
||||
"name": "Jerhesh VI - Moon 11 - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30004284": [
|
||||
{
|
||||
"station_id": 60012997,
|
||||
"name": "Defsunun IV - Moon 1 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30005069": [
|
||||
{
|
||||
"station_id": 60013015,
|
||||
"name": "Nahol X - Moon 2 - DED Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60013018,
|
||||
"name": "Nahol II - Moon 1 - DED Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60013021,
|
||||
"name": "Nahol IV - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30005078": [
|
||||
{
|
||||
"station_id": 60012484,
|
||||
"name": "Keproh VIII - Moon 3 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30005079": [
|
||||
{
|
||||
"station_id": 60012478,
|
||||
"name": "Zatamaka VII - Moon 2 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012481,
|
||||
"name": "Zatamaka X - Moon 2 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012472,
|
||||
"name": "Zatamaka XI - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30005080": [
|
||||
{
|
||||
"station_id": 60012475,
|
||||
"name": "Rannoze V - Moon 8 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012487,
|
||||
"name": "Rannoze VII - Moon 2 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30005198": [
|
||||
{
|
||||
"station_id": 60012265,
|
||||
"name": "Pakhshi IX - Moon 20 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30005199": [
|
||||
{
|
||||
"station_id": 60012739,
|
||||
"name": "Tar III - Secure Commerce Commission Depository"
|
||||
}
|
||||
],
|
||||
"30005200": [
|
||||
{
|
||||
"station_id": 60012259,
|
||||
"name": "Tekaima I - Moon 1 - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012268,
|
||||
"name": "Tekaima V - Moon 9 - CONCORD Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30005204": [
|
||||
{
|
||||
"station_id": 60012271,
|
||||
"name": "Yulai VIII - Moon 10 - CONCORD Logistic Support"
|
||||
},
|
||||
{
|
||||
"station_id": 60012736,
|
||||
"name": "Yulai III - Moon 1 - Secure Commerce Commission Depository"
|
||||
},
|
||||
{
|
||||
"station_id": 60012916,
|
||||
"name": "Yulai VIII - Moon 12 - DED Logistic Support"
|
||||
},
|
||||
{
|
||||
"station_id": 60012256,
|
||||
"name": "Yulai IX (Kjarval) - CONCORD Bureau"
|
||||
},
|
||||
{
|
||||
"station_id": 60012922,
|
||||
"name": "Yulai X - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30005205": [
|
||||
{
|
||||
"station_id": 60012919,
|
||||
"name": "Tarta IX - Moon 14 - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30005206": [
|
||||
{
|
||||
"station_id": 60012262,
|
||||
"name": "Kemerk V - Moon 10 - CONCORD Bureau"
|
||||
}
|
||||
],
|
||||
"30005315": [
|
||||
{
|
||||
"station_id": 60012994,
|
||||
"name": "Eletta VII - Moon 7 - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30005319": [
|
||||
{
|
||||
"station_id": 60012988,
|
||||
"name": "Raneilles V - Moon 2 - DED Assembly Plant"
|
||||
},
|
||||
{
|
||||
"station_id": 60012991,
|
||||
"name": "Raneilles III - DED Assembly Plant"
|
||||
}
|
||||
],
|
||||
"30005322": [
|
||||
{
|
||||
"station_id": 60012931,
|
||||
"name": "Scolluzer VI - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30005323": [
|
||||
{
|
||||
"station_id": 60012925,
|
||||
"name": "Sortet VI - Moon 5 - DED Logistic Support"
|
||||
}
|
||||
],
|
||||
"30005328": [
|
||||
{
|
||||
"station_id": 60012928,
|
||||
"name": "Reblier VIII - Moon 7 - DED Logistic Support"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
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.
@@ -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
|
||||
|
||||
355
test/integration/map/corporation_change_permission_test.exs
Normal file
355
test/integration/map/corporation_change_permission_test.exs
Normal 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
|
||||
505
test/integration/map/map_scope_filtering_test.exs
Normal file
505
test/integration/map/map_scope_filtering_test.exs
Normal file
@@ -0,0 +1,505 @@
|
||||
defmodule WandererApp.Map.MapScopeFilteringTest do
|
||||
@moduledoc """
|
||||
Integration tests for map scope filtering during character location tracking.
|
||||
|
||||
These tests verify that systems are correctly filtered based on map scope settings
|
||||
when characters move between systems. The key scenarios tested:
|
||||
|
||||
1. Characters moving between systems with [:wormholes, :null] scopes:
|
||||
- Wormhole systems should be added
|
||||
- Null-sec systems should be added
|
||||
- High-sec systems should NOT be added (filtered out)
|
||||
- Low-sec systems should NOT be added (filtered out)
|
||||
|
||||
2. Wormhole border behavior:
|
||||
- When a character jumps from wormhole to k-space, the wormhole should be added
|
||||
- K-space border systems should only be added if they match the scopes
|
||||
|
||||
3. K-space only movement:
|
||||
- Characters moving within k-space should only track systems matching scopes
|
||||
- No "border system" behavior for k-space to k-space movement
|
||||
|
||||
Reference bug: Characters with [:wormholes, :null] scopes were getting
|
||||
high-sec (0.6) and low-sec (0.4) systems added to the map when traveling.
|
||||
"""
|
||||
|
||||
use WandererApp.DataCase
|
||||
|
||||
# System class constants (matching ConnectionsImpl)
|
||||
@c1 1
|
||||
@c2 2
|
||||
@hs 7
|
||||
@ls 8
|
||||
@ns 9
|
||||
|
||||
# Test solar system IDs
|
||||
# C1 wormhole
|
||||
@wh_system_j100001 31_000_001
|
||||
# C2 wormhole
|
||||
@wh_system_j100002 31_000_002
|
||||
# High-sec system (0.6)
|
||||
@hs_system_halenan 30_000_001
|
||||
# High-sec system (0.6)
|
||||
@hs_system_mili 30_000_002
|
||||
# Low-sec system (0.4)
|
||||
@ls_system_halmah 30_000_100
|
||||
# Null-sec system
|
||||
@ns_system_geminate 30_000_200
|
||||
|
||||
setup do
|
||||
# Setup system static info cache with both wormhole and k-space systems
|
||||
setup_scope_test_systems()
|
||||
# Setup known stargates between adjacent k-space systems
|
||||
setup_kspace_stargates()
|
||||
:ok
|
||||
end
|
||||
|
||||
# Setup system static info for scope testing
|
||||
defp setup_scope_test_systems do
|
||||
test_systems = %{
|
||||
# C1 Wormhole
|
||||
@wh_system_j100001 => %{
|
||||
solar_system_id: @wh_system_j100001,
|
||||
solar_system_name: "J100001",
|
||||
solar_system_name_lc: "j100001",
|
||||
region_id: 11_000_001,
|
||||
constellation_id: 21_000_001,
|
||||
region_name: "A-R00001",
|
||||
constellation_name: "A-C00001",
|
||||
system_class: @c1,
|
||||
security: "-1.0",
|
||||
type_description: "Class 1",
|
||||
class_title: "C1",
|
||||
is_shattered: false,
|
||||
effect_name: nil,
|
||||
effect_power: nil,
|
||||
statics: ["H121"],
|
||||
wandering: [],
|
||||
triglavian_invasion_status: nil,
|
||||
sun_type_id: 45041
|
||||
},
|
||||
# C2 Wormhole
|
||||
@wh_system_j100002 => %{
|
||||
solar_system_id: @wh_system_j100002,
|
||||
solar_system_name: "J100002",
|
||||
solar_system_name_lc: "j100002",
|
||||
region_id: 11_000_001,
|
||||
constellation_id: 21_000_001,
|
||||
region_name: "A-R00001",
|
||||
constellation_name: "A-C00001",
|
||||
system_class: @c2,
|
||||
security: "-1.0",
|
||||
type_description: "Class 2",
|
||||
class_title: "C2",
|
||||
is_shattered: false,
|
||||
effect_name: nil,
|
||||
effect_power: nil,
|
||||
statics: ["D382", "L005"],
|
||||
wandering: [],
|
||||
triglavian_invasion_status: nil,
|
||||
sun_type_id: 45041
|
||||
},
|
||||
# High-sec system (Halenan 0.6)
|
||||
@hs_system_halenan => %{
|
||||
solar_system_id: @hs_system_halenan,
|
||||
solar_system_name: "Halenan",
|
||||
solar_system_name_lc: "halenan",
|
||||
region_id: 10_000_067,
|
||||
constellation_id: 20_000_901,
|
||||
region_name: "Devoid",
|
||||
constellation_name: "Devoid",
|
||||
system_class: @hs,
|
||||
security: "0.6",
|
||||
type_description: "High Security",
|
||||
class_title: "High Sec",
|
||||
is_shattered: false,
|
||||
effect_name: nil,
|
||||
effect_power: nil,
|
||||
statics: [],
|
||||
wandering: [],
|
||||
triglavian_invasion_status: nil,
|
||||
sun_type_id: 45041
|
||||
},
|
||||
# High-sec system (Mili 0.6)
|
||||
@hs_system_mili => %{
|
||||
solar_system_id: @hs_system_mili,
|
||||
solar_system_name: "Mili",
|
||||
solar_system_name_lc: "mili",
|
||||
region_id: 10_000_067,
|
||||
constellation_id: 20_000_901,
|
||||
region_name: "Devoid",
|
||||
constellation_name: "Devoid",
|
||||
system_class: @hs,
|
||||
security: "0.6",
|
||||
type_description: "High Security",
|
||||
class_title: "High Sec",
|
||||
is_shattered: false,
|
||||
effect_name: nil,
|
||||
effect_power: nil,
|
||||
statics: [],
|
||||
wandering: [],
|
||||
triglavian_invasion_status: nil,
|
||||
sun_type_id: 45041
|
||||
},
|
||||
# Low-sec system (Halmah 0.4)
|
||||
@ls_system_halmah => %{
|
||||
solar_system_id: @ls_system_halmah,
|
||||
solar_system_name: "Halmah",
|
||||
solar_system_name_lc: "halmah",
|
||||
region_id: 10_000_067,
|
||||
constellation_id: 20_000_901,
|
||||
region_name: "Devoid",
|
||||
constellation_name: "Devoid",
|
||||
system_class: @ls,
|
||||
security: "0.4",
|
||||
type_description: "Low Security",
|
||||
class_title: "Low Sec",
|
||||
is_shattered: false,
|
||||
effect_name: nil,
|
||||
effect_power: nil,
|
||||
statics: [],
|
||||
wandering: [],
|
||||
triglavian_invasion_status: nil,
|
||||
sun_type_id: 45041
|
||||
},
|
||||
# Null-sec system
|
||||
@ns_system_geminate => %{
|
||||
solar_system_id: @ns_system_geminate,
|
||||
solar_system_name: "Geminate",
|
||||
solar_system_name_lc: "geminate",
|
||||
region_id: 10_000_029,
|
||||
constellation_id: 20_000_400,
|
||||
region_name: "Geminate",
|
||||
constellation_name: "Geminate",
|
||||
system_class: @ns,
|
||||
security: "-0.5",
|
||||
type_description: "Null Security",
|
||||
class_title: "Null Sec",
|
||||
is_shattered: false,
|
||||
effect_name: nil,
|
||||
effect_power: nil,
|
||||
statics: [],
|
||||
wandering: [],
|
||||
triglavian_invasion_status: nil,
|
||||
sun_type_id: 45041
|
||||
}
|
||||
}
|
||||
|
||||
Enum.each(test_systems, fn {solar_system_id, system_info} ->
|
||||
Cachex.put(:system_static_info_cache, solar_system_id, system_info)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# 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
|
||||
|
||||
alias WandererApp.Map.Server.ConnectionsImpl
|
||||
alias WandererApp.Map.Server.SystemsImpl
|
||||
|
||||
test "can_add_location correctly filters high-sec with [:wormholes, :null] scopes" do
|
||||
# High-sec should NOT be allowed with [:wormholes, :null]
|
||||
refute ConnectionsImpl.can_add_location([:wormholes, :null], @hs_system_halenan),
|
||||
"High-sec should be filtered out with [:wormholes, :null] scopes"
|
||||
|
||||
refute ConnectionsImpl.can_add_location([:wormholes, :null], @hs_system_mili),
|
||||
"High-sec should be filtered out with [:wormholes, :null] scopes"
|
||||
end
|
||||
|
||||
test "can_add_location correctly filters low-sec with [:wormholes, :null] scopes" do
|
||||
# Low-sec should NOT be allowed with [:wormholes, :null]
|
||||
refute ConnectionsImpl.can_add_location([:wormholes, :null], @ls_system_halmah),
|
||||
"Low-sec should be filtered out with [:wormholes, :null] scopes"
|
||||
end
|
||||
|
||||
test "can_add_location correctly allows wormholes with [:wormholes, :null] scopes" do
|
||||
# Wormholes should be allowed
|
||||
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_j100001),
|
||||
"Wormhole should be allowed with [:wormholes, :null] scopes"
|
||||
|
||||
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_j100002),
|
||||
"Wormhole should be allowed with [:wormholes, :null] scopes"
|
||||
end
|
||||
|
||||
test "can_add_location correctly allows null-sec with [:wormholes, :null] scopes" do
|
||||
# Null-sec should be allowed
|
||||
assert ConnectionsImpl.can_add_location([:wormholes, :null], @ns_system_geminate),
|
||||
"Null-sec should be allowed with [:wormholes, :null] scopes"
|
||||
end
|
||||
|
||||
test "maybe_add_system filters out high-sec when not jumping from wormhole" do
|
||||
# When scopes is [:wormholes, :null] and NOT jumping from wormhole,
|
||||
# high-sec systems should be filtered
|
||||
location = %{solar_system_id: @hs_system_halenan}
|
||||
# old_location is nil (no previous system)
|
||||
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes, :null])
|
||||
assert result == :ok
|
||||
|
||||
# old_location is also high-sec (k-space to k-space)
|
||||
old_location = %{solar_system_id: @hs_system_mili}
|
||||
|
||||
result =
|
||||
SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
|
||||
|
||||
assert result == :ok
|
||||
end
|
||||
|
||||
test "maybe_add_system filters out low-sec when not jumping from wormhole" do
|
||||
location = %{solar_system_id: @ls_system_halmah}
|
||||
# old_location is high-sec (k-space to k-space)
|
||||
old_location = %{solar_system_id: @hs_system_halenan}
|
||||
|
||||
result =
|
||||
SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes, :null])
|
||||
|
||||
assert result == :ok
|
||||
end
|
||||
|
||||
test "maybe_add_system allows border high-sec when jumping FROM wormhole" do
|
||||
# When jumping FROM a wormhole TO high-sec with :wormholes scope,
|
||||
# the high-sec should be added as a border system
|
||||
location = %{solar_system_id: @hs_system_halenan}
|
||||
old_location = %{solar_system_id: @wh_system_j100001}
|
||||
|
||||
# This should attempt to add the system (not filter it out)
|
||||
# The result will be an error because the map doesn't exist,
|
||||
# but that proves the filtering logic allowed it through
|
||||
result = SystemsImpl.maybe_add_system("map_id", location, old_location, [], [:wormholes])
|
||||
|
||||
# The function attempts to add (returns error because map doesn't exist)
|
||||
# This proves border behavior is working - system was NOT filtered out
|
||||
assert match?({:error, _}, result),
|
||||
"Border system should attempt to be added (error because map doesn't exist)"
|
||||
end
|
||||
|
||||
test "is_connection_valid allows WH to HS with [:wormholes, :null] (border behavior)" do
|
||||
# The connection is valid for border behavior - but individual systems are filtered
|
||||
assert ConnectionsImpl.is_connection_valid(
|
||||
[:wormholes, :null],
|
||||
@wh_system_j100001,
|
||||
@hs_system_halenan
|
||||
),
|
||||
"WH to HS connection should be valid (border behavior)"
|
||||
end
|
||||
|
||||
test "is_connection_valid rejects HS to LS with [:wormholes, :null] (no border)" do
|
||||
# HS to LS should be rejected - neither system matches scopes and no wormhole involved
|
||||
refute ConnectionsImpl.is_connection_valid(
|
||||
[:wormholes, :null],
|
||||
@hs_system_halenan,
|
||||
@ls_system_halmah
|
||||
),
|
||||
"HS to LS connection should be rejected with [:wormholes, :null]"
|
||||
end
|
||||
|
||||
test "is_connection_valid rejects HS to HS with [:wormholes, :null]" do
|
||||
# HS to HS should be rejected
|
||||
refute ConnectionsImpl.is_connection_valid(
|
||||
[:wormholes, :null],
|
||||
@hs_system_halenan,
|
||||
@hs_system_mili
|
||||
),
|
||||
"HS to HS connection should be rejected with [:wormholes, :null]"
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_effective_scopes behavior" do
|
||||
alias WandererApp.Map.Server.CharactersImpl
|
||||
|
||||
test "get_effective_scopes returns scopes array when present" do
|
||||
# Create a map struct with scopes array
|
||||
map = %{scopes: [:wormholes, :null]}
|
||||
scopes = CharactersImpl.get_effective_scopes(map)
|
||||
assert scopes == [:wormholes, :null]
|
||||
end
|
||||
|
||||
test "get_effective_scopes converts legacy :all scope" do
|
||||
map = %{scope: :all}
|
||||
scopes = CharactersImpl.get_effective_scopes(map)
|
||||
assert scopes == [:wormholes, :hi, :low, :null, :pochven]
|
||||
end
|
||||
|
||||
test "get_effective_scopes converts legacy :wormholes scope" do
|
||||
map = %{scope: :wormholes}
|
||||
scopes = CharactersImpl.get_effective_scopes(map)
|
||||
assert scopes == [:wormholes]
|
||||
end
|
||||
|
||||
test "get_effective_scopes converts legacy :stargates scope" do
|
||||
map = %{scope: :stargates}
|
||||
scopes = CharactersImpl.get_effective_scopes(map)
|
||||
assert scopes == [:hi, :low, :null, :pochven]
|
||||
end
|
||||
|
||||
test "get_effective_scopes converts legacy :none scope" do
|
||||
map = %{scope: :none}
|
||||
scopes = CharactersImpl.get_effective_scopes(map)
|
||||
assert scopes == []
|
||||
end
|
||||
|
||||
test "get_effective_scopes defaults to [:wormholes] when no scope" do
|
||||
map = %{}
|
||||
scopes = CharactersImpl.get_effective_scopes(map)
|
||||
assert scopes == [:wormholes]
|
||||
end
|
||||
end
|
||||
|
||||
describe "WandererApp.Map struct and new/1 function" do
|
||||
alias WandererApp.Map.Server.CharactersImpl
|
||||
|
||||
test "Map struct includes scopes field" do
|
||||
# Verify the struct has the scopes field
|
||||
map_struct = %WandererApp.Map{}
|
||||
assert Map.has_key?(map_struct, :scopes)
|
||||
assert map_struct.scopes == nil
|
||||
end
|
||||
|
||||
test "Map.new/1 extracts scopes from input" do
|
||||
# Simulate input from database (Ash resource)
|
||||
input = %{
|
||||
id: "test-map-id",
|
||||
name: "Test Map",
|
||||
scope: :wormholes,
|
||||
scopes: [:wormholes, :null],
|
||||
owner_id: "owner-123",
|
||||
acls: [],
|
||||
hubs: []
|
||||
}
|
||||
|
||||
map = WandererApp.Map.new(input)
|
||||
|
||||
assert map.map_id == "test-map-id"
|
||||
assert map.name == "Test Map"
|
||||
assert map.scope == :wormholes
|
||||
assert map.scopes == [:wormholes, :null]
|
||||
end
|
||||
|
||||
test "Map.new/1 handles missing scopes (nil)" do
|
||||
# When scopes is not present in input, it should be nil
|
||||
input = %{
|
||||
id: "test-map-id",
|
||||
name: "Test Map",
|
||||
scope: :all,
|
||||
owner_id: "owner-123",
|
||||
acls: [],
|
||||
hubs: []
|
||||
}
|
||||
|
||||
map = WandererApp.Map.new(input)
|
||||
|
||||
assert map.map_id == "test-map-id"
|
||||
assert map.scope == :all
|
||||
assert map.scopes == nil
|
||||
end
|
||||
|
||||
test "get_effective_scopes uses scopes field from Map struct when present" do
|
||||
# Create map struct with both scope and scopes
|
||||
input = %{
|
||||
id: "test-map-id",
|
||||
name: "Test Map",
|
||||
scope: :all,
|
||||
scopes: [:wormholes, :null],
|
||||
owner_id: "owner-123",
|
||||
acls: [],
|
||||
hubs: []
|
||||
}
|
||||
|
||||
map = WandererApp.Map.new(input)
|
||||
|
||||
# get_effective_scopes should prioritize scopes over scope
|
||||
effective = CharactersImpl.get_effective_scopes(map)
|
||||
assert effective == [:wormholes, :null]
|
||||
end
|
||||
|
||||
test "get_effective_scopes falls back to legacy scope when scopes is nil" do
|
||||
# Create map struct with only legacy scope
|
||||
input = %{
|
||||
id: "test-map-id",
|
||||
name: "Test Map",
|
||||
scope: :all,
|
||||
owner_id: "owner-123",
|
||||
acls: [],
|
||||
hubs: []
|
||||
}
|
||||
|
||||
map = WandererApp.Map.new(input)
|
||||
|
||||
# get_effective_scopes should convert legacy :all scope
|
||||
effective = CharactersImpl.get_effective_scopes(map)
|
||||
assert effective == [:wormholes, :hi, :low, :null, :pochven]
|
||||
end
|
||||
|
||||
test "get_effective_scopes falls back to legacy scope when scopes is empty list" do
|
||||
# Empty scopes list should fall back to legacy scope
|
||||
input = %{
|
||||
id: "test-map-id",
|
||||
name: "Test Map",
|
||||
scope: :stargates,
|
||||
scopes: [],
|
||||
owner_id: "owner-123",
|
||||
acls: [],
|
||||
hubs: []
|
||||
}
|
||||
|
||||
map = WandererApp.Map.new(input)
|
||||
|
||||
# get_effective_scopes should fall back to legacy scope conversion
|
||||
effective = CharactersImpl.get_effective_scopes(map)
|
||||
assert effective == [:hi, :low, :null, :pochven]
|
||||
end
|
||||
|
||||
test "Map.new/1 extracts all scope variations correctly" do
|
||||
# Test various scope combinations
|
||||
test_cases = [
|
||||
{[:wormholes], [:wormholes]},
|
||||
{[:hi, :low], [:hi, :low]},
|
||||
{[:wormholes, :hi, :low, :null, :pochven], [:wormholes, :hi, :low, :null, :pochven]},
|
||||
{[:null], [:null]}
|
||||
]
|
||||
|
||||
for {input_scopes, expected_scopes} <- test_cases do
|
||||
input = %{
|
||||
id: "test-map-id",
|
||||
name: "Test Map",
|
||||
scope: :wormholes,
|
||||
scopes: input_scopes,
|
||||
owner_id: "owner-123",
|
||||
acls: [],
|
||||
hubs: []
|
||||
}
|
||||
|
||||
map = WandererApp.Map.new(input)
|
||||
effective = CharactersImpl.get_effective_scopes(map)
|
||||
|
||||
assert effective == expected_scopes,
|
||||
"Expected #{inspect(expected_scopes)}, got #{inspect(effective)} for input #{inspect(input_scopes)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -300,7 +300,7 @@ defmodule WandererAppWeb.Factory do
|
||||
# Include owner_id in the form data just like the LiveView does
|
||||
create_attrs =
|
||||
built_attrs
|
||||
|> Map.take([:name, :slug, :description, :scope, :only_tracked_characters])
|
||||
|> Map.take([:name, :slug, :description, :scope, :scopes, :only_tracked_characters])
|
||||
|> Map.put(:owner_id, owner_id)
|
||||
|
||||
# Debug: ensure owner_id is valid
|
||||
|
||||
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}
|
||||
}
|
||||
@@ -206,37 +210,57 @@ defmodule WandererApp.Map.Server.MapScopesTest do
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @hs_system_id) == false
|
||||
end
|
||||
|
||||
test "connection valid when at least one system matches a scope" do
|
||||
# WH to HS: valid if either :wormholes or :hi is selected
|
||||
test "wormhole border behavior: WH connections allow border k-space systems" do
|
||||
# WH to HS with [:wormholes]: valid (wormhole border behavior)
|
||||
# At least one system is WH, :wormholes is enabled -> border k-space allowed
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
|
||||
true
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == true
|
||||
# WH to HS with [:hi] only: INVALID (no wormhole scope, WH doesn't match :hi)
|
||||
# Neither system matches when we require both to match (no wormhole border behavior)
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == false
|
||||
|
||||
# WH to WH: valid only if :wormholes is selected
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @c2_system_id) ==
|
||||
true
|
||||
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @c2_system_id) == false
|
||||
|
||||
# HS to LS: valid if :hi or :low is selected
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == true
|
||||
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == true
|
||||
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
|
||||
end
|
||||
|
||||
test "connection with multiple scopes allows cross-space movement" do
|
||||
# With [:wormholes, :hi], all of these should be valid:
|
||||
# - WH to WH (wormholes matches)
|
||||
# - HS to HS (hi matches)
|
||||
# - WH to HS (either matches)
|
||||
test "k-space connections require BOTH systems to match scopes" do
|
||||
# HS to LS: requires BOTH to match, so single scope is not enough
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == false
|
||||
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == false
|
||||
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
|
||||
|
||||
# HS to LS with [:hi, :low]: valid (both match)
|
||||
assert ConnectionsImpl.is_connection_valid([:hi, :low], @hs_system_id, @ls_system_id) ==
|
||||
true
|
||||
|
||||
# HS to HS: valid with [:hi] (both match)
|
||||
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, 30_000_002) == true
|
||||
|
||||
# NS to NS: valid with [:null] (both match)
|
||||
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
|
||||
# (same system returns false)
|
||||
end
|
||||
|
||||
test "connection with multiple scopes" do
|
||||
# With [:wormholes, :hi]:
|
||||
# - WH to WH: valid (both match :wormholes)
|
||||
# - HS to HS: valid (both match :hi, 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
|
||||
|
||||
# But LS to NS should not be valid with [:wormholes, :hi]
|
||||
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 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
|
||||
@@ -294,4 +318,295 @@ defmodule WandererApp.Map.Server.MapScopesTest do
|
||||
assert ConnectionsImpl.is_prohibited_system_class?(25) == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "maybe_add_system/5 scope filtering" do
|
||||
alias WandererApp.Map.Server.SystemsImpl
|
||||
|
||||
test "returns :ok without filtering when scopes is nil" do
|
||||
# When scopes is nil, should not filter (backward compatibility)
|
||||
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [])
|
||||
assert result == :ok
|
||||
end
|
||||
|
||||
test "returns :ok without filtering when scopes is empty list" do
|
||||
# Empty scopes should not filter (let through)
|
||||
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [], [])
|
||||
assert result == :ok
|
||||
end
|
||||
|
||||
test "filters system when scopes provided and system doesn't match" do
|
||||
# When scopes is [:wormholes] and system is Hi-Sec, should filter (return :ok without adding)
|
||||
location = %{solar_system_id: @hs_system_id}
|
||||
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes])
|
||||
# Returns :ok because system was filtered out (not an error, just skipped)
|
||||
assert result == :ok
|
||||
end
|
||||
|
||||
test "allows system through when scopes match (verified via can_add_location)" do
|
||||
# When scopes is [:wormholes] and system is WH, filtering should allow it
|
||||
# We test this via can_add_location which is what maybe_add_system uses internally
|
||||
assert ConnectionsImpl.can_add_location([:wormholes], @wh_system_id) == true
|
||||
assert ConnectionsImpl.can_add_location([:null], @ns_system_id) == true
|
||||
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_id) == true
|
||||
assert ConnectionsImpl.can_add_location([:wormholes, :null], @ns_system_id) == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "border system auto-addition behavior" do
|
||||
# Tests that verify bordered systems are correctly auto-added ONLY for wormholes.
|
||||
# Key behavior:
|
||||
# - Wormhole border: WH to Hi-Sec with [:wormholes] -> BOTH added (border behavior)
|
||||
# - K-space only: Null to Hi-Sec with [:wormholes, :null] -> REJECTED (no border for k-space)
|
||||
# - K-space must match: both systems must match scopes when no wormhole involved
|
||||
|
||||
test "WORMHOLE BORDER: WH->Hi-Sec with [:wormholes] is VALID (border k-space added)" do
|
||||
# Border case: moving from WH to k-space
|
||||
# Valid because :wormholes enabled AND one system is WH
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
|
||||
true
|
||||
end
|
||||
|
||||
test "WORMHOLE BORDER: Hi-Sec->WH with [:wormholes] is VALID (border k-space added)" do
|
||||
# Border case: moving from k-space to WH
|
||||
# Valid because :wormholes enabled AND one system is WH
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @wh_system_id) ==
|
||||
true
|
||||
end
|
||||
|
||||
test "K-SPACE ONLY: Hi-Sec->Hi-Sec with [:wormholes] is 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 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 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
|
||||
# Low-Sec matches :low, but Hi-Sec doesn't match
|
||||
# No wormhole involved, so BOTH must match -> rejected
|
||||
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, @hs_system_id) == false
|
||||
end
|
||||
|
||||
test "K-SPACE MATCH: Low-Sec->Low-Sec with [:low] is VALID (both match)" do
|
||||
# Both systems match :low
|
||||
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, 30_000_101) == true
|
||||
end
|
||||
|
||||
test "K-SPACE MATCH: Null->Null with [:null] is VALID (both match)" do
|
||||
# Would need two different null-sec systems for this test
|
||||
# Using same system returns false (same system check)
|
||||
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
|
||||
end
|
||||
|
||||
test "WORMHOLE BORDER: Pochven->WH with [:wormholes, :pochven] is VALID" do
|
||||
# WH is wormhole, :wormholes enabled -> border behavior applies
|
||||
assert ConnectionsImpl.is_connection_valid(
|
||||
[:wormholes, :pochven],
|
||||
@pochven_id,
|
||||
@wh_system_id
|
||||
) ==
|
||||
true
|
||||
end
|
||||
|
||||
test "WORMHOLE BORDER: WH->Pochven with [:wormholes] is VALID (border k-space)" do
|
||||
# WH is wormhole, :wormholes enabled -> border behavior, Pochven added as border
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @pochven_id) == true
|
||||
end
|
||||
|
||||
test "border systems: WH->Hi-Sec->WH path with [:wormholes] scope" do
|
||||
# Simulates a character path through k-space between WHs
|
||||
# First jump: WH to Hi-Sec - valid (wormhole border)
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
|
||||
true
|
||||
|
||||
# Second jump: Hi-Sec to WH - valid (wormhole border)
|
||||
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @c2_system_id) ==
|
||||
true
|
||||
end
|
||||
|
||||
test "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 "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
|
||||
|
||||
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