Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion src/app/archipelago-client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -189,6 +202,7 @@ export async function initializeClient(initializeClientOptions: InitializeClient
client,
pkg,
messageLog,
playersWithStatus,
slotData,
hintedLocations,
hintedItems,
Expand Down Expand Up @@ -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<List<Readonly<PlayerAndStatus>>> {
const allPlayers = client.players.teams[client.players.self.team];
const playerList = signal(List<PlayerAndStatus>(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+_(?<slot>\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();
}
5 changes: 3 additions & 2 deletions src/app/data/slot-data.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,6 +17,7 @@ export interface AutopelagoClientAndData {
locationIsProgression: Readonly<BitArray>;
locationIsTrap: Readonly<BitArray>;
messageLog: Signal<List<Message>>;
playersWithStatus: Signal<List<Readonly<PlayerAndStatus>>>;
hintedLocations: Signal<List<Hint | null>>;
hintedItems: Signal<List<Hint | null>>;
slotData: AutopelagoSlotData;
Expand Down
113 changes: 91 additions & 22 deletions src/app/store/autopelago-store.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<ClientStatus>([clientStatuses.connected, clientStatuses.ready, clientStatuses.playing]);
export const GameStore = signalStore(
withCleverTimer(),
withGameState(),
Expand Down Expand Up @@ -125,6 +127,70 @@ export const GameStore = signalStore(
});
},
})),
withComputed((store) => {
const bestEffortRandomPlayers = computed(() => {
const game = store.game();
if (game === null) {
return List<Player>();
}

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<T extends unknown[]>(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(() => {
Expand All @@ -148,7 +214,7 @@ export const GameStore = signalStore(
return;
}

const sampleMessage = store.sampleMessage().forCompletedGoal;
const sampleMessage = store.sampleMessageFull().forCompletedGoal;
if (sampleMessage === null) {
return;
}
Expand All @@ -174,30 +240,33 @@ export const GameStore = signalStore(
return;
}

if (!store.canEventuallyAdvance()) {
return;
}

const client = game.client;
const players = client.players;
let processedMessageCount = store.processedMessageCount();
for (const message of game.messageLog().skip(processedMessageCount)) {
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(() => {
Expand All @@ -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;
}
Expand Down Expand Up @@ -258,7 +327,7 @@ export const GameStore = signalStore(
return;
}

const sampleMessage = store.sampleMessage().forEnterGoMode;
const sampleMessage = store.sampleMessageFull().forEnterGoMode;
if (sampleMessage === null) {
return;
}
Expand All @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions src/app/utils/shuffle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Fisher-Yates shuffle
// https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#JavaScript_implementation
export function shuffle<T>(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;
}