Compare commits

...

85 Commits

Author SHA1 Message Date
CI
ba0c10d2e4 chore: release version v1.92.0 2026-01-14 22:29:39 +00:00
Dmitry Popov
996c88d839 Merge pull request #575 from wanderer-industries/k162-selector
K162 selector
2026-01-15 02:29:09 +04:00
Dmitry Popov
80e998cf79 fix(core): Show c1/c2/c3 or c4/c5 or link signature modal 2026-01-14 23:28:47 +01:00
Dmitry Popov
d2bcb89fa1 Merge branch 'main' into k162-selector 2026-01-13 20:27:48 +01:00
CI
922f296f17 chore: [skip ci] 2026-01-13 00:16:39 +00:00
CI
71dc20c933 chore: release version v1.91.11 2026-01-13 00:16:39 +00:00
Dmitry Popov
80f7d34d3d Merge pull request #573 from guarzo/guarzo/maprelayreturn
fix: allow sig api when map relay is off
2026-01-13 04:16:06 +04:00
Guarzo
113fe1c695 fix: allow sig api when map relay is off 2026-01-12 23:59:20 +00:00
DanSylvest
5550844912 feat: Added ability to select a range of wh classes for k162. 2026-01-12 12:39:53 +03:00
CI
0228e68a1d chore: [skip ci] 2026-01-07 12:35:19 +00:00
CI
3424667af1 chore: release version v1.91.10 2026-01-07 12:35:19 +00:00
Dmitry Popov
6c7b28a6c1 Merge pull request #571 from guarzo/guarzo/sigapi2
fix: remove actor context requirement from sig api
2026-01-07 16:34:34 +04:00
Guarzo
3988079cd3 fix: remove actor context requirement from sig api 2026-01-07 04:24:15 +00:00
CI
f5d407fee0 chore: [skip ci] 2026-01-06 15:38:03 +00:00
CI
a857422c46 chore: release version v1.91.9 2026-01-06 15:38:02 +00:00
Dmitry Popov
ec6717d0ef Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-06 16:37:32 +01:00
Dmitry Popov
56dacdcbbd fix(core): fixed rally point cancel logic 2026-01-06 16:37:29 +01:00
CI
c8e17b1691 chore: [skip ci] 2026-01-06 14:07:08 +00:00
CI
19c7fe59ee chore: release version v1.91.8 2026-01-06 14:07:08 +00:00
Dmitry Popov
682100c231 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-06 15:06:34 +01:00
Dmitry Popov
f9ac79cdcc fix(core): fixed rally point cancel logic 2026-01-06 15:06:31 +01:00
CI
f09f220645 chore: [skip ci] 2026-01-05 20:29:10 +00:00
CI
e585cdfd20 chore: release version v1.91.7 2026-01-05 20:29:10 +00:00
Dmitry Popov
3a3180f7b3 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-05 21:28:38 +01:00
Dmitry Popov
53abc580e5 chore: added promo on characters page 2026-01-05 21:28:35 +01:00
CI
8710d172a0 chore: [skip ci] 2026-01-04 23:49:15 +00:00
CI
301a380a4b chore: release version v1.91.6 2026-01-04 23:49:15 +00:00
Dmitry Popov
8c911f89e0 fix(core): fixed new connections got deleted after linked signature cleanup 2026-01-05 00:48:38 +01:00
CI
d7e09fc94e chore: [skip ci] 2025-12-30 10:49:35 +00:00
CI
3b7e191898 chore: release version v1.91.5 2025-12-30 10:49:35 +00:00
Dmitry Popov
f351fbaf20 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-30 11:49:02 +01:00
Dmitry Popov
016e793ba7 chore: Added 2026 roadmap blog post 2025-12-30 11:48:59 +01:00
CI
db483fd253 chore: [skip ci] 2025-12-30 09:27:37 +00:00
CI
911ba231cd chore: release version v1.91.4 2025-12-30 09:27:37 +00:00
Dmitry Popov
b3053f325d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-30 10:27:06 +01:00
Dmitry Popov
4ab47334fc fix(core): fixed connections create between k-space systems (considered as wh connection) 2025-12-30 10:27:03 +01:00
CI
e163f02526 chore: [skip ci] 2025-12-28 17:02:12 +00:00
CI
9e22dba8f1 chore: release version v1.91.3 2025-12-28 17:02:12 +00:00
Dmitry Popov
9631406def Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-28 18:01:43 +01:00
Dmitry Popov
f6ae448c3b chore: update event post 2025-12-28 18:01:39 +01:00
CI
46345ef596 chore: [skip ci] 2025-12-27 22:11:03 +00:00
CI
1625f16c8f chore: release version v1.91.2 2025-12-27 22:11:03 +00:00
Dmitry Popov
b4ef9ae983 fix(core): fixed map scopes updates & logic 2025-12-27 23:10:26 +01:00
CI
3b9c2dd996 chore: [skip ci] 2025-12-25 18:20:20 +00:00
CI
8a0f9a58d0 chore: release version v1.91.1 2025-12-25 18:20:20 +00:00
Dmitry Popov
5fe8caac0d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-25 19:19:47 +01:00
Dmitry Popov
f18f567727 chore: fix blog link styles 2025-12-25 19:19:44 +01:00
CI
91acc49980 chore: [skip ci] 2025-12-24 15:09:40 +00:00
CI
ae3873a225 chore: release version v1.91.0 2025-12-24 15:09:40 +00:00
Dmitry Popov
b351c6cc26 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-24 16:09:06 +01:00
Dmitry Popov
698244d945 feat(admin): added maps administration view with basic info, search, restore/delete, acls view and edit options 2025-12-24 16:09:03 +01:00
CI
2c7dd9dc5b chore: [skip ci] 2025-12-19 12:33:26 +00:00
CI
36934cce0b chore: release version v1.90.13 2025-12-19 12:33:26 +00:00
Dmitry Popov
b7da7e4ecb Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-19 13:32:46 +01:00
Dmitry Popov
6471ea5590 fix(core): fixed welcome page 2025-12-19 13:32:44 +01:00
CI
b46bcac642 chore: [skip ci] 2025-12-19 09:38:36 +00:00
CI
52d90361e9 chore: release version v1.90.12 2025-12-19 09:38:36 +00:00
Dmitry Popov
1c902d3319 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-19 10:38:02 +01:00
Dmitry Popov
8f671a359b fix(core): fixed permissions update after character corp updates 2025-12-19 10:37:59 +01:00
CI
840c416684 chore: [skip ci] 2025-12-18 21:47:59 +00:00
CI
56e29ad30a chore: release version v1.90.11 2025-12-18 21:47:59 +00:00
Dmitry Popov
cd8f8b5801 chore: added promo codes support for map subs 2025-12-18 22:19:50 +01:00
Dmitry Popov
70e013fa3d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-18 22:19:38 +01:00
Dmitry Popov
d6bfaf8008 chore: added promo codes support for map subs 2025-12-18 22:19:26 +01:00
CI
95944199a0 chore: [skip ci] 2025-12-18 18:05:48 +00:00
CI
3bd5db8cf3 chore: release version v1.90.10 2025-12-18 18:05:48 +00:00
Dmitry Popov
a245330ada Merge branch 'advent-challenge' 2025-12-18 19:05:10 +01:00
Dmitry Popov
1226b6abf3 chore: added advent challenge 2025-12-18 19:04:43 +01:00
Dmitry Popov
7a1f5c0966 chore: [skip ci] 2025-12-17 19:32:37 +01:00
CI
e5afa1d5bc chore: [skip ci] 2025-12-15 11:46:40 +00:00
CI
1473fe8646 chore: release version v1.90.9 2025-12-15 11:46:40 +00:00
Dmitry Popov
7039ced11e fix(core): reduce chracters untrack grace period to 15 mins (after change/close/disconnect from map)
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-12-15 12:46:02 +01:00
CI
42b5bb337f chore: [skip ci] 2025-12-15 11:35:24 +00:00
CI
1dbb24f6ec chore: release version v1.90.8 2025-12-15 11:35:24 +00:00
Dmitry Popov
c242f510e0 fix(core): skip systems or connections cleanup for not started maps 2025-12-15 12:34:55 +01:00
CI
c59d51636e chore: [skip ci] 2025-12-15 00:36:18 +00:00
CI
c5a8aa1b4d chore: release version v1.90.7 2025-12-15 00:36:18 +00:00
Dmitry Popov
cba050a9e7 fix(core): fixed scopes 2025-12-15 01:35:41 +01:00
CI
59fcbef3b1 chore: [skip ci] 2025-12-12 18:49:02 +00:00
CI
2f1eb6eeaa chore: release version v1.90.6 2025-12-12 18:49:02 +00:00
Dmitry Popov
71ae326cf7 fix(core): fixed map scopes 2025-12-12 19:48:26 +01:00
CI
07829caf0f chore: [skip ci] 2025-12-12 18:36:03 +00:00
CI
a5850b5a8d chore: release version v1.90.5 2025-12-12 18:36:03 +00:00
Dmitry Popov
9f6849209b fix(core): fixed map scopes 2025-12-12 19:35:26 +01:00
CI
7bd295cbad chore: [skip ci] 2025-12-12 17:07:55 +00:00
63 changed files with 3355 additions and 242 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -2,6 +2,184 @@
<!-- 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)

View File

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

View File

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

View File

@@ -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}
/>
</>
)}
</>
);
};

View File

@@ -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);
}
}

View File

@@ -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"

View File

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

View File

@@ -10,3 +10,4 @@ export * from './useCommandComments';
export * from './useGetCacheCharacter';
export * from './useCommandsActivity';
export * from './useCommandPings';
export * from './useCommandPingBlocked';

View File

@@ -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 };
};

View File

@@ -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 });
}, []);

View File

@@ -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;
}

View File

@@ -12,6 +12,7 @@ import {
CommandLinkSignatureToSystem,
CommandMapUpdated,
CommandPingAdded,
CommandPingBlocked,
CommandPingCancelled,
CommandPresentCharacters,
CommandRemoveConnections,
@@ -29,6 +30,7 @@ import { ForwardedRef, useImperativeHandle } from 'react';
import {
useCommandComments,
useCommandPingBlocked,
useCommandPings,
useCommandsCharacters,
useCommandsConnections,
@@ -61,6 +63,7 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const mapUserRoutes = useUserRoutes();
const { addComment, removeComment } = useCommandComments();
const { pingAdded, pingCancelled } = useCommandPings();
const { pingBlocked } = useCommandPingBlocked();
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
useImperativeHandle(ref, () => {
@@ -172,6 +175,9 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
case Commands.pingCancelled:
pingCancelled(data as CommandPingCancelled);
break;
case Commands.pingBlocked:
pingBlocked(data as CommandPingBlocked);
break;
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;

View File

@@ -41,6 +41,7 @@ export enum Commands {
refreshTrackingData = 'refresh_tracking_data',
pingAdded = 'ping_added',
pingCancelled = 'ping_cancelled',
pingBlocked = 'ping_blocked',
}
export type Command =
@@ -77,7 +78,8 @@ export type Command =
| Commands.showTracking
| Commands.refreshTrackingData
| Commands.pingAdded
| Commands.pingCancelled;
| Commands.pingCancelled
| Commands.pingBlocked;
export type CommandInit = {
systems: SolarSystemRawType[];
@@ -161,6 +163,10 @@ export type CommandUpdateTracking = {
};
export type CommandPingAdded = PingData[];
export type CommandPingCancelled = Pick<PingData, 'type' | 'id'>;
export type CommandPingBlocked = {
reason: string;
message: string;
};
export interface UserSettings {
primaryCharacterId?: string;
@@ -212,6 +218,7 @@ export interface CommandData {
[Commands.refreshTrackingData]: CommandRefreshTrackingData;
[Commands.pingAdded]: CommandPingAdded;
[Commands.pingCancelled]: CommandPingCancelled;
[Commands.pingBlocked]: CommandPingBlocked;
}
export interface MapHandlers {

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

@@ -569,6 +569,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
# Broadcast permission update to trigger LiveView refresh
broadcast_permission_update(character_id)
:has_update
{:character_corporation, _info} ->
@@ -580,6 +583,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
# Broadcast permission update to trigger LiveView refresh
broadcast_permission_update(character_id)
:has_update
_ ->
@@ -822,16 +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 ->
# Connection is valid (at least one system matches scopes)
# Add BOTH systems including border systems - filtering already done by is_connection_valid
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts) do
# 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
@@ -841,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
@@ -882,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]
@@ -941,4 +959,21 @@ defmodule WandererApp.Map.Server.CharactersImpl do
track: true
})
end
# Broadcasts permission update to trigger LiveView refresh for the character's user.
# This is called when a character's corporation or alliance changes, ensuring
# users are kicked off maps they no longer have access to.
defp broadcast_permission_update(character_id) do
case WandererApp.Character.get_character(character_id) do
{:ok, %{eve_id: eve_id}} when not is_nil(eve_id) ->
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{eve_id}",
:update_permissions
)
_ ->
:ok
end
end
end

View File

@@ -5,6 +5,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
alias WandererApp.Map.Server.Impl
alias WandererApp.Map.Server.SignaturesImpl
alias WandererApp.Map.Server.SystemsImpl
# @ccp1 -1
@c1 1
@@ -296,6 +297,30 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
do: update_connection(map_id, :update_custom_info, [:custom_info], connection_update)
def cleanup_connections(map_id) do
# Defensive check: Skip cleanup if cache appears invalid
# This prevents incorrectly deleting connections when cache is empty due to
# race conditions during map restart or cache corruption
case WandererApp.Map.get_map(map_id) do
{:error, :not_found} ->
Logger.warning(
"[cleanup_connections] Skipping map #{map_id} - cache miss detected, " <>
"map data not found in cache"
)
:telemetry.execute(
[:wanderer_app, :map, :cleanup_connections, :cache_miss],
%{system_time: System.system_time()},
%{map_id: map_id}
)
:ok
{:ok, _map} ->
do_cleanup_connections(map_id)
end
end
defp do_cleanup_connections(map_id) do
connection_auto_expire_hours = get_connection_auto_expire_hours()
connection_auto_eol_hours = get_connection_auto_eol_hours()
connection_eol_expire_timeout_hours = get_eol_expire_timeout_mins() / 60
@@ -756,17 +781,39 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
to_is_wormhole = to_system_static_info.system_class in @wh_space
wormholes_enabled = :wormholes in scopes
# Wormhole border behavior: if wormholes scope is enabled AND at least one
# system is a wormhole, allow the connection (adds border k-space systems)
# Otherwise: BOTH systems must match the configured scopes
if wormholes_enabled and (from_is_wormhole or to_is_wormhole) do
# At least one system matches (wormhole matches :wormholes, or other matches its scope)
system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes)
else
# Non-wormhole movement: both systems must match scopes
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
cond do
# Case 1: Wormhole border behavior - at least one system is a wormhole
# and :wormholes is enabled, allow the connection (adds border k-space systems)
wormholes_enabled and (from_is_wormhole or to_is_wormhole) ->
# At least one system matches (wormhole matches :wormholes, or other matches its scope)
system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes)
# Case 2: K-space to K-space with :wormholes enabled - check if it's a wormhole connection
# If neither system is a wormhole AND there's no stargate between them, it's a wormhole connection
wormholes_enabled and not from_is_wormhole and not to_is_wormhole ->
# Check if there's a known stargate connection
case find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
{:ok, known_jumps} when known_jumps == [] ->
# No stargate exists - this is a wormhole connection through k-space
true
{:ok, _known_jumps} ->
# Stargate exists - this is NOT a wormhole, check normal scope matching
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
_ ->
# Error fetching jumps - fall back to scope matching
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
end
# Case 3: Non-wormhole movement without :wormholes scope
# Both systems must match the configured scopes
true ->
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
end
else
false
@@ -841,6 +888,44 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
end
end
@doc """
Check if a connection between two k-space systems is a wormhole connection.
Returns true if:
1. Both systems are k-space (not wormhole space)
2. There is no known stargate between them
This is used to detect wormhole connections through k-space, like when
a player jumps from low-sec to low-sec through a wormhole.
"""
def is_kspace_wormhole_connection?(from_solar_system_id, to_solar_system_id)
when is_nil(from_solar_system_id) or is_nil(to_solar_system_id),
do: false
def is_kspace_wormhole_connection?(from_solar_system_id, to_solar_system_id)
when from_solar_system_id == to_solar_system_id,
do: false
def is_kspace_wormhole_connection?(from_solar_system_id, to_solar_system_id) do
with {:ok, from_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_info} <- get_system_static_info(to_solar_system_id) do
from_is_wormhole = from_info.system_class in @wh_space
to_is_wormhole = to_info.system_class in @wh_space
# Both must be k-space (not wormhole space)
if not from_is_wormhole and not to_is_wormhole do
# Check if there's a known stargate
case find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
{:ok, []} -> true # No stargate = wormhole connection
_ -> false # Stargate exists or error
end
else
false
end
else
_ -> false
end
end
defp get_system_static_info(solar_system_id) do
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system_static_info} when not is_nil(system_static_info) ->
@@ -874,6 +959,13 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
WandererApp.Cache.delete("map_#{map_id}:conn_#{connection.id}:start_time")
# Clear linked_sig_eve_id on target system when connection is deleted
# This ensures old signatures become orphaned and won't affect future connections
SystemsImpl.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: location.solar_system_id,
linked_sig_eve_id: nil
})
_error ->
:ok
end

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
require Logger
alias WandererApp.Map.Server.Impl
alias WandererApp.Map.Server.SignaturesImpl
@ddrt Application.compile_env(:wanderer_app, :ddrt)
@system_auto_expire_minutes 15
@@ -146,6 +147,30 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
def cleanup_systems(map_id) do
# Defensive check: Skip cleanup if cache appears invalid
# This prevents incorrectly deleting systems when cache is empty due to
# race conditions during map restart or cache corruption
case WandererApp.Map.get_map(map_id) do
{:error, :not_found} ->
Logger.warning(
"[cleanup_systems] Skipping map #{map_id} - cache miss detected, " <>
"map data not found in cache"
)
:telemetry.execute(
[:wanderer_app, :map, :cleanup_systems, :cache_miss],
%{system_time: System.system_time()},
%{map_id: map_id}
)
:ok
{:ok, _map} ->
do_cleanup_systems(map_id)
end
end
defp do_cleanup_systems(map_id) do
expired_systems =
map_id
|> WandererApp.Map.list_systems!()
@@ -423,7 +448,8 @@ defmodule WandererApp.Map.Server.SystemsImpl do
{:ok, %{eve_id: eve_id, system: system}} = sig |> Ash.load([:system])
# Clear the linked_system_id instead of destroying the signature
case WandererApp.Api.MapSystemSignature.update_linked_system(sig, %{
# Use the wrapper to log unlink operations
case SignaturesImpl.update_signature_linked_system(sig, %{
linked_system_id: nil
}) do
{:ok, _updated_sig} ->
@@ -506,10 +532,40 @@ defmodule WandererApp.Map.Server.SystemsImpl do
# Check if the system matches the map's configured scopes before adding
should_add =
case scopes do
nil -> true
[] -> true
nil ->
true
[] ->
true
scopes when is_list(scopes) ->
ConnectionsImpl.can_add_location(scopes, location.solar_system_id)
# 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

View File

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

View File

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

View File

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

View File

@@ -41,12 +41,15 @@
<div class="absolute rounded-m top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black opacity-75 group-hover:opacity-25 transition-opacity duration-300">
</div>
<div class="absolute w-full bottom-2 p-4">
<% [first_part, second_part] = String.split(post.title, ":", parts: 2) %>
<% {first_part, second_part} = case String.split(post.title, ":", parts: 2) do
[first, second] -> {first, second}
[first] -> {first, nil}
end %>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">
{first_part}
</h3>
<p class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part || ""}
<p :if={second_part} class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part}
</p>
</div>
</div>

View File

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

View File

@@ -117,43 +117,48 @@
</div>
</div>
<div>
<div :if={length(@members) > @members_per_page} class="flex items-center justify-between px-3 py-2 border-t border-gray-500 bg-black bg-opacity-25">
<span class="text-sm text-gray-400">
Page {@members_page} of {total_pages(@members, @members_per_page)} ({length(@members)} members)
</span>
<div class="flex gap-2">
<button
phx-click="members_prev_page"
disabled={@members_page <= 1}
class={"btn btn-sm btn-ghost " <> if(@members_page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="members_next_page"
disabled={@members_page >= total_pages(@members, @members_per_page)}
class={"btn btn-sm btn-ghost " <> if(@members_page >= total_pages(@members, @members_per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
<div
:if={length(@members) > @members_per_page}
class="flex items-center justify-between px-3 py-2 border-t border-gray-500 bg-black bg-opacity-25"
>
<span class="text-sm text-gray-400">
Page {@members_page} of {total_pages(@members, @members_per_page)} ({length(
@members
)} members)
</span>
<div class="flex gap-2">
<button
phx-click="members_prev_page"
disabled={@members_page <= 1}
class={"btn btn-sm btn-ghost " <> if(@members_page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="members_next_page"
disabled={@members_page >= total_pages(@members, @members_per_page)}
class={"btn btn-sm btn-ghost " <> if(@members_page >= total_pages(@members, @members_per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<.link
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
class="btn w-full btn-neutral rounded-none"
patch={~p"/access-lists/#{@selected_acl_id}/add-members"}
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</.link>
<div
:if={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none btn-disabled"
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</div>
<.link
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
class="btn w-full btn-neutral rounded-none"
patch={~p"/access-lists/#{@selected_acl_id}/add-members"}
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</.link>
<div
:if={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none btn-disabled"
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</div>
</div>
</div>
</main>
@@ -179,10 +184,10 @@
placeholder="Select an owner"
options={Enum.map(@characters, fn character -> {character.label, character.id} end)}
/>
<!-- Divider between above inputs and the API key section -->
<hr class="my-4 border-gray-600" />
<!-- API Key Section with grid layout -->
<div class="mt-2">
<label class="block text-sm font-medium text-gray-200 mb-1">ACL API key</label>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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