Release Healer Man 0.1.6

This commit is contained in:
phenom committed 2026-08-20 14:05:50 -04:00
1 parent 55cfd43d66
commit b12fb84859
96 files changed
+10874 -609

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
import { useEffect } from "react";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useOnlineGroupStore } from "./onlineGroupStore";
import {
classCanFillPartyRole,
defaultPartyRoleForClass,
type PartyRole,
} from "../game/partyRoles";
import { isManastormPartySize } from "../game/manastormProgress";
const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
export function OnlineGroupCoordinator() {
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const phase = useShellStore((state) => state.phase);
const snapshot = useOnlineGroupStore((state) => state.snapshot);
const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds);
const refresh = useOnlineGroupStore((state) => state.refresh);
const updateMember = useOnlineGroupStore((state) => state.updateMember);
const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity);
const reset = useOnlineGroupStore((state) => state.reset);
useEffect(() => {
if (!session || session.kind !== "account" || !session.accessToken) {
reset();
return undefined;
}
void refresh(session);
const timer = window.setInterval(() => void refresh(session, true), GROUP_POLL_MS);
const onVisibility = () => {
if (document.visibilityState === "visible") void refresh(session, true);
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [refresh, reset, session?.accessToken, session?.ownerId]);
useEffect(() => {
const group = snapshot.group;
if (!session || !character || !group || group.activity) return;
const member = group.members.find((candidate) => candidate.accountId === session.ownerId);
if (!member) return;
const role: PartyRole = member.role && classCanFillPartyRole(character.classId, member.role)
? member.role
: defaultPartyRoleForClass(character.classId);
const characterChanged = member.character?.id !== character.id
|| member.character?.level !== character.level
|| member.character?.classId !== character.classId;
if (!characterChanged && member.role === role) return;
void updateMember(session, character, role);
}, [character?.classId, character?.id, character?.level, session?.ownerId, snapshot.group?.id, snapshot.group?.updatedAt, updateMember]);
useEffect(() => {
const activity = snapshot.group?.activity;
if (!session || !activity || phase === "game" || consumedActivityIds.includes(activity.id)) return;
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
if (!member?.character || !member.role) return;
consumeActivity(activity.id);
void (async () => {
if (activity.type === "dungeon") {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
useShellStore.getState().enterDungeon(activity.contentId, member.role ?? undefined);
return;
}
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
installManastormShellRuntime();
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
useShellStore.getState().enterManastorm(
member.role ?? undefined,
partySize,
activity.startingLevel ?? undefined,
);
})().catch((error) => {
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
});
}, [consumeActivity, consumedActivityIds, phase, session?.ownerId, snapshot.group?.activity?.id]);
return null;
}
+76
View File
@@ -6,6 +6,8 @@ import {
type AccountResult,
} from "./accountRepository";
import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
import type { OnlineSessionSnapshot, OnlineSessionSyncInput } from "./onlineSessionTypes";
const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
const SAVE_DEBOUNCE_MS = 500;
@@ -159,3 +161,77 @@ export async function logoutOnlineSession(session: PlayerSession | null): Promis
}
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
}
function onlineGroupRequest<T>(session: PlayerSession, pathname: string, init: RequestInit = {}): Promise<T> {
if (!session.accessToken) throw new OnlineApiError("Online grouping requires a signed-in account.", 401);
return requestOnlineJson<T>(pathname, init, session.accessToken);
}
export function fetchOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
return onlineGroupRequest(session, "/api/online-group");
}
export async function inviteOnlineGroupPlayer(session: PlayerSession, username: string): Promise<OnlineGroupSnapshot> {
const response = await onlineGroupRequest<{ state: OnlineGroupSnapshot }>(session, "/api/online-group/invite", {
method: "POST",
body: JSON.stringify({ username }),
});
return response.state;
}
export function respondToOnlineGroupInvite(
session: PlayerSession,
inviteId: string,
accept: boolean,
): Promise<OnlineGroupSnapshot> {
return onlineGroupRequest(session, "/api/online-group/invite/respond", {
method: "POST",
body: JSON.stringify({ inviteId, accept }),
});
}
export function leaveOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
return onlineGroupRequest(session, "/api/online-group/leave", { method: "POST" });
}
export function removeOnlineGroupPlayer(session: PlayerSession, accountId: string): Promise<OnlineGroupSnapshot> {
return onlineGroupRequest(session, "/api/online-group/remove", {
method: "POST",
body: JSON.stringify({ accountId }),
});
}
export function updateOnlineGroupMember(
session: PlayerSession,
character: CharacterProfile,
role: string,
): Promise<OnlineGroupSnapshot> {
return onlineGroupRequest(session, "/api/online-group/member", {
method: "PUT",
body: JSON.stringify({ character, role }),
});
}
export function startOnlineGroupActivity(
session: PlayerSession,
activity: StartOnlineGroupActivityInput,
): Promise<OnlineGroupSnapshot> {
return onlineGroupRequest(session, "/api/online-group/activity", {
method: "POST",
body: JSON.stringify(activity),
});
}
export function clearOnlineGroupActivity(session: PlayerSession): Promise<OnlineGroupSnapshot> {
return onlineGroupRequest(session, "/api/online-group/activity/clear", { method: "POST" });
}
export function synchronizeOnlineSession(
session: PlayerSession,
input: OnlineSessionSyncInput,
): Promise<OnlineSessionSnapshot> {
return onlineGroupRequest(session, "/api/online-session/sync", {
method: "POST",
body: JSON.stringify(input),
});
}
+35
View File
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from "vitest";
import { onlineGroupAiPartySize, useOnlineGroupStore } from "./onlineGroupStore";
describe("online group AI fill sizing", () => {
beforeEach(() => useOnlineGroupStore.getState().reset());
it("reserves one simulated slot for every grouped human other than the local player", () => {
useOnlineGroupStore.setState({
snapshot: {
invitations: [],
group: {
id: "group-test",
leaderAccountId: "one",
activity: null,
activityRevision: 0,
createdAt: 1,
updatedAt: 1,
members: [
{ accountId: "one", username: "One", character: null, role: null, joinedAt: 1 },
{ accountId: "two", username: "Two", character: null, role: null, joinedAt: 2 },
{ accountId: "three", username: "Three", character: null, role: null, joinedAt: 3 },
],
},
},
});
expect(onlineGroupAiPartySize(5)).toBe(3);
expect(onlineGroupAiPartySize(3)).toBe(1);
});
it("keeps ordinary solo activity sizes unchanged", () => {
expect(onlineGroupAiPartySize(5)).toBe(5);
expect(onlineGroupAiPartySize(1)).toBe(1);
});
});
+177
View File
@@ -0,0 +1,177 @@
import { create } from "zustand";
import type { CharacterProfile, PlayerSession } from "./types";
import type { PartyRole } from "../game/partyRoles";
import type { PartySize } from "../game/partyRoles";
import {
clearOnlineGroupActivity,
fetchOnlineGroup,
inviteOnlineGroupPlayer,
leaveOnlineGroup,
removeOnlineGroupPlayer,
respondToOnlineGroupInvite,
startOnlineGroupActivity,
updateOnlineGroupMember,
} from "./onlineAccountClient";
import type { OnlineGroupActivity, OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
const EMPTY_SNAPSHOT: OnlineGroupSnapshot = Object.freeze({ group: null, invitations: Object.freeze([]) });
interface OnlineGroupState {
snapshot: OnlineGroupSnapshot;
loading: boolean;
busy: boolean;
error: string;
fillWithAi: boolean;
activeActivity: OnlineGroupActivity | null;
consumedActivityIds: readonly string[];
refresh: (session: PlayerSession, silent?: boolean) => Promise<void>;
invite: (session: PlayerSession, username: string) => Promise<boolean>;
respond: (session: PlayerSession, inviteId: string, accept: boolean) => Promise<boolean>;
leave: (session: PlayerSession) => Promise<boolean>;
remove: (session: PlayerSession, accountId: string) => Promise<boolean>;
updateMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
startActivity: (session: PlayerSession, input: Omit<StartOnlineGroupActivityInput, "fillWithAi">) => Promise<boolean>;
clearActivity: (session: PlayerSession) => Promise<boolean>;
setFillWithAi: (fillWithAi: boolean) => void;
consumeActivity: (activityId: string) => void;
reset: () => void;
}
function messageFor(error: unknown): string {
return error instanceof Error ? error.message : "The online group could not be updated.";
}
export const useOnlineGroupStore = create<OnlineGroupState>((set, get) => ({
snapshot: EMPTY_SNAPSHOT,
loading: false,
busy: false,
error: "",
fillWithAi: true,
activeActivity: null,
consumedActivityIds: [],
refresh: async (session, silent = false) => {
if (session.kind !== "account" || !session.accessToken) {
set({ snapshot: EMPTY_SNAPSHOT, loading: false, busy: false, error: "" });
return;
}
if (!silent) set({ loading: true });
try {
const snapshot = await fetchOnlineGroup(session);
set({ snapshot, loading: false, error: "" });
} catch (error) {
set({ loading: false, error: messageFor(error) });
}
},
invite: async (session, username) => {
set({ busy: true, error: "" });
try {
const snapshot = await inviteOnlineGroupPlayer(session, username);
set({ snapshot, busy: false, activeActivity: null });
return true;
} catch (error) {
set({ busy: false, error: messageFor(error) });
return false;
}
},
respond: async (session, inviteId, accept) => {
set({ busy: true, error: "" });
try {
const snapshot = await respondToOnlineGroupInvite(session, inviteId, accept);
set({ snapshot, busy: false, activeActivity: null });
return true;
} catch (error) {
set({ busy: false, error: messageFor(error) });
return false;
}
},
leave: async (session) => {
set({ busy: true, error: "" });
try {
const snapshot = await leaveOnlineGroup(session);
set({ snapshot, busy: false, activeActivity: null });
return true;
} catch (error) {
set({ busy: false, error: messageFor(error) });
return false;
}
},
remove: async (session, accountId) => {
set({ busy: true, error: "" });
try {
const snapshot = await removeOnlineGroupPlayer(session, accountId);
set({ snapshot, busy: false, activeActivity: null });
return true;
} catch (error) {
set({ busy: false, error: messageFor(error) });
return false;
}
},
updateMember: async (session, character, role) => {
set({ busy: true, error: "" });
try {
const snapshot = await updateOnlineGroupMember(session, character, role);
set({ snapshot, busy: false, activeActivity: null });
return true;
} catch (error) {
set({ busy: false, error: messageFor(error) });
return false;
}
},
startActivity: async (session, input) => {
set({ busy: true, error: "" });
try {
const snapshot = await startOnlineGroupActivity(session, { ...input, fillWithAi: get().fillWithAi });
set({ snapshot, busy: false });
return true;
} catch (error) {
set({ busy: false, error: messageFor(error) });
return false;
}
},
clearActivity: async (session) => {
set({ busy: true, error: "" });
try {
const snapshot = await clearOnlineGroupActivity(session);
set({ snapshot, busy: false, activeActivity: null });
return true;
} catch (error) {
set({ busy: false, error: messageFor(error) });
return false;
}
},
setFillWithAi: (fillWithAi) => set({ fillWithAi }),
consumeActivity: (activityId) => set((state) => {
if (state.consumedActivityIds.includes(activityId)) return state;
return {
activeActivity: state.snapshot.group?.activity?.id === activityId
? state.snapshot.group.activity
: state.activeActivity,
consumedActivityIds: [...state.consumedActivityIds.slice(-7), activityId],
};
}),
reset: () => set({
snapshot: EMPTY_SNAPSHOT,
loading: false,
busy: false,
error: "",
activeActivity: null,
consumedActivityIds: [],
}),
}));
export function onlineGroupHumanCount(): number {
return useOnlineGroupStore.getState().snapshot.group?.members.length ?? 1;
}
export function onlineGroupAiPartySize(activityPartySize: number): PartySize {
return Math.max(1, Math.min(5, activityPartySize - Math.max(0, onlineGroupHumanCount() - 1))) as PartySize;
}
+63
View File
@@ -0,0 +1,63 @@
import type { PartyRole } from "../game/partyRoles";
import type { CharacterAppearance } from "./characterCatalog";
export interface OnlineGroupCharacter {
readonly id: string;
readonly name: string;
readonly classId: string;
readonly categoryId: string;
readonly raceId: string;
readonly gender: "female" | "male";
readonly level: number;
readonly appearance?: CharacterAppearance;
}
export interface OnlineGroupMember {
readonly accountId: string;
readonly username: string;
readonly character: OnlineGroupCharacter | null;
readonly role: PartyRole | null;
readonly joinedAt: number;
}
export interface OnlineGroupActivity {
readonly id: string;
readonly type: "dungeon" | "manastorm";
readonly contentId: string;
readonly fillWithAi: boolean;
readonly partySize: number;
readonly startingLevel: number | null;
readonly startedAt: number;
}
export interface OnlineGroup {
readonly id: string;
readonly leaderAccountId: string;
readonly members: readonly OnlineGroupMember[];
readonly activity: OnlineGroupActivity | null;
readonly activityRevision: number;
readonly createdAt: number;
readonly updatedAt: number;
}
export interface OnlineGroupInvitation {
readonly id: string;
readonly groupId: string;
readonly inviterAccountId: string;
readonly inviterUsername: string;
readonly createdAt: number;
readonly expiresAt: number;
}
export interface OnlineGroupSnapshot {
readonly group: OnlineGroup | null;
readonly invitations: readonly OnlineGroupInvitation[];
}
export interface StartOnlineGroupActivityInput {
readonly type: OnlineGroupActivity["type"];
readonly contentId: string;
readonly fillWithAi: boolean;
readonly partySize: number;
readonly startingLevel?: number;
}
+24
View File
@@ -0,0 +1,24 @@
import { create } from "zustand";
import type { OnlineSessionSnapshot } from "./onlineSessionTypes";
interface OnlineSessionState {
snapshot: OnlineSessionSnapshot | null;
connected: boolean;
synchronizing: boolean;
error: string;
setSynchronizing: (synchronizing: boolean) => void;
accept: (snapshot: OnlineSessionSnapshot) => void;
fail: (message: string) => void;
reset: () => void;
}
export const useOnlineSessionStore = create<OnlineSessionState>((set) => ({
snapshot: null,
connected: false,
synchronizing: false,
error: "",
setSynchronizing: (synchronizing) => set({ synchronizing }),
accept: (snapshot) => set({ snapshot, connected: true, synchronizing: false, error: "" }),
fail: (error) => set({ connected: false, synchronizing: false, error }),
reset: () => set({ snapshot: null, connected: false, synchronizing: false, error: "" }),
}));
+108
View File
@@ -0,0 +1,108 @@
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import type { ActiveCast, MobCombatState } from "../game/combatStore";
import type { DamageSchool } from "../game/combatAuras";
import type { InventoryItem } from "../game/lootTypes";
import type { PartyMember } from "../game/partyStore";
import type { OnlineGroupActivity, OnlineGroupCharacter } from "./onlineGroupTypes";
import type { PartyRole } from "../game/partyRoles";
export type OnlineSessionVector = readonly [number, number, number];
export interface OnlineSessionPlayer {
readonly accountId: string;
readonly username: string;
readonly character: OnlineGroupCharacter | null;
readonly role: PartyRole | null;
readonly position: OnlineSessionVector;
readonly yaw: number;
readonly grounded: boolean;
readonly health: number;
readonly maxHealth: number;
readonly resource: number;
readonly maxResource: number;
readonly resourceName: string;
readonly selectedTargetId: string | null;
readonly activeCast: ActiveCast | null;
readonly animationEvent: CharacterCombatAnimationEvent | null;
readonly equipment: readonly InventoryItem[];
readonly updatedAt: number;
}
export interface OnlineMobTransform {
readonly position: OnlineSessionVector;
readonly yaw: number;
}
export interface OnlineSharedWorld {
readonly mobs: Readonly<Record<string, MobCombatState>>;
readonly mobTransforms: Readonly<Record<string, OnlineMobTransform>>;
readonly partyMembers: readonly PartyMember[];
readonly partyPositions: Readonly<Record<string, OnlineSessionVector>>;
readonly partyRuntime: {
readonly command: string;
readonly activeMobId: string | null;
readonly objectiveBossId: string | null;
readonly memberTargetIds: Readonly<Record<string, string>>;
};
readonly wailing: Readonly<Record<string, unknown>> | null;
readonly manastorm: Readonly<Record<string, unknown>> | null;
readonly publishedAt?: number;
readonly publishedBy?: string;
}
export type OnlineRelayEventKind = "damage" | "healing" | "resurrection" | "loot" | "portal";
export interface OnlineRelayEventInput {
readonly clientEventId: string;
readonly kind: OnlineRelayEventKind;
readonly sourceActorId: string;
readonly targetActorId: string;
readonly abilityId?: string | null;
readonly school?: DamageSchool | null;
readonly rawAmount?: number;
readonly effectiveAmount?: number;
readonly critical?: boolean;
readonly occurredAt?: number;
}
export interface OnlineRelayEvent extends OnlineRelayEventInput {
readonly id: number;
readonly sourceAccountId: string;
readonly createdAt: number;
}
export interface OnlineSessionSnapshot {
readonly activity: OnlineGroupActivity;
readonly groupId: string;
readonly leaderAccountId: string;
readonly localAccountId: string;
readonly authority: boolean;
readonly revision: number;
readonly serverTime: number;
readonly players: readonly OnlineSessionPlayer[];
readonly world: OnlineSharedWorld | null;
readonly events: readonly OnlineRelayEvent[];
readonly latestEventId: number;
}
export interface OnlinePresenceInput {
readonly position: OnlineSessionVector;
readonly yaw: number;
readonly grounded: boolean;
readonly health: number;
readonly maxHealth: number;
readonly resource: number;
readonly maxResource: number;
readonly resourceName: string;
readonly selectedTargetId: string | null;
readonly activeCast: ActiveCast | null;
readonly animationEvent: CharacterCombatAnimationEvent | null;
readonly equipment: readonly InventoryItem[];
}
export interface OnlineSessionSyncInput {
readonly afterEventId: number;
readonly presence: OnlinePresenceInput;
readonly world?: OnlineSharedWorld;
readonly events?: readonly OnlineRelayEventInput[];
}