Compare commits

..

48 Commits

Author SHA1 Message Date
Dmitry Popov
f4ddc8dc8b Merge pull request #530 from s-no1ukno/main
feat(map): Update Owners on Multiple Structures
2026-01-29 19:37:27 +04:00
Dmitry Popov
ac9b46e24d Merge pull request #585 from guarzo/guarzo/addsysfromapi
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
2026-01-27 12:21:35 +04:00
Guarzo
40d0a0777a fix: adding system when linked signature is provided 2026-01-27 03:10:33 +00:00
Dmitry Popov
608792d99a Merge pull request #584 from guarzo/guarzo/autoadd
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
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
🧪 Test Suite / Test Suite (push) Has been cancelled
feat: auto add system on sig addition
2026-01-26 22:45:57 +04:00
Guarzo
dc9e0c821e feat: auto add system on sig addition 2026-01-26 13:47:37 +00:00
Dmitry Popov
79d4fd0e43 Merge pull request #582 from guarzo/guarzo/evenmoredev
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
fix: saving updates to unknown sigs
2026-01-25 15:20:19 +04:00
Guarzo
5d03c1ecc7 fix: saving updates to unknown sigs 2026-01-25 01:50:14 +00:00
Dmitry Popov
2eef05495e Merge pull request #580 from guarzo/guarzo/moreapidev
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
fix: wh position and sig type change
2026-01-24 02:07:53 +04:00
Guarzo
f724455a1e fix: wh position and sig type change 2026-01-23 16:01:52 +00:00
Dmitry Popov
33bbb3425c Merge pull request #579 from guarzo/guarzo/apidev
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
fix: api updates and linked sig addition
2026-01-21 00:04:01 +04:00
Guarzo
a919bd9038 fix: api updates and linked sig addition 2026-01-20 17:55:30 +00:00
Dmitry Popov
8ae34cd94a Merge pull request #577 from guarzo/guarzo/apisigfixes
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
fix: api fixes and format
2026-01-16 16:06:34 +04:00
Guarzo
2f38da52e8 fix: api fixes and format 2026-01-16 08:39:19 +00:00
CI
89d7df0ba2 chore: [skip ci] 2026-01-14 22:29:39 +00:00
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
Jordan Snow
a7d6b06332 feat(map): Reviewed changes
Adding the changes from first review of PR #530. This includes cleanup,
wrapping callbacks in a `useCallback()` hook, and inclusion of clsx
wrapper for styling.
2025-10-23 22:06:42 -06:00
Jordan Snow
8f6da817db Fix: Wrong file added to commits
This file should not have been added to previous commits, and was only
changed to allow for a fix in my local dev environment.
2025-10-19 12:26:28 -06:00
Jordan Snow
378f22a1ef feat(map): Logic for multiple owner updates
Finished all the logic for updating owners on multiple structures in a
single system.
2025-10-18 21:43:44 -06:00
Jordan Snow
14730097b2 feat(map) Adding all the things to the modal
Added a bunch of text and formatting to the system structures owners
dialog box
2025-10-18 20:26:28 -06:00
Jordan Snow
e8bff3098a feat(map): wip New Dialog for Structure Owners
Added the new modal to be able to update all structures within a system
in a single update.
2025-10-18 19:24:19 -06:00
43 changed files with 1674 additions and 180 deletions

View File

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

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

@@ -1,4 +1,4 @@
import React, { useCallback, ClipboardEvent, useRef } from 'react';
import React, { useCallback, ClipboardEvent, useRef, useState } from 'react';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth';
import {
@@ -13,7 +13,9 @@ import { Widget } from '@/hooks/Mapper/components/mapInterface/components';
import { SystemStructuresContent } from './SystemStructuresContent/SystemStructuresContent';
import { useSystemStructures } from './hooks/useSystemStructures';
import { processSnippetText } from './helpers';
import { processSnippetText, StructureItem } from './helpers';
import { SystemStructuresOwnersDialog } from './SystemStructuresOwnersDialog/SystemStructuresOwnersDialog';
import clsx from 'clsx';
export const SystemStructures: React.FC = () => {
const {
@@ -24,6 +26,7 @@ export const SystemStructures: React.FC = () => {
const isNotSelectedSystem = selectedSystems.length !== 1;
const { structures, handleUpdateStructures } = useSystemStructures({ systemId, outCommand });
const [showEditDialog, setShowEditDialog] = useState(false);
const labelRef = useRef<HTMLDivElement>(null);
const isCompact = useMaxWidth(labelRef, 260);
@@ -48,6 +51,18 @@ export const SystemStructures: React.FC = () => {
[processClipboard],
);
const handleSave = (updatedStructures: StructureItem[]) => {
handleUpdateStructures(updatedStructures)
}
const handleOpenDialog = useCallback(() => {
setShowEditDialog(true)
}, [])
const handleCloseDialog = useCallback(() => {
setShowEditDialog(false)
}, [])
const handlePasteTimer = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
@@ -71,8 +86,19 @@ export const SystemStructures: React.FC = () => {
</div>
<LayoutEventBlocker className="flex gap-2.5">
{structures.length > 1 && (
<WdImgButton
className={clsx(PrimeIcons.USER_EDIT, 'text-sky-400 hover:text-sky-200 transition duration-300')}
onClick={handleOpenDialog}
tooltip={{
position: TooltipPosition.left,
// @ts-ignore
content: 'Update all structure owners',
}}
/>
)}
<WdImgButton
className={`${PrimeIcons.CLOCK} text-sky-400 hover:text-sky-200 transition duration-300`}
className={clsx(PrimeIcons.CLOCK, 'text-sky-400 hover:text-sky-200 transition duration-300')}
onClick={handlePasteTimer}
tooltip={{
position: TooltipPosition.left,
@@ -117,6 +143,15 @@ export const SystemStructures: React.FC = () => {
<SystemStructuresContent structures={structures} onUpdateStructures={handleUpdateStructures} />
)}
</Widget>
{showEditDialog && (
<SystemStructuresOwnersDialog
visible={showEditDialog}
structures={structures}
onClose={handleCloseDialog}
onSave={handleSave}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,31 @@
.systemStructuresOwnersDialog {
.p-dialog-content {
background-color: var(--surface-800) !important;
}
.p-dialog-header {
background-color: var(--surface-700);
color: var(--text-color);
}
.p-dialog-header-icon,
.p-dialog-header-title {
color: var(--gray-200);
}
.p-inputtext {
background-color: #2a2a2a !important;
color: #ddd !important;
font-size: 12px !important;
padding: 0.25rem 0.5rem !important;
}
.p-dialog-footer {
.p-button {
font-size: 12px !important;
padding: 0.3rem 0.75rem !important;
}
}
}

View File

@@ -0,0 +1,158 @@
import React, { useCallback, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { AutoComplete } from 'primereact/autocomplete';
import clsx from 'clsx';
import { StructureItem } from '../helpers';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OutCommand } from '@/hooks/Mapper/types';
import { WdButton } from '@/hooks/Mapper/components/ui-kit';
import { useToast } from '@/hooks/Mapper/ToastProvider';
interface StructuresOwnersEditDialogProps {
visible: boolean;
structures: StructureItem[];
onClose: () => void;
onSave: (updatedStuctures: StructureItem[]) => void;
}
export const SystemStructuresOwnersDialog: React.FC<StructuresOwnersEditDialogProps> = ({
visible,
structures,
onClose,
onSave,
}) => {
const [ownerInput, setOwnerInput] = useState('');
const [ownerSuggestions, setOwnerSuggestions] = useState<{ label: string; value: string }[]>([]);
const { outCommand } = useMapRootState();
const { show } = useToast();
const [prevQuery, setPrevQuery] = useState('');
const [prevResults, setPrevResults] = useState<{ label: string; value: string }[]>([]);
const [editData, setEditData] = useState<StructureItem[]>(structures)
// Searching corporation owners via auto-complete
const searchOwners = useCallback(
async (e: { query: string }) => {
const newQuery = e.query.trim();
if (!newQuery) {
setOwnerSuggestions([]);
return;
}
// If user typed more text but we have partial match in prevResults
if (newQuery.startsWith(prevQuery) && prevResults.length > 0) {
const filtered = prevResults.filter(item => item.label.toLowerCase().includes(newQuery.toLowerCase()));
setOwnerSuggestions(filtered);
return;
}
try {
// TODO fix it
const { results = [] } = await outCommand({
type: OutCommand.getCorporationNames,
data: { search: newQuery },
});
setOwnerSuggestions(results);
setPrevQuery(newQuery);
setPrevResults(results);
} catch (err) {
show({
severity: 'error',
summary: 'Failed to fetch owners',
detail: `${err}`,
life: 10000,
})
}
},
[prevQuery, prevResults, outCommand],
);
// when user picks a corp from auto-complete
const handleSelectOwner = (selected: { label: string; value: string }) => {
setOwnerInput(selected.label);
setEditData(structures.map(item => {
return { ...item, ownerName: selected.label, ownerId: selected.value }
}))
};
const handleSaveClick = async () => {
if (!editData) return;
// fetch corporation ticker if we have an ownerId
for (const structure of editData) {
if (structure.ownerId) {
try {
// TODO fix it
const { ticker } = await outCommand({
type: OutCommand.getCorporationTicker,
data: { corp_id: structure.ownerId },
});
structure.ownerTicker = ticker ?? '';
} catch (err) {
console.error('Failed to fetch ticker:', err);
structure.ownerTicker = '';
}
}
}
onSave(editData);
onClose()
};
return (
<Dialog
visible={visible}
onHide={onClose}
header={'Update All Structure Owners'}
className={clsx('myStructuresOwnersDialog', 'text-stone-200 w-full max-w-md')}
>
<div className="flex flex-col gap-2 text-[14px]">
<div className="flex gap-2">
Updating the corporation name below will update all structures currently
saved within the system.
</div>
<hr />
<div className="flex flex-col gap-2">
<label className="grid grid-cols-[100px_1fr] gap-2 items-start mt-2">
<span className="mt-1">Structures to update:</span>
<ul>
{structures && structures.map((item, i) => (
<li key={i}>{item.structureType || 'Unknown Type'} - {item.name}</li>
))}
</ul>
</label>
</div>
<hr />
<div>
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center">
<span>Owner:</span>
<AutoComplete
id="owner"
value={ownerInput}
suggestions={ownerSuggestions}
completeMethod={searchOwners}
minLength={3}
delay={400}
field="label"
placeholder="Corporation name..."
onChange={e => setOwnerInput(e.value)}
onSelect={e => handleSelectOwner(e.value)}
/>
</label>
</div>
</div>
<div className="flex justify-end items-center gap-2 mt-4">
<WdButton label="Save" className="p-button-sm" onClick={handleSaveClick} />
</div>
</Dialog>
);
};

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 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

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

@@ -123,7 +123,8 @@ defmodule WandererApp.Api.MapSystemSignature do
:group,
:type,
:custom_info,
:deleted
:deleted,
:linked_system_id
]
end
@@ -140,7 +141,8 @@ defmodule WandererApp.Api.MapSystemSignature do
:type,
:custom_info,
:deleted,
:update_forced_at
:update_forced_at,
:linked_system_id
]
primary? true

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

@@ -126,4 +126,12 @@ defmodule WandererApp.Map.Operations do
@doc "Delete a signature in a map"
@spec delete_signature(String.t(), String.t()) :: :ok | {:error, String.t()}
defdelegate delete_signature(map_id, sig_id), to: Signatures
@doc "Link a signature to a target system"
@spec link_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
defdelegate link_signature(conn, sig_id, params), to: Signatures
@doc "Unlink a signature from its target system"
@spec unlink_signature(Plug.Conn.t(), String.t()) :: {:ok, map()} | {:error, atom()}
defdelegate unlink_signature(conn, sig_id), to: Signatures
end

View File

@@ -63,13 +63,31 @@ defmodule WandererApp.Map.Operations.Connections do
if is_nil(src_info) or is_nil(tgt_info) do
{:error, :invalid_system_info}
else
# Get wormhole_type for ship size inference
wormhole_type = attrs["wormhole_type"]
# Build extra_info map with optional connection attributes
extra_info =
%{}
|> maybe_add_extra("time_status", attrs["time_status"])
|> maybe_add_extra("mass_status", attrs["mass_status"])
|> maybe_add_extra("locked", attrs["locked"])
|> maybe_add_extra("wormhole_type", wormhole_type)
info = %{
solar_system_source_id: src_info.solar_system_id,
solar_system_target_id: tgt_info.solar_system_id,
character_id: char_id,
type: parse_type(attrs["type"]),
ship_size_type:
resolve_ship_size(attrs["type"], attrs["ship_size_type"], src_info, tgt_info)
resolve_ship_size(
attrs["type"],
attrs["ship_size_type"],
wormhole_type,
src_info,
tgt_info
),
extra_info: if(extra_info == %{}, do: nil, else: extra_info)
}
case Server.add_connection(map_id, info) do
@@ -95,10 +113,11 @@ defmodule WandererApp.Map.Operations.Connections do
# Determines the ship size for a connection, applying wormhole-specific rules
# for C1, C13, and C4⇄NS links, falling back to the caller's provided size or Large.
defp resolve_ship_size(type_val, ship_size_val, src_info, tgt_info) do
# If wormhole_type is provided (e.g., "H296"), infer ship size from it.
defp resolve_ship_size(type_val, ship_size_val, wormhole_type, src_info, tgt_info) do
case parse_type(type_val) do
@connection_type_wormhole ->
wormhole_ship_size(ship_size_val, src_info, tgt_info)
wormhole_ship_size(ship_size_val, wormhole_type, src_info, tgt_info)
_other ->
# Stargates and others just use the parsed or default size
@@ -108,15 +127,45 @@ defmodule WandererApp.Map.Operations.Connections do
# -- Wormholespecific sizing rules ----------------------------------------
defp wormhole_ship_size(ship_size_val, src, tgt) do
defp wormhole_ship_size(ship_size_val, wormhole_type, src, tgt) do
# First, try to infer from wormhole_type (e.g., "H296", "C5", etc.)
inferred_size = infer_ship_size_from_wormhole_type(wormhole_type)
# Parse ship_size_val early to handle string values correctly
parsed_ship_size = parse_ship_size(ship_size_val, nil)
cond do
c1_system?(src, tgt) -> @medium_ship_size
c13_system?(src, tgt) -> @small_ship_size
c4_to_ns?(src, tgt) -> @small_ship_size
true -> parse_ship_size(ship_size_val, @large_ship_size)
# If user explicitly provided a ship_size_val, use it
not is_nil(parsed_ship_size) ->
parsed_ship_size
# If we could infer from wormhole_type, use that
not is_nil(inferred_size) ->
inferred_size
# Otherwise fall back to system class rules
c1_system?(src, tgt) ->
@medium_ship_size
c13_system?(src, tgt) ->
@small_ship_size
c4_to_ns?(src, tgt) ->
@small_ship_size
true ->
@large_ship_size
end
end
# Infer ship size from wormhole type name using EVE static data
defp infer_ship_size_from_wormhole_type(nil), do: nil
defp infer_ship_size_from_wormhole_type(""), do: nil
defp infer_ship_size_from_wormhole_type("K162"), do: nil
defp infer_ship_size_from_wormhole_type(wormhole_type) do
WandererApp.Utils.EVEUtil.get_wh_size(wormhole_type)
end
defp c1_system?(%{system_class: @c1_system_class}, _), do: true
defp c1_system?(_, %{system_class: @c1_system_class}), do: true
defp c1_system?(_, _), do: false
@@ -162,6 +211,9 @@ defmodule WandererApp.Map.Operations.Connections do
defp parse_type(_), do: @connection_type_wormhole
defp maybe_add_extra(map, _key, nil), do: map
defp maybe_add_extra(map, key, value), do: Map.put(map, key, value)
defp parse_int(nil, field), do: {:error, {:missing_field, field}}
defp parse_int(val, _) when is_integer(val), do: {:ok, val}

View File

@@ -5,8 +5,10 @@ defmodule WandererApp.Map.Operations.Signatures do
require Logger
alias WandererApp.Map.Operations
alias WandererApp.Map.Operations.Connections
alias WandererApp.Api.{Character, MapSystem, MapSystemSignature}
alias WandererApp.Map.Server
alias WandererApp.Utils.EVEUtil
@spec validate_character_eve_id(map() | nil, String.t()) ::
{:ok, String.t()} | {:error, :invalid_character} | {:error, :unexpected_error}
@@ -78,7 +80,7 @@ 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} <- ensure_system_on_map(map_id, solar_system_id, user_id, char_id) do
attrs =
params
|> Map.put("system_id", system.id)
@@ -94,6 +96,21 @@ defmodule WandererApp.Map.Operations.Signatures do
delete_connection_with_sigs: false
}) do
:ok ->
# Handle linked_system_id if provided - auto-add system and create/update connection
linked_system_id = Map.get(params, "linked_system_id")
wormhole_type = Map.get(params, "type")
if is_integer(linked_system_id) and linked_system_id != solar_system_id do
handle_linked_system(
map_id,
solar_system_id,
linked_system_id,
wormhole_type,
user_id,
char_id
)
end
# Try to fetch the created signature to return with proper fields
with {:ok, sigs} <-
MapSystemSignature.by_system_id_and_eve_ids(system.id, [attrs["eve_id"]]),
@@ -129,6 +146,13 @@ defmodule WandererApp.Map.Operations.Signatures do
Logger.error("[create_signature] Unexpected error during character validation")
{:error, :unexpected_error}
{:error, :invalid_solar_system} ->
Logger.error(
"[create_signature] Invalid solar_system_id: #{solar_system_id} (not a valid EVE system)"
)
{:error, :invalid_solar_system}
_ ->
Logger.error(
"[create_signature] System not found for solar_system_id: #{solar_system_id}"
@@ -147,6 +171,203 @@ defmodule WandererApp.Map.Operations.Signatures do
def create_signature(_conn, _params), do: {:error, :missing_params}
# Check cache (not DB) to ensure system is actually visible on the map.
@spec ensure_system_on_map(String.t(), integer(), String.t(), String.t()) ::
{:ok, map()} | {:error, atom()}
defp ensure_system_on_map(map_id, solar_system_id, user_id, char_id) do
case WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_id}) do
nil -> add_system_to_map(map_id, solar_system_id, user_id, char_id)
system -> {:ok, system}
end
end
@spec add_system_to_map(String.t(), integer(), String.t(), String.t()) ::
{:ok, map()} | {:error, atom()}
defp add_system_to_map(map_id, solar_system_id, user_id, char_id) do
with {:ok, static_info} when not is_nil(static_info) <-
WandererApp.CachedInfo.get_system_static_info(solar_system_id),
:ok <-
Server.add_system(
map_id,
%{solar_system_id: solar_system_id, coordinates: nil},
user_id,
char_id
),
system when not is_nil(system) <- fetch_system_after_add(map_id, solar_system_id) do
Logger.info("[create_signature] Auto-added system #{solar_system_id} to map #{map_id}")
{:ok, system}
else
{:ok, nil} ->
{:error, :invalid_solar_system}
{:error, _} ->
{:error, :invalid_solar_system}
nil ->
Logger.error("[add_system_to_map] Failed to fetch system after add")
{:error, :system_add_failed}
error ->
Logger.error("[add_system_to_map] Failed to add system: #{inspect(error)}")
{:error, :system_add_failed}
end
end
defp fetch_system_after_add(map_id, solar_system_id) do
case WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_id}) do
nil ->
case MapSystem.read_by_map_and_solar_system(%{
map_id: map_id,
solar_system_id: solar_system_id
}) do
{:ok, system} -> system
_ -> nil
end
system ->
system
end
end
# Handles the linked_system_id logic: auto-adds the linked system and creates/updates connection
@spec handle_linked_system(
String.t(),
integer(),
integer(),
String.t() | nil,
String.t(),
String.t()
) :: :ok | {:error, atom()}
defp handle_linked_system(
map_id,
source_system_id,
linked_system_id,
wormhole_type,
user_id,
char_id
) do
# Ensure the linked system is on the map
case ensure_system_on_map(map_id, linked_system_id, user_id, char_id) do
{:ok, _linked_system} ->
# Check if connection exists between the systems
case Connections.get_connection_by_systems(map_id, source_system_id, linked_system_id) do
{:ok, nil} ->
# No connection exists, create one
create_connection_with_wormhole_type(
map_id,
source_system_id,
linked_system_id,
wormhole_type,
char_id
)
{:ok, _existing_conn} ->
# Connection exists, update wormhole type if provided
update_connection_wormhole_type(
map_id,
source_system_id,
linked_system_id,
wormhole_type
)
{:error, reason} ->
Logger.warning(
"[handle_linked_system] Failed to check connection: #{inspect(reason)}"
)
{:error, :connection_check_failed}
end
{:error, :invalid_solar_system} ->
Logger.warning(
"[handle_linked_system] Invalid linked_system_id: #{linked_system_id} (not a valid EVE system)"
)
{:error, :invalid_linked_system}
{:error, reason} ->
Logger.warning("[handle_linked_system] Failed to add linked system: #{inspect(reason)}")
{:error, :linked_system_add_failed}
end
end
# Creates a connection between two systems with the specified wormhole type
@spec create_connection_with_wormhole_type(
String.t(),
integer(),
integer(),
String.t() | nil,
String.t()
) :: :ok | {:error, atom()}
defp create_connection_with_wormhole_type(
map_id,
source_system_id,
target_system_id,
wormhole_type,
char_id
) do
conn_attrs = %{
"solar_system_source" => source_system_id,
"solar_system_target" => target_system_id,
"type" => 0,
"wormhole_type" => wormhole_type
}
case Connections.create(conn_attrs, map_id, char_id) do
{:ok, :created} ->
Logger.info(
"[create_signature] Auto-created connection #{source_system_id} <-> #{target_system_id} (type: #{wormhole_type || "unknown"})"
)
:ok
{:skip, :exists} ->
# Connection already exists (race condition), update it instead
update_connection_wormhole_type(map_id, source_system_id, target_system_id, wormhole_type)
error ->
Logger.warning(
"[create_connection_with_wormhole_type] Failed to create connection: #{inspect(error)}"
)
{:error, :connection_create_failed}
end
end
# Updates the wormhole type and ship size for an existing connection
@spec update_connection_wormhole_type(String.t(), integer(), integer(), String.t() | nil) ::
:ok | {:error, atom()}
defp update_connection_wormhole_type(_map_id, _source, _target, nil), do: :ok
defp update_connection_wormhole_type(map_id, source_system_id, target_system_id, wormhole_type) do
# Get ship size from wormhole type
ship_size_type = EVEUtil.get_wh_size(wormhole_type)
if not is_nil(ship_size_type) do
case Server.update_connection_ship_size_type(map_id, %{
solar_system_source_id: source_system_id,
solar_system_target_id: target_system_id,
ship_size_type: ship_size_type
}) do
:ok ->
Logger.info(
"[create_signature] Updated connection #{source_system_id} <-> #{target_system_id} ship_size_type to #{ship_size_type} (wormhole: #{wormhole_type})"
)
:ok
error ->
Logger.warning(
"[update_connection_wormhole_type] Failed to update ship size: #{inspect(error)}"
)
{:error, :ship_size_update_failed}
end
else
:ok
end
end
@spec update_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
def update_signature(
%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} =
@@ -248,4 +469,161 @@ defmodule WandererApp.Map.Operations.Signatures do
end
def delete_signature(_conn, _sig_id), do: {:error, :missing_params}
@doc """
Links a signature to a target system, creating the association between
the signature and the wormhole connection to that system.
This also:
- Updates the signature's group to "Wormhole"
- Sets the target system's linked_sig_eve_id
- Copies temporary_name from signature to target system
- Updates connection time_status and ship_size_type from signature data
"""
@spec link_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
def link_signature(
%{assigns: %{map_id: map_id}} = _conn,
sig_id,
%{"solar_system_target" => solar_system_target}
)
when is_integer(solar_system_target) do
with {:ok, signature} <- MapSystemSignature.by_id(sig_id),
{:ok, source_system} <- MapSystem.by_id(signature.system_id),
true <- source_system.map_id == map_id,
target_system when not is_nil(target_system) <-
WandererApp.Map.find_system_by_location(map_id, %{solar_system_id: solar_system_target}) do
# Update signature group to Wormhole and set linked_system_id
{:ok, updated_signature} =
signature
|> MapSystemSignature.update_group!(%{group: "Wormhole"})
|> MapSystemSignature.update_linked_system(%{linked_system_id: solar_system_target})
# Only update target system if it doesn't already have a linked signature
if is_nil(target_system.linked_sig_eve_id) do
# Set the target system's linked_sig_eve_id
Server.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: solar_system_target,
linked_sig_eve_id: signature.eve_id
})
# Copy temporary_name if present
if not is_nil(signature.temporary_name) do
Server.update_system_temporary_name(map_id, %{
solar_system_id: solar_system_target,
temporary_name: signature.temporary_name
})
end
# Update connection time_status from signature custom_info
signature_time_status =
if not is_nil(signature.custom_info) do
case Jason.decode(signature.custom_info) do
{:ok, map} -> Map.get(map, "time_status")
{:error, _} -> nil
end
else
nil
end
if not is_nil(signature_time_status) do
Server.update_connection_time_status(map_id, %{
solar_system_source_id: source_system.solar_system_id,
solar_system_target_id: solar_system_target,
time_status: signature_time_status
})
end
# Update connection ship_size_type from signature wormhole type
signature_ship_size_type = EVEUtil.get_wh_size(signature.type)
if not is_nil(signature_ship_size_type) do
Server.update_connection_ship_size_type(map_id, %{
solar_system_source_id: source_system.solar_system_id,
solar_system_target_id: solar_system_target,
ship_size_type: signature_ship_size_type
})
end
end
# Broadcast update
Server.Impl.broadcast!(map_id, :signatures_updated, source_system.solar_system_id)
# Return the updated signature
result =
updated_signature
|> Map.from_struct()
|> Map.put(:solar_system_id, source_system.solar_system_id)
|> Map.drop([:system_id, :__meta__, :system, :aggregates, :calculations])
{:ok, result}
else
false ->
{:error, :not_found}
nil ->
{:error, :target_system_not_found}
{:error, %Ash.Error.Query.NotFound{}} ->
{:error, :not_found}
err ->
Logger.error("[link_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def link_signature(_conn, _sig_id, %{"solar_system_target" => _}),
do: {:error, :invalid_solar_system_target}
def link_signature(_conn, _sig_id, _params), do: {:error, :missing_params}
@doc """
Unlinks a signature from its target system.
"""
@spec unlink_signature(Plug.Conn.t(), String.t()) :: {:ok, map()} | {:error, atom()}
def unlink_signature(%{assigns: %{map_id: map_id}} = _conn, sig_id) do
with {:ok, signature} <- MapSystemSignature.by_id(sig_id),
{:ok, source_system} <- MapSystem.by_id(signature.system_id),
:ok <- if(source_system.map_id == map_id, do: :ok, else: {:error, :not_found}),
:ok <- if(not is_nil(signature.linked_system_id), do: :ok, else: {:error, :not_linked}) do
# Clear the target system's linked_sig_eve_id
Server.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: signature.linked_system_id,
linked_sig_eve_id: nil
})
# Clear the signature's linked_system_id using the wrapper for logging
{:ok, updated_signature} =
Server.SignaturesImpl.update_signature_linked_system(signature, %{
linked_system_id: nil
})
# Broadcast update
Server.Impl.broadcast!(map_id, :signatures_updated, source_system.solar_system_id)
# Return the updated signature
result =
updated_signature
|> Map.from_struct()
|> Map.put(:solar_system_id, source_system.solar_system_id)
|> Map.drop([:system_id, :__meta__, :system, :aggregates, :calculations])
{:ok, result}
else
{:error, :not_found} ->
{:error, :not_found}
{:error, :not_linked} ->
{:error, :not_linked}
{:error, %Ash.Error.Query.NotFound{}} ->
{:error, :not_found}
err ->
Logger.error("[unlink_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def unlink_signature(_conn, _sig_id), do: {:error, :missing_params}
end

View File

@@ -36,7 +36,8 @@ defmodule WandererApp.Map.Operations.Systems do
# Private helper for batch upsert
defp create_system_batch(%{map_id: map_id, user_id: user_id, char_id: char_id}, params) do
with {:ok, solar_system_id} <- fetch_system_id(params) do
update_existing = fetch_update_existing(params, false)
# Default to true so re-submitting with new position updates the system
update_existing = fetch_update_existing(params, true)
map_id
|> WandererApp.Map.check_location(%{solar_system_id: solar_system_id})
@@ -46,9 +47,13 @@ defmodule WandererApp.Map.Operations.Systems do
{:error, :already_exists} ->
if update_existing do
do_update_system(map_id, user_id, char_id, solar_system_id, params)
# Mark as skip so it counts as "updated" not "created"
case do_update_system(map_id, user_id, char_id, solar_system_id, params) do
{:ok, _} -> {:skip, :updated}
error -> error
end
else
:ok
{:skip, :already_exists}
end
end
end
@@ -200,16 +205,22 @@ defmodule WandererApp.Map.Operations.Systems do
defp normalize_coordinates(%{"coordinates" => %{"x" => x, "y" => y}})
when is_number(x) and is_number(y),
do: %{x: x, y: y}
do: %{"x" => x, "y" => y}
defp normalize_coordinates(%{coordinates: %{x: x, y: y}}) when is_number(x) and is_number(y),
do: %{x: x, y: y}
do: %{"x" => x, "y" => y}
defp normalize_coordinates(params) do
%{
x: params |> Map.get("position_x", Map.get(params, :position_x, 0)),
y: params |> Map.get("position_y", Map.get(params, :position_y, 0))
}
x = params |> Map.get("position_x", Map.get(params, :position_x))
y = params |> Map.get("position_y", Map.get(params, :position_y))
# Only return coordinates if both x and y are provided
# Otherwise return nil to let the server use auto-positioning
if is_number(x) and is_number(y) do
%{"x" => x, "y" => y}
else
nil
end
end
defp apply_system_updates(map_id, system_id, attrs, %{x: x, y: y}) do

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
@@ -594,6 +595,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
time_status = get_extra_info(extra_info, "time_status", time_status)
mass_status = get_extra_info(extra_info, "mass_status", 0)
locked = get_extra_info(extra_info, "locked", false)
wormhole_type = get_extra_info(extra_info, "wormhole_type", nil)
{:ok, connection} =
WandererApp.MapConnectionRepo.create(%{
@@ -604,7 +606,8 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
ship_size_type: ship_size_type,
time_status: time_status,
mass_status: mass_status,
locked: locked
locked: locked,
wormhole_type: wormhole_type
})
if connection_type == @connection_type_wormhole do
@@ -914,8 +917,10 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
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
# No stargate = wormhole connection
{:ok, []} -> true
# Stargate exists or error
_ -> false
end
else
false
@@ -958,6 +963,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,23 @@ 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 +113,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

@@ -109,8 +109,10 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
nil ->
MapSystemSignature.create!(sig)
_ ->
:noop
existing ->
# If signature already exists, update it instead of ignoring
# This handles the case where frontend sends existing sigs as "added"
apply_update_signature(map_id, existing, sig)
end
end)
@@ -167,19 +169,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 +199,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,
@@ -310,6 +329,7 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
group: sig["group"],
type: Map.get(sig, "type"),
custom_info: Map.get(sig, "custom_info"),
linked_system_id: Map.get(sig, "linked_system_id"),
# Use character_eve_id from sig if provided, otherwise use the default
character_eve_id: Map.get(sig, "character_eve_id", character_eve_id),
deleted: false

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

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

View File

@@ -487,10 +487,17 @@ defmodule WandererAppWeb.MapSystemAPIController do
)
def create(conn, params) do
# Support both batch format {"systems": [...], "connections": [...]}
# and single system format {"solar_system_id": ..., ...}
# Support multiple formats:
# 1. Batch format: {"systems": [...], "connections": [...]}
# 2. Wrapped batch format: {"data": {"systems": [...], "connections": [...]}}
# 3. Single system format: {"solar_system_id": ..., ...}
{systems, connections} =
cond do
Map.has_key?(params, "data") and is_map(params["data"]) ->
# Wrapped batch format - extract from data wrapper
data = params["data"]
{Map.get(data, "systems", []), Map.get(data, "connections", [])}
Map.has_key?(params, "systems") ->
# Batch format
{Map.get(params, "systems", []), Map.get(params, "connections", [])}

View File

@@ -190,9 +190,37 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
The `character_eve_id` field is optional. If provided, it must be a valid character
that exists in the database, otherwise a 422 error will be returned. If not provided,
the signature will be associated with the map owner's character.
## Auto-add System Behavior
If the `solar_system_id` is not already on the map, it will be automatically added.
The system must be a valid EVE Online solar system ID.
## Linked System and Connection Behavior
If `linked_system_id` is provided (for wormhole signatures):
- The linked system will be automatically added to the map if not present
- A connection will be created between the source and linked systems if one doesn't exist
- If a connection already exists, its ship size will be updated based on the wormhole `type`
- The wormhole `type` (e.g., "H296", "C2", "K162") is used to determine connection ship size:
- H296 → XL/Freighter size (1B kg max mass)
- N770, D845 → Large size (375M kg max mass)
- etc.
"""
operation(:create,
summary: "Create a new signature",
description: """
Creates a new cosmic signature in the specified solar system.
**Auto-add behavior**: If the solar_system_id is not already on the map, it will be
automatically added. The system must be a valid EVE Online solar system ID.
**Linked system behavior**: If linked_system_id is provided:
- The linked system is auto-added to the map if not present
- A wormhole connection is auto-created between the systems
- The connection's ship_size_type is inferred from the wormhole type (e.g., H296 → XL)
- If the connection already exists, its ship size is updated based on the wormhole type
""",
parameters: [
map_identifier: [
in: :path,
@@ -218,7 +246,7 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
error: %OpenApiSpex.Schema{
type: :string,
description:
"Error type (e.g., 'invalid_character', 'system_not_found', 'missing_params')"
"Error type (e.g., 'invalid_character', 'invalid_solar_system', 'missing_params')"
}
},
example: %{error: "invalid_character"}
@@ -311,4 +339,117 @@ defmodule WandererAppWeb.MapSystemSignatureAPIController do
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
Link a signature to a target system.
This creates the association between a wormhole signature and the system it leads to.
It also updates the connection's time_status and ship_size_type based on the signature data.
"""
operation(:link,
summary: "Link a signature to a target system",
description: """
Links a wormhole signature to its destination system. This operation:
- Sets the signature's linked_system_id to the target system
- Updates the signature's group to "Wormhole"
- Sets the target system's linked_sig_eve_id (if not already set)
- Copies temporary_name from signature to target system
- Updates the connection's time_status and ship_size_type from signature data
""",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true
],
id: [in: :path, description: "Signature UUID", type: :string, required: true]
],
request_body:
{"Link request", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{
solar_system_target: %OpenApiSpex.Schema{
type: :integer,
description: "Target solar system ID to link to"
}
},
required: [:solar_system_target],
example: %{solar_system_target: 31_001_922}
}},
responses: [
ok:
{"Linked signature", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{data: @signature_schema},
example: %{data: @signature_schema.example}
}},
unprocessable_entity:
{"Error", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{
type: :string,
description: "Error type"
}
},
example: %{error: "target_system_not_found"}
}}
]
)
def link(conn, %{"id" => id} = params) do
case MapOperations.link_signature(conn, id, params) do
{:ok, sig} -> json(conn, %{data: sig})
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
Unlink a signature from its target system.
"""
operation(:unlink,
summary: "Unlink a signature from its target system",
description: "Removes the link between a signature and its destination system.",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true
],
id: [in: :path, description: "Signature UUID", type: :string, required: true]
],
responses: [
ok:
{"Unlinked signature", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{data: @signature_schema},
example: %{data: Map.put(@signature_schema.example, :linked_system_id, nil)}
}},
unprocessable_entity:
{"Error", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{
type: :string,
description: "Error type"
}
},
example: %{error: "not_linked"}
}}
]
)
def unlink(conn, %{"id" => id}) do
case MapOperations.unlink_signature(conn, id) do
{:ok, sig} -> json(conn, %{data: sig})
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
end

View File

@@ -29,6 +29,36 @@
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

@@ -299,6 +299,8 @@ defmodule WandererAppWeb.Router do
resources "/structures", MapSystemStructureAPIController, except: [:new, :edit]
get "/structure-timers", MapSystemStructureAPIController, :structure_timers
resources "/signatures", MapSystemSignatureAPIController, except: [:new, :edit]
post "/signatures/:id/link", MapSystemSignatureAPIController, :link
delete "/signatures/:id/link", MapSystemSignatureAPIController, :unlink
get "/user-characters", MapAPIController, :show_user_characters
get "/tracked-characters", MapAPIController, :show_tracked_characters
end

View File

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

View File

@@ -1,48 +0,0 @@
%{
title: "Event: PLEX Giveaway Announcement",
author: "Wanderer Team",
cover_image_uri: "/images/news/2025/12-18-advent-giveaway/cover.webp",
tags: ~w(event giveaway challenge christmas advent partnership),
description: "Join our Advent Christmas Giveaway Challenge! Be the fastest to claim your reward!"
}
---
![Christmas Giveaway Challenge](/images/news/2025/12-18-advent-giveaway/cover.webp "Christmas Giveaway Challenge")
### Event Details
- **Event Name:** Advent Christmas Giveaway
- **Event Link:** [Advent Christmas Giveaway](https://eventcortex.com/events/invite/cYdBywu1ygfVS3UN6ZZcmDzL1q85aDmH)
### The Season of Giving
This holiday season, we're spreading some festive cheer with a special event for our community: the **Advent Christmas Giveaway Challenge**!
---
### Tips for Participants
- **Be Ready:** Know the reveal time and be online a few minutes early.
---
### FINAL DAY
🎉 PLEX Giveaway Announcement! 🎉
Weve decided to give away 500 PLEX!
At each secret location, youll find 100 PLEX waiting for you (along with a skin 😉).
There will be a total of 5 secret locations —
see you on the spot! 🚀
Good luck, and may the fastest capsuleer win!
---
Fly safe and happy holidays,
**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

View File

@@ -26,9 +26,9 @@ defmodule WandererApp.Map.CorporationChangePermissionTest do
setup :verify_on_exit!
@test_corp_id_a 98000001
@test_corp_id_b 98000002
@test_alliance_id_a 99000001
@test_corp_id_a 98_000_001
@test_corp_id_b 98_000_002
@test_alliance_id_a 99_000_001
setup do
# Configure the PubSubMock to forward to real Phoenix.PubSub for broadcast testing
@@ -70,7 +70,8 @@ defmodule WandererApp.Map.CorporationChangePermissionTest do
simulate_corporation_change(character, @test_corp_id_b)
# Should receive :update_permissions broadcast
assert_receive :update_permissions, 1000,
assert_receive :update_permissions,
1000,
"Should receive :update_permissions when corporation changes"
end
@@ -94,7 +95,8 @@ defmodule WandererApp.Map.CorporationChangePermissionTest do
simulate_alliance_removal(character)
# Should receive :update_permissions broadcast
assert_receive :update_permissions, 1000,
assert_receive :update_permissions,
1000,
"Should receive :update_permissions when alliance is removed"
end
end

View File

@@ -116,6 +116,7 @@ defmodule WandererApp.Map.Server.AclScopesPropagationTest do
# 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

View File

@@ -577,35 +577,55 @@ defmodule WandererApp.Map.Server.MapScopesTest do
# 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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
assert ConnectionsImpl.is_connection_valid([:wormholes], @pochven_id, @ns_system_id) ==
true,
"Pochven->Null should be valid"
end
end

View File

@@ -464,9 +464,8 @@ defmodule WandererApp.Map.Operations.SignaturesTest do
Task.async(fn ->
params = %{"solar_system_id" => 30_000_140 + i}
result = Signatures.create_signature(conn, params)
# We expect either system_not_found (system doesn't exist in test)
# or the MapTestHelpers would have caught the map server error
assert {:error, :system_not_found} = result
# Fake solar_system_ids aren't in EVE static data, so we get :invalid_solar_system
assert {:error, :invalid_solar_system} = result
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