diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9aad4a0..2d1c3840 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,24 @@
+## [v1.98.1](https://github.com/wanderer-industries/wanderer/compare/v1.98.0...v1.98.1) (2026-04-10)
+
+
+
+
+### Bug Fixes:
+
+* signatures: Back linked signatures not cleared old connection statuses
+
+## [v1.98.0](https://github.com/wanderer-industries/wanderer/compare/v1.97.5...v1.98.0) (2026-04-06)
+
+
+
+
+### Features:
+
+* core: added character profile pages support
+
## [v1.97.5](https://github.com/wanderer-industries/wanderer/compare/v1.97.4...v1.97.5) (2026-03-26)
diff --git a/assets/css/app.css b/assets/css/app.css
index b553c4fe..6df4f864 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -1,5 +1,6 @@
@layer tailwind-base, primereact, tailwind-utilities;
+@import 'quill/dist/quill.snow.css';
@import 'primereact/resources/themes/arya-blue/theme.css' layer(primereact);
/*@import 'primereact/resources/themes/bootstrap4-dark-blue/theme.css' layer(primereact);*/
@@ -1025,3 +1026,77 @@ body > div:first-of-type {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(236, 72, 153, 0.3);
}
+
+/* Quill editor dark theme overrides */
+.ql-toolbar.ql-snow {
+ border-color: #44403c;
+ background-color: #1c1917;
+}
+
+.ql-container.ql-snow {
+ border-color: #44403c;
+ background-color: #0c0a09;
+ color: #e7e5e4;
+ min-height: 200px;
+}
+
+.ql-editor.ql-blank::before {
+ color: #78716c;
+ font-style: italic;
+}
+
+.ql-snow .ql-stroke {
+ stroke: #a8a29e;
+}
+
+.ql-snow .ql-fill,
+.ql-snow .ql-stroke.ql-fill {
+ fill: #a8a29e;
+}
+
+.ql-snow .ql-picker {
+ color: #a8a29e;
+}
+
+.ql-snow .ql-picker-options {
+ background-color: #1c1917;
+ border-color: #44403c;
+}
+
+.ql-snow .ql-picker-label:hover,
+.ql-snow .ql-picker-label.ql-active,
+.ql-snow .ql-picker-item:hover,
+.ql-snow .ql-picker-item.ql-selected {
+ color: #e7e5e4;
+}
+
+.ql-snow .ql-picker-label:hover .ql-stroke,
+.ql-snow .ql-picker-label.ql-active .ql-stroke,
+.ql-snow button:hover .ql-stroke,
+.ql-snow button.ql-active .ql-stroke {
+ stroke: #e7e5e4;
+}
+
+.ql-snow .ql-picker-label:hover .ql-fill,
+.ql-snow .ql-picker-label.ql-active .ql-fill,
+.ql-snow button:hover .ql-fill,
+.ql-snow button.ql-active .ql-fill {
+ fill: #e7e5e4;
+}
+
+.ql-snow a {
+ color: #60a5fa;
+}
+
+.ql-tooltip {
+ background-color: #1c1917 !important;
+ border-color: #44403c !important;
+ color: #e7e5e4 !important;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important;
+}
+
+.ql-tooltip input[type="text"] {
+ background-color: #0c0a09;
+ border-color: #44403c;
+ color: #e7e5e4;
+}
diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx
index 9d07a74b..092de26f 100644
--- a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx
+++ b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx
@@ -44,17 +44,6 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
switch (group) {
case SignatureGroup.Wormhole:
- if (values.linked_system) {
- await outCommand({
- type: OutCommand.linkSignatureToSystem,
- data: {
- signature_eve_id: signatureData.eve_id,
- solar_system_source: systemId,
- solar_system_target: values.linked_system,
- },
- });
- }
-
out = {
...out,
custom_info: JSON.stringify({
@@ -125,6 +114,20 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
},
});
+ // Link after updateSignatures so the K162's linked_system_id is still nil
+ // during signature update, preventing maybe_update_connection_* from
+ // resetting connection properties set by the forward signature.
+ if (group === SignatureGroup.Wormhole && values.linked_system) {
+ await outCommand({
+ type: OutCommand.linkSignatureToSystem,
+ data: {
+ signature_eve_id: signatureData.eve_id,
+ solar_system_source: systemId,
+ solar_system_target: values.linked_system,
+ },
+ });
+ }
+
signatureForm.reset();
onHide();
},
diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts
index e0781201..93e7863f 100755
--- a/assets/js/hooks/index.ts
+++ b/assets/js/hooks/index.ts
@@ -9,6 +9,7 @@ import DownloadJson from './downloadJson';
import NewVersionUpdate from './newVersionUpdate';
import MapAction from './maps/mapAction';
import ShowCharactersAddAlert from './showCharactersAddAlert';
+import WysiwygEditor from './wysiwygEditor';
export default {
DownloadJson,
@@ -22,4 +23,5 @@ export default {
CopyToClipboard,
NewVersionUpdate,
ShowCharactersAddAlert,
+ WysiwygEditor,
};
diff --git a/assets/js/hooks/wysiwygEditor.ts b/assets/js/hooks/wysiwygEditor.ts
new file mode 100644
index 00000000..0ff189eb
--- /dev/null
+++ b/assets/js/hooks/wysiwygEditor.ts
@@ -0,0 +1,46 @@
+import Quill from "quill";
+import TurndownService from "turndown";
+
+const WysiwygEditor = {
+ mounted() {
+ const view = this as any;
+ const editorContainer = view.el.querySelector(".ql-editor-container");
+ if (!editorContainer) return;
+
+ const toolbarOptions = [
+ ["bold", "italic", "underline", "strike"],
+ ["blockquote", "link"],
+ [{ list: "ordered" }, { list: "bullet" }],
+ [{ header: [1, 2, 3, false] }],
+ ["clean"],
+ ];
+
+ const quill = new Quill(editorContainer, {
+ theme: "snow",
+ modules: { toolbar: toolbarOptions },
+ });
+
+ const initialContent = editorContainer.getAttribute("data-initial-content");
+ if (initialContent) {
+ quill.clipboard.dangerouslyPasteHTML(initialContent);
+ }
+
+ quill.on("text-change", () => {
+ view.pushEvent("content-text-change", { content: quill.getText() });
+ });
+
+ view.handleEvent("request_editor_content", () => {
+ const html = quill.root.innerHTML;
+
+ if (quill.getText().trim() === "") {
+ view.pushEvent("editor_content_markdown", { markdown: "" });
+ } else {
+ const turndownService = new TurndownService();
+ const markdown = turndownService.turndown(html);
+ view.pushEvent("editor_content_markdown", { markdown: markdown });
+ }
+ });
+ },
+};
+
+export default WysiwygEditor;
diff --git a/assets/package.json b/assets/package.json
index 98386a5c..259c9a24 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -43,6 +43,8 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tailwindcss": "^3.3.6",
+ "quill": "^2.0.3",
+ "turndown": "^7.2.0",
"topbar": "^3.0.0",
"use-local-storage-state": "^19.3.1"
},
diff --git a/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp b/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp
deleted file mode 100644
index 7bd653e5..00000000
Binary files a/assets/static/images/news/2026/01-05-weekly-giveaway/cover.webp and /dev/null differ
diff --git a/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg b/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg
deleted file mode 100644
index cffd0ca8..00000000
Binary files a/assets/static/images/news/2026/02-12-eve-creator-awards/cover.jpg and /dev/null differ
diff --git a/assets/yarn.lock b/assets/yarn.lock
index fd94574b..204205a1 100644
--- a/assets/yarn.lock
+++ b/assets/yarn.lock
@@ -922,6 +922,11 @@
resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8"
integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==
+"@mixmark-io/domino@^2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@mixmark-io/domino/-/domino-2.2.0.tgz#4e8ec69bf1afeb7a14f0628b7e2c0f35bdb336c3"
+ integrity sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==
+
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@@ -3000,6 +3005,11 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
+eventemitter3@^5.0.1:
+ version "5.0.4"
+ resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb"
+ integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==
+
execa@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
@@ -3041,7 +3051,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
-fast-diff@^1.1.2:
+fast-diff@^1.1.2, fast-diff@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0"
integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
@@ -4321,11 +4331,21 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
+lodash-es@^4.17.21:
+ version "4.18.1"
+ resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
+ integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
+
lodash.castarray@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==
+lodash.clonedeep@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
+ integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
+
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
@@ -5146,6 +5166,11 @@ package-json-from-dist@^1.0.0:
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
+parchment@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/parchment/-/parchment-3.0.0.tgz#2e3a4ada454e1206ae76ea7afcb50e9fb517e7d6"
+ integrity sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==
+
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@@ -5451,6 +5476,25 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+quill-delta@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-5.1.0.tgz#1c4bc08f7c8e5cc4bdc88a15a1a70c1cc72d2b48"
+ integrity sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==
+ dependencies:
+ fast-diff "^1.3.0"
+ lodash.clonedeep "^4.5.0"
+ lodash.isequal "^4.5.0"
+
+quill@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/quill/-/quill-2.0.3.tgz#752765a31d5a535cdc5717dc49d4e50099365eb1"
+ integrity sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==
+ dependencies:
+ eventemitter3 "^5.0.1"
+ lodash-es "^4.17.21"
+ parchment "^3.0.0"
+ quill-delta "^5.1.0"
+
react-dom@18.3.1, react-dom@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
@@ -5987,16 +6031,7 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
-"string-width-cjs@npm:string-width@^4.2.0":
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
-string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -6081,14 +6116,7 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -6296,6 +6324,13 @@ ts-jest@^29.1.2:
type-fest "^4.41.0"
yargs-parser "^21.1.1"
+turndown@^7.2.0:
+ version "7.2.4"
+ resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.2.4.tgz#42d98202aefa8c188c997b586bc6da78bdf27ea2"
+ integrity sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==
+ dependencies:
+ "@mixmark-io/domino" "^2.2.0"
+
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@@ -6618,16 +6653,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
-wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
diff --git a/lib/wanderer_app/api/character.ex b/lib/wanderer_app/api/character.ex
index 239b753f..03b6064d 100644
--- a/lib/wanderer_app/api/character.ex
+++ b/lib/wanderer_app/api/character.ex
@@ -41,6 +41,7 @@ defmodule WandererApp.Api.Character do
)
define(:admin_all, action: :admin_all)
+ define(:update_description, action: :update_description)
end
actions do
@@ -141,6 +142,11 @@ defmodule WandererApp.Api.Character do
accept([:eve_wallet_balance])
end
+
+ update :update_description do
+ accept([:description])
+ require_atomic? false
+ end
end
cloak do
@@ -211,6 +217,11 @@ defmodule WandererApp.Api.Character do
attribute :eve_wallet_balance, :float
attribute :tracking_pool, :string
+ attribute :description, :string do
+ allow_nil? true
+ constraints max_length: 10_000
+ end
+
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
end
diff --git a/lib/wanderer_app/api/map_transaction.ex b/lib/wanderer_app/api/map_transaction.ex
index ca6928f1..1cb96884 100644
--- a/lib/wanderer_app/api/map_transaction.ex
+++ b/lib/wanderer_app/api/map_transaction.ex
@@ -22,6 +22,7 @@ defmodule WandererApp.Api.MapTransaction do
define(:by_user, action: :by_user)
define(:create, action: :create)
define(:top_donators, action: :top_donators)
+ define(:server_top_donators, action: :server_top_donators)
end
actions do
@@ -77,6 +78,31 @@ defmodule WandererApp.Api.MapTransaction do
|> then(&{:ok, &1})
end
end
+
+ action :server_top_donators, {:array, :struct} do
+ argument(:after, :utc_datetime, allow_nil?: true)
+
+ run fn input, _context ->
+ base =
+ from(t in __MODULE__,
+ where: t.type == :in and not is_nil(t.user_id),
+ group_by: [t.user_id],
+ select: %{user_id: t.user_id, total_amount: sum(t.amount)},
+ order_by: [desc: sum(t.amount)],
+ limit: 10
+ )
+
+ query =
+ case input.arguments[:after] do
+ nil -> base
+ after_date -> base |> where([t], t.inserted_at >= ^after_date)
+ end
+
+ query
+ |> WandererApp.Repo.all()
+ |> then(&{:ok, &1})
+ end
+ end
end
attributes do
diff --git a/lib/wanderer_app/map/operations/signatures.ex b/lib/wanderer_app/map/operations/signatures.ex
index c2917c02..b8828d33 100644
--- a/lib/wanderer_app/map/operations/signatures.ex
+++ b/lib/wanderer_app/map/operations/signatures.ex
@@ -552,15 +552,9 @@ defmodule WandererApp.Map.Operations.Signatures do
if not is_nil(forward_sig.custom_info) do
case Jason.decode(forward_sig.custom_info) do
{:ok, decoded} ->
- fwd_time =
- if is_nil(signature_time_status),
- do: Map.get(decoded, "time_status"),
- else: signature_time_status
-
- fwd_mass =
- if is_nil(signature_mass_status),
- do: Map.get(decoded, "mass_status"),
- else: signature_mass_status
+ # Always prefer forward sig values over K162 defaults
+ fwd_time = Map.get(decoded, "time_status") || signature_time_status
+ fwd_mass = Map.get(decoded, "mass_status") || signature_mass_status
{fwd_time, fwd_mass}
@@ -600,6 +594,29 @@ defmodule WandererApp.Map.Operations.Signatures do
mass_status: signature_mass_status
})
end
+
+ # Update K162's custom_info to match the resolved connection values
+ if not is_nil(signature_time_status) or not is_nil(signature_mass_status) do
+ updated_custom_info =
+ (signature.custom_info || "{}")
+ |> Jason.decode!()
+ |> then(fn decoded ->
+ decoded
+ |> then(fn d ->
+ if not is_nil(signature_time_status),
+ do: Map.put(d, "time_status", signature_time_status),
+ else: d
+ end)
+ |> then(fn d ->
+ if not is_nil(signature_mass_status),
+ do: Map.put(d, "mass_status", signature_mass_status),
+ else: d
+ end)
+ end)
+ |> Jason.encode!()
+
+ MapSystemSignature.update(signature, %{custom_info: updated_custom_info})
+ end
end
# Broadcast update
diff --git a/lib/wanderer_app_web.ex b/lib/wanderer_app_web.ex
index 084b5be6..858b4ff4 100644
--- a/lib/wanderer_app_web.ex
+++ b/lib/wanderer_app_web.ex
@@ -68,6 +68,13 @@ defmodule WandererAppWeb do
end
end
+ def blog_live_view do
+ live_view(
+ layout: {WandererAppWeb.Layouts, :blog},
+ container: {:div, class: ""}
+ )
+ end
+
def live_component do
quote do
use Phoenix.LiveComponent
diff --git a/lib/wanderer_app_web/components/layouts.ex b/lib/wanderer_app_web/components/layouts.ex
index c8dd2199..7de56f13 100644
--- a/lib/wanderer_app_web/components/layouts.ex
+++ b/lib/wanderer_app_web/components/layouts.ex
@@ -206,6 +206,7 @@ defmodule WandererAppWeb.Layouts do
>
+ <.link
+ navigate={~p"/characters/#{character.eve_id}"}
+ class="tooltip tooltip-bottom"
+ data-tip="View Profile"
+ >
+ <.icon name="hero-user-solid" class="w-4 h-4 hover:text-white" />
+
<.link
patch={~p"/characters/authorize"}
class="tooltip tooltip-bottom"
@@ -236,7 +243,9 @@
<:col :let={character} label="Name">
- {character.name}
+ <.link navigate={~p"/characters/#{character.eve_id}"} class="hover:text-white underline">
+ {character.name}
+
<:col :let={character} label="Corporation">
{character
diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex
index 5d6fee64..c95ee4e4 100644
--- a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex
+++ b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex
@@ -326,15 +326,9 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
if not is_nil(forward_sig.custom_info) do
decoded = forward_sig.custom_info |> Jason.decode!()
- fwd_time =
- if is_nil(signature_time_status),
- do: Map.get(decoded, "time_status"),
- else: signature_time_status
-
- fwd_mass =
- if is_nil(signature_mass_status),
- do: Map.get(decoded, "mass_status"),
- else: signature_mass_status
+ # Always prefer forward sig values over K162 defaults
+ fwd_time = Map.get(decoded, "time_status") || signature_time_status
+ fwd_mass = Map.get(decoded, "mass_status") || signature_mass_status
{fwd_time, fwd_mass}
else
@@ -373,6 +367,29 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
mass_status: signature_mass_status
})
end
+
+ # Update K162's custom_info to match the resolved connection values
+ if not is_nil(signature_time_status) or not is_nil(signature_mass_status) do
+ updated_custom_info =
+ (signature.custom_info || "{}")
+ |> Jason.decode!()
+ |> then(fn decoded ->
+ decoded
+ |> then(fn d ->
+ if not is_nil(signature_time_status),
+ do: Map.put(d, "time_status", signature_time_status),
+ else: d
+ end)
+ |> then(fn d ->
+ if not is_nil(signature_mass_status),
+ do: Map.put(d, "mass_status", signature_mass_status),
+ else: d
+ end)
+ end)
+ |> Jason.encode!()
+
+ WandererApp.Api.MapSystemSignature.update(signature, %{custom_info: updated_custom_info})
+ end
end
WandererApp.Map.Server.Impl.broadcast!(map_id, :signatures_updated, solar_system_source)
diff --git a/lib/wanderer_app_web/live/nav.ex b/lib/wanderer_app_web/live/nav.ex
index a1a28ed5..aec602dc 100755
--- a/lib/wanderer_app_web/live/nav.ex
+++ b/lib/wanderer_app_web/live/nav.ex
@@ -6,6 +6,7 @@ defmodule WandererAppWeb.Nav do
alias WandererAppWeb.{
AccessListsLive,
+ CharacterProfileLive,
MapLive,
MapsLive,
CharactersLive,
@@ -64,6 +65,9 @@ defmodule WandererAppWeb.Nav do
{CharactersLive, _} ->
:characters
+ {CharacterProfileLive, _} ->
+ :characters
+
{CharactersTrackingLive, _} ->
:characters_tracking
diff --git a/lib/wanderer_app_web/live/sponsors/sponsors_live.ex b/lib/wanderer_app_web/live/sponsors/sponsors_live.ex
new file mode 100644
index 00000000..ebd454d4
--- /dev/null
+++ b/lib/wanderer_app_web/live/sponsors/sponsors_live.ex
@@ -0,0 +1,93 @@
+defmodule WandererAppWeb.SponsorsLive do
+ use WandererAppWeb, :live_view
+
+ alias BetterNumber, as: Number
+
+ require Logger
+
+ @cache_key "server_top_donators"
+ @cache_ttl :timer.minutes(15)
+
+ @impl true
+ def mount(_params, _session, socket) do
+ if not WandererApp.Env.map_subscriptions_enabled?() do
+ {:ok, socket |> redirect(to: "/")}
+ else
+ top_donators = load_top_donators()
+ {corporation_id, corporation_info} = load_corporation_info()
+
+ {:ok,
+ assign(socket,
+ page_title: "Sponsors",
+ top_donators: top_donators,
+ corporation_id: corporation_id,
+ corporation_info: corporation_info
+ )}
+ end
+ end
+
+ def format_isk(amount) do
+ Number.to_human(amount, units: ["", "K", "M", "B", "T", "P"])
+ end
+
+ defp load_top_donators do
+ case Cachex.get(:api_cache, @cache_key) do
+ {:ok, nil} ->
+ donators = fetch_and_enrich()
+ Cachex.put(:api_cache, @cache_key, donators, ttl: @cache_ttl)
+ donators
+
+ {:ok, cached} ->
+ cached
+
+ _ ->
+ fetch_and_enrich()
+ end
+ end
+
+ defp fetch_and_enrich do
+ after_date = DateTime.utc_now() |> DateTime.add(-30, :day)
+
+ case WandererApp.Api.MapTransaction.server_top_donators(%{after: after_date}) do
+ {:ok, donators} ->
+ enrich_with_characters(donators)
+
+ {:error, reason} ->
+ Logger.warning("Failed to load server top donators: #{inspect(reason)}")
+ []
+ end
+ end
+
+ defp enrich_with_characters(donators) do
+ donators
+ |> Enum.map(fn %{user_id: user_id, total_amount: total_amount} ->
+ case WandererApp.Api.Character.active_by_user(%{user_id: user_id}) do
+ {:ok, [character | _]} ->
+ %{
+ character_name: character.name,
+ eve_id: character.eve_id,
+ corporation_name: character.corporation_name,
+ corporation_ticker: character.corporation_ticker,
+ total_amount: total_amount
+ }
+
+ _ ->
+ nil
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp load_corporation_info do
+ corp_eve_id = WandererApp.Env.corp_eve_id()
+
+ if corp_eve_id == -1 do
+ {nil, nil}
+ else
+ case WandererApp.Esi.get_corporation_info(corp_eve_id) do
+ {:ok, info} -> {corp_eve_id, info}
+ _ -> {nil, nil}
+ end
+ end
+ end
+end
diff --git a/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex b/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex
new file mode 100644
index 00000000..74ae3fbe
--- /dev/null
+++ b/lib/wanderer_app_web/live/sponsors/sponsors_live.html.heex
@@ -0,0 +1,119 @@
+
+
+
+ Our Sponsors
+
+
+ Top 10 ISK donators across all maps in the last 30 days.
+
+
+
+
+ No donations found for this period.
+
+
+
+
+
+ {index + 1}
+
+

+
+ <.link
+ navigate={~p"/characters/#{donator.eve_id}"}
+ class="text-sm font-bold text-white truncate hover:text-blue-400 transition-colors"
+ >
+ {donator.character_name}
+
+
+
+ [{donator.corporation_ticker}]
+ {donator.corporation_name}
+
+
+
+
+ ISK {WandererAppWeb.SponsorsLive.format_isk(donator.total_amount)}
+
+
+
+
+
+ <.button
+ type="button"
+ phx-click={show_modal("donate-modal")}
+ class="p-button p-component p-button-primary w-full"
+ style="min-width: 0;"
+ >
+ Become a Sponsor
+
+
+
+
+
+ <.modal id="donate-modal" title="How to become a sponsor?" class="!w-[700px]">
+
+ It's not available yet :(
+
+
+
+
+ Wanderer EVE Corporation:
+
+
+
+
![{@corporation_info["name"]}]({"https://images.evetech.net/corporations/#{@corporation_id}/logo?size=32"})
+
+
+
{@corporation_info["name"]}
+
+
+
+ How to Donate ISK to Wanderer in Eve Online
+
+
+ -
+ Open corporations overview:
+ Click on the 'Social' and then on 'Corporation' in the Neocom menu to access corporations search.
+
+ -
+ Search for a Corporation:
+ Type in the search bar the name: {@corporation_info["name"]}.
+
+ -
+ Choose 'Give Money':
+ Select the 'Give Money' in the context menu to initiate the transfer.
+
+ -
+ Specify the Amount:
+ Input the amount of ISK you wish to transfer to the corporate account.
+
+ -
+ Add a Reason (Optional):
+ Include a short note or reason for the transfer if desired.
+
+ -
+ Confirm the Transfer:
+ Double-check the recipient's name and the amount, then click 'OK' to complete the transaction.
+
+
+
+ The ISK will be transferred instantly to the Wanderer's account. Ensure you enter the correct recipient name to avoid any errors. Fly safe and enjoy your time in Eve Online!
+
+
+
+
+
diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex
index 919c0d84..4eab48b9 100644
--- a/lib/wanderer_app_web/router.ex
+++ b/lib/wanderer_app_web/router.ex
@@ -7,7 +7,7 @@ defmodule WandererAppWeb.Router do
import WandererAppWeb.UserAuth,
warn: false,
- only: [redirect_if_user_is_authenticated: 2]
+ only: [redirect_if_user_is_authenticated: 2, require_authenticated_user: 2]
import WandererAppWeb.BasicAuth,
warn: false,
@@ -164,6 +164,10 @@ defmodule WandererAppWeb.Router do
plug :put_layout, html: {WandererAppWeb.Layouts, :blog}
end
+ pipeline :require_auth do
+ plug :require_authenticated_user
+ end
+
pipeline :api do
plug WandererAppWeb.Plugs.ContentNegotiation, accepts: ["json"]
plug :accepts, ["json"]
@@ -417,6 +421,8 @@ defmodule WandererAppWeb.Router do
get "/", BlogController, :license
end
+
+
scope "/swaggerui" do
pipe_through [:browser, :api_spec]
@@ -549,6 +555,8 @@ defmodule WandererAppWeb.Router do
live "/tracking", CharactersTrackingLive, :index
live "/characters", CharactersLive, :index
live "/characters/authorize", CharactersLive, :authorize
+ live "/characters/:eve_id", CharacterProfileLive, :show
+ live "/sponsors", SponsorsLive, :index
live "/maps/new", MapsLive, :create
live "/maps/:slug/edit", MapsLive, :edit
live "/maps/:slug/settings", MapsLive, :settings
diff --git a/mix.exs b/mix.exs
index fca14304..1105237b 100644
--- a/mix.exs
+++ b/mix.exs
@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
- @version "1.97.5"
+ @version "1.98.1"
def project do
[
@@ -134,6 +134,8 @@ defmodule WandererApp.MixProject do
{:live_view_events, "~> 0.1.0"},
{:ash_pagify, "~> 1.4.1"},
{:timex, "~> 3.0"},
+ {:earmark, "~> 1.4"},
+ {:html_sanitize_ex, "~> 1.4"},
# Test coverage and quality
{:excoveralls, "~> 0.18", only: :test}
]
diff --git a/mix.lock b/mix.lock
index 59319aad..87e4410e 100644
--- a/mix.lock
+++ b/mix.lock
@@ -57,6 +57,7 @@
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"heroicons": {:hex, :heroicons, "0.5.5", "c2bcb05a90f010df246a5a2a2b54cac15483b5de137b2ef0bead77fcdf06e21a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "2f4bf929440fecd5191ba9f40e5009b0f75dc993d765c0e4d068fcb7026d6da1"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
+ "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.5.0", "ea13a4a92ba0fa17bc6199f1bb7b755a8595ec3b5f763330ea8570d8b5f648e4", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "4eaa2205ae56fab95d0f25065d709b05f0cba730f3fcec184dfde594acdd4578"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
@@ -78,6 +79,7 @@
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
"mix_audit": {:hex, :mix_audit, "2.1.3", "c70983d5cab5dca923f9a6efe559abfb4ec3f8e87762f02bab00fa4106d17eda", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "8c3987100b23099aea2f2df0af4d296701efd031affb08d0746b2be9e35988ec"},
+ "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"},
"nebulex": {:hex, :nebulex, "2.6.2", "0874989db4e382362884662d2ee9f31b4c4862595f4ec300bd279068729dd2d0", [:mix], [{:decorator, "~> 1.4", [hex: :decorator, repo: "hexpm", optional: true]}, {:shards, "~> 1.1", [hex: :shards, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "002a1774d5a187eb631ae4006db13df4bb6b325fe2a3c14cb14a1f3e989042b4"},
"nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"},
diff --git a/priv/posts/2026/01-01-roadmap-2026.md b/priv/posts/2026/01-01-roadmap-2026.md
index 602ba019..e4956e1d 100644
--- a/priv/posts/2026/01-01-roadmap-2026.md
+++ b/priv/posts/2026/01-01-roadmap-2026.md
@@ -3,7 +3,7 @@ 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."
+description: "Wanderer's 2026 roadmap are ready to reveal! Discover what exciting features and improvements are coming in 2026."
}
---
diff --git a/priv/posts/2026/01-05-weekly-giveaway-challenge.md b/priv/posts/2026/01-05-weekly-giveaway-challenge.md
deleted file mode 100644
index 8ca30415..00000000
--- a/priv/posts/2026/01-05-weekly-giveaway-challenge.md
+++ /dev/null
@@ -1,36 +0,0 @@
-%{
-title: "Event: Weekly Giveaway Challenge",
-author: "Wanderer Team",
-cover_image_uri: "/images/news/2026/01-05-weekly-giveaway/cover.webp",
-tags: ~w(event giveaway challenge),
-description: "Join our Weekly Giveaway Challenge! Be the fastest to claim your reward!"
-}
-
----
-
-
-
-
-### Event Details
-
-In 2026, we're going to giveaway partnership SKIN codes for our community, every week!
-
-- **Event Name:** Weekly Giveaway Challenge
-- **Event Link:** [Join Weekly Giveaway Challenge](https://eventcortex.com/events/invite/Cjo87svZFq6J8cc1cubH4B7AR_VfPmQ4)
-
----
-
-### Tips for Participants
-
-- **Be Ready:** Know the reveal time and be online a few minutes early.
-
----
-
-Good luck, and may the fastest capsuleer win!
-
----
-
-Fly safe,
-**Wanderer Team**
-
----
diff --git a/priv/posts/2026/02-12-eve-creator-awards.md b/priv/posts/2026/02-12-eve-creator-awards.md
deleted file mode 100644
index 10691c51..00000000
--- a/priv/posts/2026/02-12-eve-creator-awards.md
+++ /dev/null
@@ -1,53 +0,0 @@
-%{
-title: "EVE Creator Awards - Nominate Wanderer!",
-author: "Wanderer Team",
-cover_image_uri: "/images/news/2026/02-12-eve-creator-awards/cover.jpg",
-tags: ~w(event community awards nomination),
-description: "CCP has opened nominations for the EVE Creator Awards! Support Wanderer by voting for Third-Party App of the Year and Developer of the Year."
-}
-
----
-
-
-
-### EVE Creator Awards - We Need Your Vote!
-
-CCP has opened nominations for the **EVE Creator Awards**, including **Best Third-Party App** and **Developer of the Year**, and you can support us by voting.
-
----
-
-### How to Nominate Us
-
-You can nominate us for **Third-Party App of the Year**, and choose one of the team as **Developer of the Year**: Dan Sylvest, vvrong, or Gustav Oswaldo.
-
-**App field** may be filled with: `Wanderer` / `https://wanderer.ltd/`
-
----
-
-### A Bit of Stats
-
-Over the past months, Wanderer has grown to more than **7,000 monthly users**, with pilots joining from all around the world.
-
----
-
-### Meet the Team
-
-- **Dan Sylvest** — leads frontend, design, and frontend architecture, along with several supporting services.
-- **vvrong** (you know him as Demiro) — responsible for backend development, core architecture, and APIs, with additional frontend contributions.
-- **Gustav Oswaldo** — contributes across backend and frontend, including zKillboard-related services, APIs, and bots.
-
----
-
-### Vote Now
-
-- **[Vote for us here](https://eve-creator-awards.paperform.co/)**
-- **[Read the announcement](https://www.eveonline.com/news/view/eve-creator-awards)**
-
----
-
-Thank you for your support!
-
-Fly safe,
-**Wanderer Team**
-
----
diff --git a/priv/repo/migrations/20260406213852_add_character_description.exs b/priv/repo/migrations/20260406213852_add_character_description.exs
new file mode 100644
index 00000000..6454b8ff
--- /dev/null
+++ b/priv/repo/migrations/20260406213852_add_character_description.exs
@@ -0,0 +1,29 @@
+defmodule WandererApp.Repo.Migrations.AddCharacterDescription do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ alter table(:maps_v1) do
+ modify :scopes, {:array, :text}, default: '{wormholes}'
+ end
+
+ alter table(:character_v1) do
+ add :description, :text
+ end
+ end
+
+ def down do
+ alter table(:character_v1) do
+ remove :description
+ end
+
+ alter table(:maps_v1) do
+ modify :scopes, {:array, :text}, default: nil
+ end
+ end
+end
diff --git a/priv/repo/seeds_sponsors.exs b/priv/repo/seeds_sponsors.exs
new file mode 100644
index 00000000..fbb1b55e
--- /dev/null
+++ b/priv/repo/seeds_sponsors.exs
@@ -0,0 +1,135 @@
+# Seed script for example sponsor donations.
+#
+# Creates sample users, characters, a map, and donation transactions
+# so the /sponsors page and /characters/:eve_id profile pages have data.
+#
+# Run with:
+# mix run priv/repo/seeds_sponsors.exs
+#
+require Logger
+Logger.info("Seeding sponsor donation data...")
+
+alias WandererApp.Repo
+
+# Well-known EVE character data (real public info, no secrets)
+characters_data = [
+ %{
+ eve_id: "96734492",
+ name: "Oz Hasaki",
+ corporation_id: 98_553_333,
+ corporation_name: "Wormhole Wanderers",
+ corporation_ticker: "W.W",
+ alliance_id: nil,
+ alliance_name: nil,
+ alliance_ticker: nil
+ },
+ %{
+ eve_id: "2119543215",
+ name: "Katya Itzimansen",
+ corporation_id: 98_681_432,
+ corporation_name: "Anoikis Explorers",
+ corporation_ticker: "A.EXP",
+ alliance_id: 99_011_258,
+ alliance_name: "Anoikis Coalition",
+ alliance_ticker: "ANOK"
+ },
+ %{
+ eve_id: "93568202",
+ name: "Dmitriy Lancel",
+ corporation_id: 98_712_045,
+ corporation_name: "Signal Cartel",
+ corporation_ticker: "1420.",
+ alliance_id: 99_005_338,
+ alliance_name: "EvE-Scout Enclave",
+ alliance_ticker: "SCOUT"
+ },
+ %{
+ eve_id: "94801715",
+ name: "Heron Explorer",
+ corporation_id: 98_553_333,
+ corporation_name: "Wormhole Wanderers",
+ corporation_ticker: "W.W",
+ alliance_id: nil,
+ alliance_name: nil,
+ alliance_ticker: nil
+ }
+]
+
+# Donation amounts (ISK) for each character — descending order
+donation_amounts = [
+ 2_500_000_000.0,
+ 1_200_000_000.0,
+ 800_000_000.0,
+ 350_000_000.0
+]
+
+# ---------- Create users, characters, a map, and transactions ----------
+
+Repo.transaction(fn ->
+ # 1. Create a dummy map for the transactions (needs name + slug)
+ {:ok, map} =
+ WandererApp.Api.Map.new(%{
+ name: "Seed Sponsors Map",
+ slug: "seed-sponsors-map"
+ })
+
+ Logger.info(" Created map: #{map.id}")
+
+ Enum.zip(characters_data, donation_amounts)
+ |> Enum.each(fn {char_data, amount} ->
+ # 2. Create a user
+ {:ok, user} =
+ WandererApp.Api.User
+ |> Ash.Changeset.for_create(:create, %{
+ name: char_data.name,
+ hash: "seed_sponsor_#{char_data.eve_id}"
+ })
+ |> Ash.create()
+
+ Logger.info(" Created user: #{user.id} (#{user.name})")
+
+ # 3. Create a character linked to that user
+ {:ok, character} =
+ WandererApp.Api.Character
+ |> Ash.Changeset.for_create(:create, %{
+ eve_id: char_data.eve_id,
+ name: char_data.name
+ })
+ |> Ash.create()
+
+ # Assign user
+ {:ok, character} = WandererApp.Api.Character.assign_user(character, %{user_id: user.id})
+
+ # Update corporation info
+ {:ok, character} =
+ WandererApp.Api.Character.update_corporation(character, %{
+ corporation_id: char_data.corporation_id,
+ corporation_name: char_data.corporation_name,
+ corporation_ticker: char_data.corporation_ticker
+ })
+
+ # Update alliance info (if present)
+ if char_data.alliance_id do
+ WandererApp.Api.Character.update_alliance(character, %{
+ alliance_id: char_data.alliance_id,
+ alliance_name: char_data.alliance_name,
+ alliance_ticker: char_data.alliance_ticker
+ })
+ end
+
+ Logger.info(" Created character: #{character.eve_id} (#{character.name})")
+
+ # 4. Create a donation transaction (type: :in)
+ {:ok, _txn} =
+ WandererApp.Api.MapTransaction.create(%{
+ map_id: map.id,
+ user_id: user.id,
+ type: :in,
+ amount: amount
+ })
+
+ Logger.info(" Created donation: #{amount} ISK from #{char_data.name}")
+ end)
+end)
+
+Logger.info("Sponsor seed data complete!")
diff --git a/priv/resource_snapshots/repo/character_v1/20260406213852.json b/priv/resource_snapshots/repo/character_v1/20260406213852.json
new file mode 100644
index 00000000..79f95921
--- /dev/null
+++ b/priv/resource_snapshots/repo/character_v1/20260406213852.json
@@ -0,0 +1,413 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "eve_id",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "online",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "deleted",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "scopes",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "character_owner_hash",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "token_type",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "expires_at",
+ "type": "bigint"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "ship_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "ship_item_id",
+ "type": "bigint"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "corporation_id",
+ "type": "bigint"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "corporation_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "corporation_ticker",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "alliance_id",
+ "type": "bigint"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "alliance_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "alliance_ticker",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "tracking_pool",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "description",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "inserted_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "character_v1_user_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": null,
+ "table": "user_v1"
+ },
+ "scale": null,
+ "size": null,
+ "source": "user_id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "encrypted_eve_wallet_balance",
+ "type": "binary"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "encrypted_location",
+ "type": "binary"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "encrypted_ship",
+ "type": "binary"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "encrypted_solar_system_id",
+ "type": "binary"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "encrypted_structure_id",
+ "type": "binary"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "encrypted_station_id",
+ "type": "binary"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "encrypted_access_token",
+ "type": "binary"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "encrypted_refresh_token",
+ "type": "binary"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "B75AEDD6CAB8418E22082401EEB78FDA9E2B9B70883EC7EA0E05EE26695D138E",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "character_v1_unique_eve_id_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "eve_id"
+ }
+ ],
+ "name": "unique_eve_id",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.WandererApp.Repo",
+ "schema": null,
+ "table": "character_v1"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/maps_v1/20260406213852.json b/priv/resource_snapshots/repo/maps_v1/20260406213852.json
new file mode 100644
index 00000000..d436d79d
--- /dev/null
+++ b/priv/resource_snapshots/repo/maps_v1/20260406213852.json
@@ -0,0 +1,277 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "slug",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "description",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "personal_note",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "public_api_key",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "[]",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "hubs",
+ "type": [
+ "array",
+ "text"
+ ]
+ },
+ {
+ "allow_nil?": false,
+ "default": "\"wormholes\"",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "scope",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "deleted",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "only_tracked_characters",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "options",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "webhooks_enabled",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": false,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "sse_enabled",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "'{wormholes}'",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "scopes",
+ "type": [
+ "array",
+ "text"
+ ]
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "inserted_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "maps_v1_owner_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": null,
+ "table": "character_v1"
+ },
+ "scale": null,
+ "size": null,
+ "source": "owner_id",
+ "type": "uuid"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "17E507A2F8B57193D92DF2E707C6623C68A07B5058227A12DEF1522777BE7B83",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "maps_v1_unique_public_api_key_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "public_api_key"
+ }
+ ],
+ "name": "unique_public_api_key",
+ "nils_distinct?": true,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "maps_v1_unique_slug_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "slug"
+ }
+ ],
+ "name": "unique_slug",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.WandererApp.Repo",
+ "schema": null,
+ "table": "maps_v1"
+}
\ No newline at end of file
diff --git a/test/unit/map/server/k162_backlink_preference_test.exs b/test/unit/map/server/k162_backlink_preference_test.exs
new file mode 100644
index 00000000..9926725e
--- /dev/null
+++ b/test/unit/map/server/k162_backlink_preference_test.exs
@@ -0,0 +1,359 @@
+defmodule WandererApp.Map.Server.K162BacklinkPreferenceTest do
+ @moduledoc """
+ Tests for the K162 back-link preference fix.
+
+ Verifies that when a K162 (return wormhole) signature is linked to a system
+ where a forward signature (e.g., H296) already set mass/time on the connection,
+ the forward sig values take precedence over K162 form defaults.
+
+ Also verifies that the K162's custom_info is updated to reflect resolved values.
+ """
+ use WandererApp.DataCase, async: false
+
+ import Mox
+
+ alias WandererApp.Api.{MapSystem, MapSystemSignature}
+ alias WandererApp.Map.Server.SignaturesImpl
+ alias WandererAppWeb.Factory
+
+ setup :verify_on_exit!
+
+ setup do
+ 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
+ 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)
+
+ character = Factory.create_character()
+ map = Factory.create_map(%{owner_id: character.id})
+
+ %{map: map, character: character}
+ end
+
+ describe "find_forward_signature/2" do
+ test "finds forward signature in target system that links back to source", %{map: map} do
+ # System A (source) at solar_system_id 30_000_142
+ {:ok, system_a} =
+ MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"})
+
+ # System B (target) at solar_system_id 30_000_143
+ {:ok, system_b} =
+ MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_143, name: "System B"})
+
+ # Forward sig in System A: H296 linking to System B (solar_system_id)
+ _forward_sig =
+ Factory.insert(:map_system_signature, %{
+ system_id: system_a.id,
+ eve_id: "FWD-001",
+ type: "H296",
+ group: "Wormhole",
+ linked_system_id: 30_000_143,
+ custom_info:
+ Jason.encode!(%{"time_status" => 1, "mass_status" => 1, "k162Type" => nil})
+ })
+
+ # find_forward_signature looks in the target system (system_b.id)
+ # for a signature linking back to the source solar_system_id (30_000_142)
+ # But the forward sig is in system_a, linking TO system_b.
+ # So we need to call it with system_a.id (where the forward sig lives)
+ # and source_solar_system_id = 30_000_143 (what the forward sig links to)
+ #
+ # Wait - re-reading the function:
+ # find_forward_signature(target_system_uuid, source_solar_system_id)
+ # It looks in target_system_uuid for sigs with linked_system_id == source_solar_system_id
+ #
+ # In the K162 linking scenario:
+ # - K162 is in System B, user links it to System A
+ # - We call find_forward_signature(target_system.id = System A, source_solar_system = System B's solar_system_id)
+ # - This finds the H296 in System A that has linked_system_id = System B's solar_system_id
+
+ result = SignaturesImpl.find_forward_signature(system_a.id, 30_000_143)
+ assert result != nil
+ assert result.eve_id == "FWD-001"
+ assert result.type == "H296"
+ end
+
+ test "returns nil when no forward signature exists", %{map: map} do
+ {:ok, system_a} =
+ MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"})
+
+ result = SignaturesImpl.find_forward_signature(system_a.id, 30_000_143)
+ assert is_nil(result)
+ end
+
+ test "returns nil when signatures exist but none link back to source", %{map: map} do
+ {:ok, system_a} =
+ MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"})
+
+ # Signature in System A but not linked to anything
+ _unlinked_sig =
+ Factory.insert(:map_system_signature, %{
+ system_id: system_a.id,
+ eve_id: "UNL-001",
+ type: "H296",
+ group: "Wormhole"
+ })
+
+ result = SignaturesImpl.find_forward_signature(system_a.id, 30_000_143)
+ assert is_nil(result)
+ end
+ end
+
+ describe "back-link preference logic" do
+ test "forward sig time_status and mass_status take precedence over K162 defaults" do
+ # This tests the fixed logic: `Map.get(decoded, "time_status") || signature_time_status`
+ # Forward sig has time_status=1 (16h EOL), mass_status=1 (half mass)
+ # K162 has time_status=0 (24h), mass_status=0 (normal)
+
+ forward_custom_info = %{"time_status" => 1, "mass_status" => 1}
+ k162_time_status = 0
+ k162_mass_status = 0
+
+ # Fixed logic: always prefer forward sig values
+ fwd_time = Map.get(forward_custom_info, "time_status") || k162_time_status
+ fwd_mass = Map.get(forward_custom_info, "mass_status") || k162_mass_status
+
+ # Forward sig values (1) should take precedence
+ assert fwd_time == 1
+ assert fwd_mass == 1
+ end
+
+ test "K162 values used when forward sig has nil values" do
+ # Forward sig has no time/mass set
+ forward_custom_info = %{}
+ k162_time_status = 2
+ k162_mass_status = 2
+
+ fwd_time = Map.get(forward_custom_info, "time_status") || k162_time_status
+ fwd_mass = Map.get(forward_custom_info, "mass_status") || k162_mass_status
+
+ # K162 values should be used as fallback
+ assert fwd_time == 2
+ assert fwd_mass == 2
+ end
+
+ test "0 values from forward sig are truthy in Elixir and take precedence" do
+ # 0 is truthy in Elixir (only nil and false are falsy)
+ forward_custom_info = %{"time_status" => 0, "mass_status" => 0}
+ k162_time_status = 2
+ k162_mass_status = 2
+
+ fwd_time = Map.get(forward_custom_info, "time_status") || k162_time_status
+ fwd_mass = Map.get(forward_custom_info, "mass_status") || k162_mass_status
+
+ # 0 is truthy in Elixir, so forward sig's 0 values should take precedence
+ assert fwd_time == 0
+ assert fwd_mass == 0
+ end
+
+ test "old logic (is_nil check) would incorrectly keep K162 defaults" do
+ # Demonstrates the bug with old logic:
+ # if is_nil(signature_time_status), do: Map.get(decoded, "time_status"), else: signature_time_status
+ # When K162 has time_status=0, is_nil(0) is false, so K162's 0 was kept
+
+ forward_custom_info = %{"time_status" => 1, "mass_status" => 1}
+ k162_time_status = 0
+ k162_mass_status = 0
+
+ # Old logic (broken):
+ old_fwd_time =
+ if is_nil(k162_time_status),
+ do: Map.get(forward_custom_info, "time_status"),
+ else: k162_time_status
+
+ old_fwd_mass =
+ if is_nil(k162_mass_status),
+ do: Map.get(forward_custom_info, "mass_status"),
+ else: k162_mass_status
+
+ # Old logic would keep K162 defaults (0) instead of forward sig values (1)
+ assert old_fwd_time == 0
+ assert old_fwd_mass == 0
+
+ # New logic (fixed):
+ new_fwd_time = Map.get(forward_custom_info, "time_status") || k162_time_status
+ new_fwd_mass = Map.get(forward_custom_info, "mass_status") || k162_mass_status
+
+ # New logic correctly uses forward sig values
+ assert new_fwd_time == 1
+ assert new_fwd_mass == 1
+ end
+ end
+
+ describe "K162 custom_info update after linking" do
+ test "K162 signature custom_info is updated with resolved values", %{map: map} do
+ {:ok, system_a} =
+ MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"})
+
+ # K162 in system A with default values
+ k162_sig =
+ Factory.insert(:map_system_signature, %{
+ system_id: system_a.id,
+ eve_id: "K162-001",
+ type: "K162",
+ group: "Wormhole",
+ custom_info:
+ Jason.encode!(%{"time_status" => 0, "mass_status" => 0, "k162Type" => "C2"})
+ })
+
+ # Simulate what the linking code does: update K162's custom_info
+ # with resolved values from the forward signature
+ signature_time_status = 1
+ signature_mass_status = 1
+
+ updated_custom_info =
+ (k162_sig.custom_info || "{}")
+ |> Jason.decode!()
+ |> then(fn decoded ->
+ decoded
+ |> then(fn d ->
+ if not is_nil(signature_time_status),
+ do: Map.put(d, "time_status", signature_time_status),
+ else: d
+ end)
+ |> then(fn d ->
+ if not is_nil(signature_mass_status),
+ do: Map.put(d, "mass_status", signature_mass_status),
+ else: d
+ end)
+ end)
+ |> Jason.encode!()
+
+ {:ok, updated_sig} =
+ MapSystemSignature.update(k162_sig, %{custom_info: updated_custom_info})
+
+ # Verify the custom_info was updated
+ decoded = Jason.decode!(updated_sig.custom_info)
+ assert decoded["time_status"] == 1
+ assert decoded["mass_status"] == 1
+ # k162Type should be preserved
+ assert decoded["k162Type"] == "C2"
+ end
+
+ test "K162 custom_info update preserves existing fields", %{map: map} do
+ {:ok, system_a} =
+ MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"})
+
+ k162_sig =
+ Factory.insert(:map_system_signature, %{
+ system_id: system_a.id,
+ eve_id: "K162-002",
+ type: "K162",
+ group: "Wormhole",
+ custom_info:
+ Jason.encode!(%{
+ "time_status" => 0,
+ "mass_status" => 0,
+ "k162Type" => "C5",
+ "extra_field" => "preserved"
+ })
+ })
+
+ # Only update time_status, leave mass_status nil (should not update)
+ signature_time_status = 2
+ signature_mass_status = nil
+
+ updated_custom_info =
+ (k162_sig.custom_info || "{}")
+ |> Jason.decode!()
+ |> then(fn decoded ->
+ decoded
+ |> then(fn d ->
+ if not is_nil(signature_time_status),
+ do: Map.put(d, "time_status", signature_time_status),
+ else: d
+ end)
+ |> then(fn d ->
+ if not is_nil(signature_mass_status),
+ do: Map.put(d, "mass_status", signature_mass_status),
+ else: d
+ end)
+ end)
+ |> Jason.encode!()
+
+ {:ok, updated_sig} =
+ MapSystemSignature.update(k162_sig, %{custom_info: updated_custom_info})
+
+ decoded = Jason.decode!(updated_sig.custom_info)
+ # time_status should be updated
+ assert decoded["time_status"] == 2
+ # mass_status should remain unchanged (nil signature_mass_status)
+ assert decoded["mass_status"] == 0
+ # Other fields should be preserved
+ assert decoded["k162Type"] == "C5"
+ assert decoded["extra_field"] == "preserved"
+ end
+
+ test "K162 custom_info update handles nil initial custom_info", %{map: map} do
+ {:ok, system_a} =
+ MapSystem.create(%{map_id: map.id, solar_system_id: 30_000_142, name: "System A"})
+
+ k162_sig =
+ Factory.insert(:map_system_signature, %{
+ system_id: system_a.id,
+ eve_id: "K162-003",
+ type: "K162",
+ group: "Wormhole"
+ # custom_info is nil by default
+ })
+
+ signature_time_status = 1
+ signature_mass_status = 1
+
+ updated_custom_info =
+ (k162_sig.custom_info || "{}")
+ |> Jason.decode!()
+ |> then(fn decoded ->
+ decoded
+ |> then(fn d ->
+ if not is_nil(signature_time_status),
+ do: Map.put(d, "time_status", signature_time_status),
+ else: d
+ end)
+ |> then(fn d ->
+ if not is_nil(signature_mass_status),
+ do: Map.put(d, "mass_status", signature_mass_status),
+ else: d
+ end)
+ end)
+ |> Jason.encode!()
+
+ {:ok, updated_sig} =
+ MapSystemSignature.update(k162_sig, %{custom_info: updated_custom_info})
+
+ decoded = Jason.decode!(updated_sig.custom_info)
+ assert decoded["time_status"] == 1
+ assert decoded["mass_status"] == 1
+ end
+ end
+end