diff --git a/src/app/archipelago-client.ts b/src/app/archipelago-client.ts index 9e05eae..e516f9f 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'; @@ -80,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; @@ -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..a9ddab1 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, @@ -12,10 +12,12 @@ 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'; +const ONLINE_AND_NOT_GOALED_STATUSES = new Set([clientStatuses.connected, clientStatuses.ready, clientStatuses.playing]); export const GameStore = signalStore( withCleverTimer(), withGameState(), @@ -125,6 +127,70 @@ 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; + } + + // list each player at least once before repeating any. + let currentRandomPlayerBag: Player[] = []; + return message.replaceAll('{RANDOM_PLAYER}', () => { + if (currentRandomPlayerBag.length === 0) { + currentRandomPlayerBag = shuffle([...randomPlayers]); + } + 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) { + 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 +214,7 @@ export const GameStore = signalStore( return; } - const sampleMessage = store.sampleMessage().forCompletedGoal; + const sampleMessage = store.sampleMessageFull().forCompletedGoal; if (sampleMessage === null) { return; } @@ -174,10 +240,6 @@ export const GameStore = signalStore( return; } - if (!store.canEventuallyAdvance()) { - return; - } - const client = game.client; const players = client.players; let processedMessageCount = store.processedMessageCount(); @@ -185,19 +247,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(() => { @@ -207,7 +276,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 +327,7 @@ export const GameStore = signalStore( return; } - const sampleMessage = store.sampleMessage().forEnterGoMode; + const sampleMessage = store.sampleMessageFull().forEnterGoMode; if (sampleMessage === null) { return; } @@ -273,7 +342,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; } 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; +}