From 51fbac7c911b5ccecc5c607702159a08dbfd2b30 Mon Sep 17 00:00:00 2001 From: Joe Amenta Date: Thu, 12 Feb 2026 07:31:57 -0500 Subject: [PATCH 1/5] Try to add message template placeholders to more messages. The first one, "a random player", is the trickiest: there's no obvious way to get the list of connected players, and archipelago.js doesn't add that on in any realistically usable way, so the ideal way to do this is impossible, and the closest possible alternative is, I think, prohibitively hard. But I think the closest that I can reasonably get to that alternative is actually ideal after all. Most likely, the use case for adding a placeholder for a random player is to cheer on someone else who hasn't yet hit their goal anyway. If so, then it's not a problem that I can't distinguish online from offline among players who have. Completely 100% untested so far, but I need to walk away for now. --- src/app/archipelago-client.ts | 57 ++++++++++++++++++++++- src/app/data/slot-data.ts | 5 ++- src/app/store/autopelago-store.ts | 75 ++++++++++++++++++++++++++++--- 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/src/app/archipelago-client.ts b/src/app/archipelago-client.ts index 9e05eae..9b55313 100644 --- a/src/app/archipelago-client.ts +++ b/src/app/archipelago-client.ts @@ -1,4 +1,14 @@ -import { Client, type ConnectionOptions, Hint, type MessageNode, type Player } from '@airbreather/archipelago.js'; +import { + Client, + type ClientStatus, + clientStatuses, + type ConnectionOptions, + type DataChangeCallback, + Hint, + type JSONSerializable, + type MessageNode, + type Player, +} from '@airbreather/archipelago.js'; import { computed, type DestroyRef, signal, type Signal } from '@angular/core'; import BitArray from '@bitarray/typedarray'; import { List, Repeat } from 'immutable'; @@ -55,6 +65,9 @@ export async function initializeClient(initializeClientOptions: InitializeClient client.options.maximumMessages = 0; const messageLog = createReactiveMessageLog(client, destroyRef); + // we also do our own thing to monitor player status + const playersWithStatus = createReactivePlayerList(client); + // we also have our own hint stuff. const reactiveHints = createReactiveHints(client, destroyRef); @@ -189,6 +202,7 @@ export async function initializeClient(initializeClientOptions: InitializeClient client, pkg, messageLog, + playersWithStatus, slotData, hintedLocations, hintedItems, @@ -301,3 +315,44 @@ function createReactiveMessageLog(client: Client, destroyRef?: DestroyRef): Sign return messageLog.asReadonly(); } + +export interface PlayerAndStatus { + player: Player; + isSelf: boolean; + status: ClientStatus | null; + statusRaw: JSONSerializable; +} + +const validPlayerStatuses = new Set(Object.values(clientStatuses)); +function isValidClientStatus(val: JSONSerializable): val is ClientStatus { + return typeof val === 'number' && validPlayerStatuses.has(val as ClientStatus); +} + +function createReactivePlayerList(client: Client): Signal>> { + const allPlayers = client.players.teams[client.players.self.team]; + const playerList = signal(List(allPlayers.map(player => ({ + player, + isSelf: player.slot === client.players.self.slot, + status: null, + statusRaw: null, + })))); + const onClientStatusUpdated: DataChangeCallback = (key: string, value: JSONSerializable) => { + const slot = Number(/^_read_client_status_\d+_(?\d+)$/.exec(key)?.groups?.['slot']); + if (!slot) { + return; + } + + playerList.update(players => players.set(slot, { + player: allPlayers[slot], + isSelf: allPlayers[slot].slot === client.players.self.slot, + status: isValidClientStatus(value) ? value : null, + statusRaw: value, + })); + }; + + // there's no way to unsubscribe our callbacks, so I think this is the only such "reactive" thing + // that would stack up if you keep subscribing and then unsubscribing a single instance of Client. + // not an immediate problem, but it's annoying enough that I took note (airbreather 2026-02-11). + void client.storage.notify(allPlayers.map(player => `_read_client_status_${player.team.toString()}_${player.slot.toString()}`), onClientStatusUpdated); + return playerList.asReadonly(); +} diff --git a/src/app/data/slot-data.ts b/src/app/data/slot-data.ts index 8626573..2a695aa 100644 --- a/src/app/data/slot-data.ts +++ b/src/app/data/slot-data.ts @@ -1,8 +1,8 @@ import type { Client, Hint, JSONRecord, PackageMetadata } from '@airbreather/archipelago.js'; import type { Signal } from '@angular/core'; import type BitArray from '@bitarray/typedarray'; -import { List } from 'immutable'; -import type { Message } from '../archipelago-client'; +import type { List } from 'immutable'; +import type { Message, PlayerAndStatus } from '../archipelago-client'; import type { ConnectScreenState } from '../connect-screen/connect-screen-state'; import type { TargetLocationEvidence } from '../game/target-location-evidence'; import type { ToJSONSerializable } from '../utils/types'; @@ -17,6 +17,7 @@ export interface AutopelagoClientAndData { locationIsProgression: Readonly; locationIsTrap: Readonly; messageLog: Signal>; + playersWithStatus: Signal>>; hintedLocations: Signal>; hintedItems: Signal>; slotData: AutopelagoSlotData; diff --git a/src/app/store/autopelago-store.ts b/src/app/store/autopelago-store.ts index a5e63fd..60b4e1e 100644 --- a/src/app/store/autopelago-store.ts +++ b/src/app/store/autopelago-store.ts @@ -1,8 +1,8 @@ -import type { SayPacket } from '@airbreather/archipelago.js'; +import { type ClientStatus, clientStatuses, type Player, type SayPacket } from '@airbreather/archipelago.js'; import { withResource } from '@angular-architects/ngrx-toolkit'; -import { effect, resource } from '@angular/core'; +import { computed, effect, resource } from '@angular/core'; -import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; +import { patchState, signalStore, withComputed, withHooks, withMethods, withState } from '@ngrx/signals'; import { List, Set as ImmutableSet } from 'immutable'; import { BAKED_DEFINITIONS_BY_VICTORY_LANDMARK, @@ -16,6 +16,7 @@ import { toWeighted } from '../utils/weighted-sampler'; import { withCleverTimer } from './with-clever-timer'; import { withGameState } from './with-game-state'; +const ONLINE_AND_NOT_GOALED_STATUSES = new Set([clientStatuses.connected, clientStatuses.ready, clientStatuses.playing]); export const GameStore = signalStore( withCleverTimer(), withGameState(), @@ -125,6 +126,66 @@ export const GameStore = signalStore( }); }, })), + withComputed((store) => { + const bestEffortRandomPlayers = computed(() => { + const game = store.game(); + if (game === null) { + return List(); + } + + const otherRealPlayers = game.playersWithStatus().filter(p => !p.isSelf && p.player.slot !== 0); + if (otherRealPlayers.isEmpty()) { + // solo game. + return List([game.client.players.self]); + } + + // "definitely online" as opposed to those who have goaled so we can't tell the difference. + const definitelyOnlinePlayers = otherRealPlayers.filter(p => p.status !== null && ONLINE_AND_NOT_GOALED_STATUSES.has(p.status)); + return definitelyOnlinePlayers.isEmpty() + ? otherRealPlayers.map(p => p.player) + : definitelyOnlinePlayers.map(p => p.player); + }); + function _messageTemplate(message: string) { + if (!message.includes('{RANDOM_PLAYER}')) { + return message; + } + + const randomPlayers = bestEffortRandomPlayers(); + if (randomPlayers.isEmpty()) { + // don't spend too long thinking about a good answer here. it's only possible EXTREMELY + // early during initialization, and there shouldn't be any reason to call us during those + // points anyway, so it really doesn't matter. + return message; + } + + return message.replaceAll('{RANDOM_PLAYER}', () => { + const idx = Math.floor(Math.random() * randomPlayers.size); + const otherPlayer = randomPlayers.get(idx); + if (!otherPlayer) { + throw new Error('list.size is inconsistent with list.get(i). this is a bug in immutable.js'); + } + return otherPlayer.alias; + }); + } + function _wrapMessageTemplate(f: ((...args: T) => string) | null) { + return f === null + ? null + : (...args: T) => _messageTemplate(f(...args)); + } + return { + sampleMessageFull: computed(() => { + const sampleMessage = store.sampleMessage(); + return { + forChangedTarget: _wrapMessageTemplate(sampleMessage.forChangedTarget), + forEnterGoMode: _wrapMessageTemplate(sampleMessage.forEnterGoMode), + forEnterBK: _wrapMessageTemplate(sampleMessage.forEnterBK), + forRemindBK: _wrapMessageTemplate(sampleMessage.forRemindBK), + forExitBK: _wrapMessageTemplate(sampleMessage.forExitBK), + forCompletedGoal: _wrapMessageTemplate(sampleMessage.forCompletedGoal), + } satisfies typeof sampleMessage; + }), + }; + }), withHooks({ onInit(store) { effect(() => { @@ -148,7 +209,7 @@ export const GameStore = signalStore( return; } - const sampleMessage = store.sampleMessage().forCompletedGoal; + const sampleMessage = store.sampleMessageFull().forCompletedGoal; if (sampleMessage === null) { return; } @@ -207,7 +268,7 @@ export const GameStore = signalStore( } const { allLocations } = store.defs(); - const sampleMessage = store.sampleMessage().forChangedTarget; + const sampleMessage = store.sampleMessageFull().forChangedTarget; if (sampleMessage === null) { return; } @@ -258,7 +319,7 @@ export const GameStore = signalStore( return; } - const sampleMessage = store.sampleMessage().forEnterGoMode; + const sampleMessage = store.sampleMessageFull().forEnterGoMode; if (sampleMessage === null) { return; } @@ -273,7 +334,7 @@ export const GameStore = signalStore( return; } - const { forEnterBK, forRemindBK, forExitBK } = store.sampleMessage(); + const { forEnterBK, forRemindBK, forExitBK } = store.sampleMessageFull(); if (forEnterBK === null || forRemindBK === null || forExitBK === null) { return; } From 8f6956ef57d2336c29a04503633f58a850416fed Mon Sep 17 00:00:00 2001 From: Joe Amenta Date: Fri, 13 Feb 2026 15:28:23 -0500 Subject: [PATCH 2/5] This needs to happen later --- src/app/archipelago-client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/archipelago-client.ts b/src/app/archipelago-client.ts index 9b55313..e516f9f 100644 --- a/src/app/archipelago-client.ts +++ b/src/app/archipelago-client.ts @@ -65,9 +65,6 @@ export async function initializeClient(initializeClientOptions: InitializeClient client.options.maximumMessages = 0; const messageLog = createReactiveMessageLog(client, destroyRef); - // we also do our own thing to monitor player status - const playersWithStatus = createReactivePlayerList(client); - // we also have our own hint stuff. const reactiveHints = createReactiveHints(client, destroyRef); @@ -93,6 +90,9 @@ export async function initializeClient(initializeClientOptions: InitializeClient options, ); + // we also do our own thing to monitor player status, but this has to happen later. + const playersWithStatus = createReactivePlayerList(client); + const victoryLocationYamlKey = VICTORY_LOCATION_NAME_LOOKUP[slotData.victory_location_name]; const defs = BAKED_DEFINITIONS_BY_VICTORY_LANDMARK[victoryLocationYamlKey]; const player = client.players.self; From dfa261d7d979c26cf3231ddb532c0738ec05c2ef Mon Sep 17 00:00:00 2001 From: Joe Amenta Date: Fri, 13 Feb 2026 15:44:27 -0500 Subject: [PATCH 3/5] ...not sure why I thought that this would have worked? --- src/app/store/autopelago-store.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/app/store/autopelago-store.ts b/src/app/store/autopelago-store.ts index 60b4e1e..e53ef85 100644 --- a/src/app/store/autopelago-store.ts +++ b/src/app/store/autopelago-store.ts @@ -246,19 +246,26 @@ export const GameStore = signalStore( store.processMessage(message, players); ++processedMessageCount; } + }); - patchState(store, ({ outgoingMessages }) => { - if (outgoingMessages.size > 0) { - client.socket.send(...outgoingMessages.map(msg => ({ - cmd: 'Say', - text: msg, - } satisfies SayPacket))); - } + effect(() => { + const game = store.game(); + if (!game) { + return; + } - return { - outgoingMessages: outgoingMessages.clear(), - processedMessageCount, - }; + const client = game.client; + const outgoingMessages = store.outgoingMessages(); + if (outgoingMessages.size === 0) { + return; + } + + client.socket.send(...outgoingMessages.map(msg => ({ + cmd: 'Say', + text: msg, + } satisfies SayPacket))); + patchState(store, { + outgoingMessages: outgoingMessages.clear(), }); }); effect(() => { From f53a2b065fe5db43a7d05cc049a3cb16b2e0a1bd Mon Sep 17 00:00:00 2001 From: Joe Amenta Date: Fri, 13 Feb 2026 15:46:29 -0500 Subject: [PATCH 4/5] this was also kinda useless... --- src/app/store/autopelago-store.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app/store/autopelago-store.ts b/src/app/store/autopelago-store.ts index e53ef85..b13b5b4 100644 --- a/src/app/store/autopelago-store.ts +++ b/src/app/store/autopelago-store.ts @@ -235,10 +235,6 @@ export const GameStore = signalStore( return; } - if (!store.canEventuallyAdvance()) { - return; - } - const client = game.client; const players = client.players; let processedMessageCount = store.processedMessageCount(); From 12aacca2bb2dff7b48e1135e44570a2ef5463b05 Mon Sep 17 00:00:00 2001 From: Joe Amenta Date: Fri, 13 Feb 2026 16:13:25 -0500 Subject: [PATCH 5/5] Ensure each player is listed at least once before repeats; add `shuffle` utility. --- src/app/store/autopelago-store.ts | 15 ++++++++++----- src/app/utils/shuffle.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 src/app/utils/shuffle.ts diff --git a/src/app/store/autopelago-store.ts b/src/app/store/autopelago-store.ts index b13b5b4..a9ddab1 100644 --- a/src/app/store/autopelago-store.ts +++ b/src/app/store/autopelago-store.ts @@ -12,6 +12,7 @@ import { import type { AutopelagoClientAndData } from '../data/slot-data'; import { targetLocationEvidenceFromJSONSerializable } from '../game/target-location-evidence'; import { makePlayerToken } from '../utils/make-player-token'; +import { shuffle } from '../utils/shuffle'; import { toWeighted } from '../utils/weighted-sampler'; import { withCleverTimer } from './with-clever-timer'; import { withGameState } from './with-game-state'; @@ -158,13 +159,17 @@ export const GameStore = signalStore( return message; } + // list each player at least once before repeating any. + let currentRandomPlayerBag: Player[] = []; return message.replaceAll('{RANDOM_PLAYER}', () => { - const idx = Math.floor(Math.random() * randomPlayers.size); - const otherPlayer = randomPlayers.get(idx); - if (!otherPlayer) { - throw new Error('list.size is inconsistent with list.get(i). this is a bug in immutable.js'); + if (currentRandomPlayerBag.length === 0) { + currentRandomPlayerBag = shuffle([...randomPlayers]); } - return otherPlayer.alias; + const player = currentRandomPlayerBag.pop(); + if (player === undefined) { + throw new Error('pop should have returned something here. this is a programming error'); + } + return player.alias; }); } function _wrapMessageTemplate(f: ((...args: T) => string) | null) { diff --git a/src/app/utils/shuffle.ts b/src/app/utils/shuffle.ts new file mode 100644 index 0000000..23293ba --- /dev/null +++ b/src/app/utils/shuffle.ts @@ -0,0 +1,9 @@ +// Fisher-Yates shuffle +// https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#JavaScript_implementation +export function shuffle(array: T[]): T[] { + for (let i = array.length - 1; i >= 1; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +}