diff --git a/package-lock.json b/package-lock.json
index 2a593420..84a72336 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54,6 +54,7 @@
"loglevel": "^1.9.2",
"loglevel-plugin-prefix": "^0.8.4",
"microdiff": "^1.5.0",
+ "minidenticons": "^4.2.1",
"moize": "^6.1.7",
"pako": "^2.1.0",
"react": "^19.2.4",
@@ -6061,6 +6062,15 @@
"node": ">=4"
}
},
+ "node_modules/minidenticons": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/minidenticons/-/minidenticons-4.2.1.tgz",
+ "integrity": "sha512-oWfFivA0lOx/V/bO/YIJbthB26lV8JXYvhnv9zM2hNd3fzsHTXQ6c6bWZPcvhD3nnOB+lQk/D9lF43BXixrN8g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=15.14.0"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
diff --git a/package.json b/package.json
index dcb0ac2d..76d58cce 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"loglevel": "^1.9.2",
"loglevel-plugin-prefix": "^0.8.4",
"microdiff": "^1.5.0",
+ "minidenticons": "^4.2.1",
"moize": "^6.1.7",
"pako": "^2.1.0",
"react": "^19.2.4",
diff --git a/src/factories/list/FactoryGridCard.tsx b/src/factories/list/FactoryGridCard.tsx
index be631b96..4c68202c 100644
--- a/src/factories/list/FactoryGridCard.tsx
+++ b/src/factories/list/FactoryGridCard.tsx
@@ -6,7 +6,7 @@ import { useStore } from '@/core/zustand';
import { ProgressChip } from '@/factories/components/ProgressChip';
import type { Factory } from '@/factories/Factory';
import { useIsFactoryVisible } from '@/factories/useIsFactoryVisible';
-import { FactoryPeers } from '@/games/sync/FactoryPeers';
+import { FactoryPeers } from '@/games/sync/ui/FactoryPeers';
import { FactoryItemImage } from '@/recipes/ui/FactoryItemImage';
import classes from './FactoryGridCard.module.css';
diff --git a/src/factories/list/FactoryRow.tsx b/src/factories/list/FactoryRow.tsx
index 4d22241d..05b395f3 100644
--- a/src/factories/list/FactoryRow.tsx
+++ b/src/factories/list/FactoryRow.tsx
@@ -24,7 +24,7 @@ import { FactoryInputRow } from '@/factories/inputs/input-row/FactoryInputRow';
import { FactoryOutputRow } from '@/factories/inputs/output-row/FactoryOutputRow';
import { useIsFactoryVisible } from '@/factories/useIsFactoryVisible';
import { useGameFactoryIsCollapsed } from '@/games/gamesSlice';
-import { FactoryPeers } from '@/games/sync/FactoryPeers';
+import { FactoryPeers } from '@/games/sync/ui/FactoryPeers';
export interface IFactoryRowProps {
id: string;
diff --git a/src/games/menu/GameMenu.tsx b/src/games/menu/GameMenu.tsx
index 9a9a0a48..97230324 100644
--- a/src/games/menu/GameMenu.tsx
+++ b/src/games/menu/GameMenu.tsx
@@ -1,4 +1,4 @@
-import { Box, Button, Menu } from '@mantine/core';
+import { Button, Menu } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
@@ -55,9 +55,6 @@ export function GameMenu(props: IGameMenuProps) {
state => !!state.games.games[selectedId ?? '']?.savedId,
);
const isSaving = useStore(state => state.gameSave.isSaving);
- const isSyncConnected = useStore(
- state => state.gameSave.isRealtimeSyncConnected,
- );
const navigate = useNavigate();
const [opened, { toggle, open, close }] = useDisclosure();
@@ -96,122 +93,113 @@ export function GameMenu(props: IGameMenuProps) {
return (
<>
-
-
-
+ )}
+
+ }
+ onClick={() => {
+ navigate(`/games`);
+ }}
+ >
+ Games list
+
+
+
+
+
{selectedId && (
)}
diff --git a/src/games/sync/FactoryPeers.tsx b/src/games/sync/FactoryPeers.tsx
deleted file mode 100644
index 437a4b38..00000000
--- a/src/games/sync/FactoryPeers.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Avatar, Tooltip } from '@mantine/core';
-import { useFactoryPeers } from './peersSlice';
-
-export interface FactoryPeersProps {
- factoryId: string;
-}
-
-export function FactoryPeers({ factoryId }: FactoryPeersProps) {
- const peers = useFactoryPeers(factoryId);
- if (peers.length === 0) return null;
-
- return (
-
- {peers.map(peer => (
-
-
- {peer.displayName.charAt(0).toUpperCase()}
-
-
- ))}
-
- );
-}
diff --git a/src/games/sync/OnlinePeers.tsx b/src/games/sync/OnlinePeers.tsx
deleted file mode 100644
index f2103437..00000000
--- a/src/games/sync/OnlinePeers.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Avatar, Tooltip } from '@mantine/core';
-import { useOnlinePeers } from './peersSlice';
-
-export function OnlinePeers() {
- const peers = useOnlinePeers();
- if (peers.length === 0) return null;
-
- return (
-
- {peers.map(peer => (
-
-
- {peer.displayName.charAt(0).toUpperCase()}
-
-
- ))}
-
- );
-}
diff --git a/src/games/sync/deviceIdentity.ts b/src/games/sync/deviceIdentity.ts
new file mode 100644
index 00000000..bbb198d1
--- /dev/null
+++ b/src/games/sync/deviceIdentity.ts
@@ -0,0 +1,94 @@
+const ADJECTIVES = [
+ 'Reckless',
+ 'Bold',
+ 'Curious',
+ 'Brave',
+ 'Eager',
+ 'Shiny',
+ 'Sturdy',
+ 'Molten',
+ 'Swift',
+ 'Heavy',
+ 'Lonely',
+ 'Mighty',
+ 'Rusty',
+ 'Gilded',
+ 'Electric',
+ 'Radiant',
+ 'Stormy',
+ 'Frozen',
+ 'Crimson',
+ 'Verdant',
+ 'Nuclear',
+ 'Pulsing',
+ 'Turbo',
+ 'Automated',
+ 'Encased',
+ 'Pioneer',
+ 'Industrious',
+ 'Overclocked',
+ 'Tactical',
+ 'Silent',
+ 'Whirring',
+ 'Humming',
+ 'Stoic',
+ 'Clever',
+ 'Nimble',
+ 'Fearless',
+ 'Daring',
+ 'Spirited',
+ 'Resolute',
+ 'Glittering',
+];
+
+const CREATURES = [
+ 'Lizard Doggo',
+ 'Spitter',
+ 'Stinger',
+ 'Hog',
+ 'Alpha Hog',
+ 'Cliff Hog',
+ 'Crab Hatcher',
+ 'Plasma Spitter',
+ 'Nuclear Hog',
+ 'Gas Stinger',
+ 'Elite Stinger',
+ 'Alpha Stinger',
+ 'Flying Crab',
+ 'Baby Crab',
+ 'Fluffy-tailed Hog',
+];
+
+const DEVICE_NAME_KEY = 'satisfactory-logistics:device-name';
+const SENDER_ID_KEY = 'satisfactory-logistics:sender-id';
+
+function pick(arr: T[]): T {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+export function generateDeviceName(): string {
+ return `${pick(ADJECTIVES)} ${pick(CREATURES)}`;
+}
+
+function readOrCreateSessionValue(key: string, factory: () => string): string {
+ try {
+ const existing = sessionStorage.getItem(key);
+ if (existing) return existing;
+ const created = factory();
+ sessionStorage.setItem(key, created);
+ return created;
+ } catch {
+ // sessionStorage may be unavailable (SSR, disabled cookies) — fall back to
+ // a fresh value for this module lifetime.
+ return factory();
+ }
+}
+
+export const DEVICE_NAME = readOrCreateSessionValue(
+ DEVICE_NAME_KEY,
+ generateDeviceName,
+);
+
+export const SENDER_ID = readOrCreateSessionValue(SENDER_ID_KEY, () =>
+ crypto.randomUUID(),
+);
diff --git a/src/games/sync/peersSlice.ts b/src/games/sync/peersSlice.ts
index 01df41cb..517508da 100644
--- a/src/games/sync/peersSlice.ts
+++ b/src/games/sync/peersSlice.ts
@@ -9,6 +9,7 @@ export interface PeerInfo {
userId: string;
avatarUrl: string | null;
displayName: string;
+ deviceName: string;
factoryId: string | null;
}
@@ -27,21 +28,29 @@ export const peersSlice = createSlice({
},
});
-export function useOnlinePeers(): PeerInfo[] {
+function sortPeers(peers: PeerInfo[]): PeerInfo[] {
+ // Self first, then alphabetical by displayName, then by deviceName to keep
+ // multi-device peers of the same user adjacent and stable.
+ return [...peers].sort((a, b) => {
+ const aSelf = a.senderId === SENDER_ID ? 0 : 1;
+ const bSelf = b.senderId === SENDER_ID ? 0 : 1;
+ if (aSelf !== bSelf) return aSelf - bSelf;
+ const byName = a.displayName.localeCompare(b.displayName);
+ if (byName !== 0) return byName;
+ return a.deviceName.localeCompare(b.deviceName);
+ });
+}
+
+export function useAllPeers(): PeerInfo[] {
const peers = useStore(s => s.peers.peers);
- return useMemo(
- () => Object.values(peers).filter(p => p.senderId !== SENDER_ID),
- [peers],
- );
+ return useMemo(() => sortPeers(Object.values(peers)), [peers]);
}
-export function useFactoryPeers(factoryId: string): PeerInfo[] {
+export function useAllFactoryPeers(factoryId: string): PeerInfo[] {
const peers = useStore(s => s.peers.peers);
return useMemo(
() =>
- Object.values(peers).filter(
- p => p.senderId !== SENDER_ID && p.factoryId === factoryId,
- ),
+ sortPeers(Object.values(peers).filter(p => p.factoryId === factoryId)),
[peers, factoryId],
);
}
diff --git a/src/games/sync/realtimeSyncHandlers.ts b/src/games/sync/realtimeSyncHandlers.ts
index efab6693..8c3b58ef 100644
--- a/src/games/sync/realtimeSyncHandlers.ts
+++ b/src/games/sync/realtimeSyncHandlers.ts
@@ -211,15 +211,14 @@ export function computeLeaderAndPeers(
for (const p of presences) {
if (!p.senderId) continue;
senderIds.push(p.senderId);
- if (p.senderId !== SENDER_ID) {
- peerMap[p.senderId] = {
- senderId: p.senderId,
- userId: p.userId ?? '',
- avatarUrl: p.avatarUrl ?? null,
- displayName: p.displayName ?? 'Unknown',
- factoryId: p.factoryId ?? null,
- };
- }
+ peerMap[p.senderId] = {
+ senderId: p.senderId,
+ userId: p.userId ?? '',
+ avatarUrl: p.avatarUrl ?? null,
+ displayName: p.displayName ?? 'Unknown',
+ deviceName: p.deviceName ?? '',
+ factoryId: p.factoryId ?? null,
+ };
}
}
diff --git a/src/games/sync/realtimeSyncTypes.ts b/src/games/sync/realtimeSyncTypes.ts
index 5714d3d7..7780c535 100644
--- a/src/games/sync/realtimeSyncTypes.ts
+++ b/src/games/sync/realtimeSyncTypes.ts
@@ -2,7 +2,7 @@ import type { Patch } from 'immer';
import type { GameRemoteData } from '@/games/Game';
import type { SerializedGame } from '@/games/store/gameFactoriesActions';
-export const SENDER_ID = crypto.randomUUID();
+export { DEVICE_NAME, SENDER_ID } from './deviceIdentity';
export const PATCH_DEBOUNCE_MS = 150;
export const AUTO_SAVE_DEBOUNCE_MS = 40_000;
export const DB_FALLBACK_MS = 3_000;
@@ -32,6 +32,7 @@ export interface PresencePayload {
userId: string;
avatarUrl: string | null;
displayName: string;
+ deviceName: string;
factoryId: string | null;
}
diff --git a/src/games/sync/ui/DeviceIdenticon.tsx b/src/games/sync/ui/DeviceIdenticon.tsx
new file mode 100644
index 00000000..772ed993
--- /dev/null
+++ b/src/games/sync/ui/DeviceIdenticon.tsx
@@ -0,0 +1,35 @@
+import { minidenticon } from 'minidenticons';
+import { useMemo } from 'react';
+
+export interface DeviceIdenticonProps {
+ seed: string;
+ size?: number;
+ saturation?: number;
+ lightness?: number;
+ title?: string;
+}
+
+export function DeviceIdenticon({
+ seed,
+ size = 12,
+ saturation = 85,
+ lightness = 55,
+ title,
+}: DeviceIdenticonProps) {
+ const svg = useMemo(
+ () => minidenticon(seed, saturation, lightness),
+ [seed, saturation, lightness],
+ );
+ const dataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
+
+ return (
+
+ );
+}
diff --git a/src/games/sync/ui/FactoryPeers.tsx b/src/games/sync/ui/FactoryPeers.tsx
new file mode 100644
index 00000000..cdf4d2a8
--- /dev/null
+++ b/src/games/sync/ui/FactoryPeers.tsx
@@ -0,0 +1,42 @@
+import { Box } from '@mantine/core';
+import { useMemo } from 'react';
+import { useAllFactoryPeers } from '../peersSlice';
+import { PeerAvatar } from './PeerAvatar';
+
+export interface FactoryPeersProps {
+ factoryId: string;
+}
+
+export function FactoryPeers({ factoryId }: FactoryPeersProps) {
+ const peers = useAllFactoryPeers(factoryId);
+
+ const deviceCountByUser = useMemo(() => {
+ const map = new Map();
+ for (const p of peers) {
+ map.set(p.userId, (map.get(p.userId) ?? 0) + 1);
+ }
+ return map;
+ }, [peers]);
+
+ if (peers.length === 0) return null;
+
+ return (
+
+ {peers.map((peer, idx) => (
+
+ 1}
+ />
+
+ ))}
+
+ );
+}
diff --git a/src/games/sync/ui/OnlinePeers.tsx b/src/games/sync/ui/OnlinePeers.tsx
new file mode 100644
index 00000000..269a5ac4
--- /dev/null
+++ b/src/games/sync/ui/OnlinePeers.tsx
@@ -0,0 +1,142 @@
+import {
+ Avatar,
+ Box,
+ Divider,
+ Group,
+ Popover,
+ Stack,
+ Text,
+} from '@mantine/core';
+import { useDisclosure } from '@mantine/hooks';
+import { IconCircleFilled } from '@tabler/icons-react';
+import { useMemo } from 'react';
+import { useAllPeers } from '../peersSlice';
+import { SENDER_ID } from '../realtimeSyncTypes';
+import { DeviceIdenticon } from './DeviceIdenticon';
+import { PeerAvatar } from './PeerAvatar';
+
+export function OnlinePeers() {
+ const peers = useAllPeers();
+ const [opened, { toggle, close }] = useDisclosure(false);
+
+ const deviceCountByUser = useMemo(() => {
+ const map = new Map();
+ for (const p of peers) {
+ map.set(p.userId, (map.get(p.userId) ?? 0) + 1);
+ }
+ return map;
+ }, [peers]);
+
+ const leaderSenderId = useMemo(() => {
+ if (peers.length === 0) return null;
+ return [...peers.map(p => p.senderId)].sort()[0];
+ }, [peers]);
+
+ const groupedByUser = useMemo(() => {
+ const groups = new Map<
+ string,
+ {
+ userId: string;
+ displayName: string;
+ avatarUrl: string | null;
+ devices: typeof peers;
+ }
+ >();
+ for (const p of peers) {
+ const existing = groups.get(p.userId);
+ if (existing) {
+ existing.devices.push(p);
+ } else {
+ groups.set(p.userId, {
+ userId: p.userId,
+ displayName: p.displayName,
+ avatarUrl: p.avatarUrl,
+ devices: [p],
+ });
+ }
+ }
+ return Array.from(groups.values());
+ }, [peers]);
+
+ if (peers.length === 0) return null;
+
+ return (
+
+
+
+ {peers.map((peer, idx) => (
+
+ 1}
+ />
+
+ ))}
+
+
+
+
+
+ Connected ({peers.length})
+
+ {groupedByUser.map((group, idx) => (
+
+ {idx > 0 && }
+
+
+ {group.displayName.charAt(0).toUpperCase()}
+
+
+ {group.displayName}
+ {group.devices.some(d => d.senderId === SENDER_ID) && (
+
+ {' '}
+ (you)
+
+ )}
+
+
+
+ {group.devices.map(device => (
+
+
+
+ {device.deviceName || 'Unknown device'}
+
+ {device.senderId === leaderSenderId && (
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/games/sync/ui/PeerAvatar.tsx b/src/games/sync/ui/PeerAvatar.tsx
new file mode 100644
index 00000000..70426f84
--- /dev/null
+++ b/src/games/sync/ui/PeerAvatar.tsx
@@ -0,0 +1,81 @@
+import { Avatar, Box, Tooltip } from '@mantine/core';
+import type { PeerInfo } from '../peersSlice';
+import { SENDER_ID } from '../realtimeSyncTypes';
+import { DeviceIdenticon } from './DeviceIdenticon';
+
+export interface PeerAvatarProps {
+ peer: PeerInfo;
+ showDeviceBadge?: boolean;
+ size?: number;
+ withTooltip?: boolean;
+}
+
+export function PeerAvatar({
+ peer,
+ showDeviceBadge = false,
+ size = 28,
+ withTooltip = true,
+}: PeerAvatarProps) {
+ const isSelf = peer.senderId === SENDER_ID;
+ const tooltipLabel = [
+ peer.displayName + (isSelf ? ' (you)' : ''),
+ peer.deviceName,
+ ]
+ .filter(Boolean)
+ .join(' · ');
+
+ const badgeSize = Math.max(16, Math.round(size * 0.7));
+
+ const avatar = (
+
+ {peer.displayName.charAt(0).toUpperCase()}
+
+ );
+
+ const content =
+ showDeviceBadge && peer.deviceName ? (
+
+ {avatar}
+
+
+
+
+ ) : (
+ avatar
+ );
+
+ if (!withTooltip) return content;
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/src/games/sync/ui/RealtimeSyncIndicator.tsx b/src/games/sync/ui/RealtimeSyncIndicator.tsx
new file mode 100644
index 00000000..5a0960b1
--- /dev/null
+++ b/src/games/sync/ui/RealtimeSyncIndicator.tsx
@@ -0,0 +1,66 @@
+import { Box, Group, Tooltip } from '@mantine/core';
+import { IconBroadcast, IconBroadcastOff } from '@tabler/icons-react';
+import { useStore } from '@/core/zustand';
+import { useAllPeers } from '../peersSlice';
+import { OnlinePeers } from './OnlinePeers';
+
+export function RealtimeSyncIndicator() {
+ const isConnected = useStore(s => s.gameSave.isRealtimeSyncConnected);
+ const peers = useAllPeers();
+
+ if (!isConnected && peers.length === 0) return null;
+
+ return (
+
+
+
+ {isConnected ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ {peers.length > 0 && }
+
+ );
+}
diff --git a/src/games/sync/useRealtimeGameSync.ts b/src/games/sync/useRealtimeGameSync.ts
index b836eee0..fed8b020 100644
--- a/src/games/sync/useRealtimeGameSync.ts
+++ b/src/games/sync/useRealtimeGameSync.ts
@@ -23,6 +23,7 @@ import {
BROADCAST_EVENT,
BROADCAST_FULL_REQUEST,
BROADCAST_FULL_RESPONSE,
+ DEVICE_NAME,
type FullStateRequestPayload,
type FullStateResponsePayload,
isBroadcastSuppressed,
@@ -65,6 +66,7 @@ export function useRealtimeGameSync() {
avatarUrl: user.user_metadata?.avatar_url ?? null,
displayName:
user.user_metadata?.full_name ?? user.user_metadata?.name ?? 'Unknown',
+ deviceName: DEVICE_NAME,
factoryId,
};
channelRef.current.track(payload);
@@ -215,6 +217,7 @@ export function useRealtimeGameSync() {
user.user_metadata?.full_name ??
user.user_metadata?.name ??
'Unknown',
+ deviceName: DEVICE_NAME,
factoryId: factoryIdRef.current,
} satisfies PresencePayload);
doRequestFullState();
diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx
index 6c8df291..0eca3194 100644
--- a/src/layout/Header.tsx
+++ b/src/layout/Header.tsx
@@ -14,7 +14,7 @@ import { UserMenu } from '@/auth/UserMenu';
import { useStore } from '@/core/zustand';
import { GameMenu } from '@/games/menu/GameMenu';
import { GameSettingsModal } from '@/games/settings/GameSettingsModal';
-import { OnlinePeers } from '@/games/sync/OnlinePeers';
+import { RealtimeSyncIndicator } from '@/games/sync/ui/RealtimeSyncIndicator';
import { NotesPanelTrigger } from '@/notes/NotesPanelTrigger';
import { TutorialMenu } from '@/tutorial/TutorialMenu';
import classes from './Header.module.css';
@@ -68,17 +68,19 @@ export function Header() {
)}
-
-
-
+
+
+
+
+
+
{hasSelectedGame && }
-