Seven steps in one commit, 1.15.0 to 1.21.0. They change the same files and were not saved apart, so they go in together. - The battle at a glance (1.15). A strip across the top of an encounter lists everyone in turn order and stays in view; a click goes to that card. On a wide screen the stat block of whoever is on turn stays beside the cards, and Pin keeps anyone else's there. Saving throws, every skill and passive Perception, Investigation and Insight are on encounter cards and in the character panel, which "Sheet" on a card and a button on the party overview open. For the DM, N is Next Turn and S opens the sheet of whoever is on turn. The party overview shows passive scores and spell save DC. - Subclasses (1.16). Three per class, picked in the level-up checklist at level 2 (cleric, druid, wizard) or 3, shown beside the class everywhere, and changed by the DM from the sheet: the old one's lines come out of Special Abilities and the new one's go in. Characters already at that level keep the path they were on. - Character state (1.17). Inspiration, exhaustion and conditions on the sheet and in the panel, and counters for what a class does a set number of times (Rage, Ki, Channel Divinity...), which the player spends and a rest gives back. A long rest takes a level of exhaustion off. A character's conditions are their own now: they stay from one encounter to the next. - Faster battles (1.18). Up to 20 of a monster added at once, numbered. One button rolls initiative for the monsters and NPCs. A condition can be given a number of rounds and then comes off by itself. Hovering over a condition shows its rule, and damaging someone who is Concentrating shows the save to call for. - Rolls from the sheet (1.19). A number with a dotted underline rolls when clicked, with its bonus: skills, saves, ability checks, attacks, damage, spell dice, and a monster's attack, damage and abilities. The dice roller has advantage and disadvantage. "Call for a roll" rolls one check or save for the whole party. Rolls stay in the browser. - XP and difficulty (1.20). An encounter shows the DM how hard it is, Trivial to Deadly, from the monsters' challenge ratings against the characters in it, and what it is worth in XP; one button shares that out, once. The sheet and the party overview show XP and when a level is due. Levelling up stays the DM's decision. - Subclasses are optional per campaign (1.21). With "Characters choose a subclass" unticked in the campaign's Settings nobody is asked, each class follows its first subclass, and none is named. - Conditions, exhaustion, counters and subclass features are reminders for the table: none of them changes a number on the sheet. - The docs cover all of it, with generated tables of what each subclass adds. - New migrations: a character's subclass; inspiration, exhaustion, conditions and spent counters on a character; the rounds left on a combatant's conditions; the XP awarded for an encounter; the campaign's subclass setting. - All packages at version 1.21.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1112 lines
44 KiB
TypeScript
1112 lines
44 KiB
TypeScript
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
|
import { apiClient } from './client';
|
|
import type {
|
|
BuildOptions,
|
|
Campaign,
|
|
CampaignLibrary,
|
|
CampaignMemberSummary,
|
|
CharacterDetail,
|
|
CharacterSummary,
|
|
Currency,
|
|
DiceMode,
|
|
Encounter,
|
|
EncounterMode,
|
|
EncounterParticipant,
|
|
GearEffect,
|
|
GoldTransaction,
|
|
Item,
|
|
LevelUpPlan,
|
|
Location,
|
|
Monster,
|
|
Note,
|
|
Npc,
|
|
SaleOffer,
|
|
QuickStats,
|
|
RestResult,
|
|
RestType,
|
|
Spell,
|
|
Weapon,
|
|
} from './types';
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Campaigns
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useCampaigns() {
|
|
return useQuery({
|
|
queryKey: ['campaigns'],
|
|
queryFn: async () => (await apiClient.get<{ campaigns: Campaign[] }>('/campaigns')).data.campaigns,
|
|
});
|
|
}
|
|
|
|
export function useCampaign(campaignId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId],
|
|
enabled: Boolean(campaignId),
|
|
queryFn: async () =>
|
|
(
|
|
await apiClient.get<{ campaign: Campaign; members: CampaignMemberSummary[] }>(`/campaigns/${campaignId}`)
|
|
).data,
|
|
});
|
|
}
|
|
|
|
export function useCreateCampaign() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: {
|
|
name: string;
|
|
description?: string;
|
|
startingGold?: number;
|
|
startingItemCount?: number;
|
|
startingSpellCount?: number;
|
|
skipMotivationRoll?: boolean;
|
|
}) => (await apiClient.post<{ campaign: Campaign }>('/campaigns', data)).data.campaign,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
|
|
});
|
|
}
|
|
|
|
// A ready-made world (a seed file on the server) as a new campaign, by its code.
|
|
export function useCreateCampaignFromSeed() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (code: string) =>
|
|
(await apiClient.post<{ campaign: Campaign }>('/campaigns/from-seed', { code })).data.campaign,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
|
|
});
|
|
}
|
|
|
|
export function useUpdateCampaign(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: Partial<Campaign> & { archived?: boolean }) =>
|
|
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, data)).data.campaign,
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
|
|
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Archived campaigns stay whole; they only move off the main list.
|
|
export function useSetCampaignArchived() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ campaignId, archived }: { campaignId: string; archived: boolean }) =>
|
|
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, { archived })).data.campaign,
|
|
onSuccess: (campaign) => {
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaign.id] });
|
|
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Permanent: takes every character, encounter and note in the campaign with it.
|
|
export function useDeleteCampaign() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (campaignId: string) => apiClient.delete(`/campaigns/${campaignId}`),
|
|
onSuccess: (_res, campaignId) => {
|
|
qc.removeQueries({ queryKey: ['campaign', campaignId] });
|
|
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useJoinCampaign() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (inviteCode: string) =>
|
|
(await apiClient.post<{ campaign: Campaign }>(`/campaigns/join/${inviteCode}`)).data.campaign,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
|
|
});
|
|
}
|
|
|
|
export function useRegenerateInvite(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async () =>
|
|
(await apiClient.post<{ inviteCode: string }>(`/campaigns/${campaignId}/invite`, { regenerate: true })).data
|
|
.inviteCode,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId] }),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Characters
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useCampaignCharacters(campaignId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'characters'],
|
|
enabled: Boolean(campaignId),
|
|
queryFn: async () =>
|
|
(await apiClient.get<{ characters: CharacterSummary[] }>(`/campaigns/${campaignId}/characters`)).data
|
|
.characters,
|
|
});
|
|
}
|
|
|
|
export function useCreateCharacter(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { name: string; userId?: string }) =>
|
|
(await apiClient.post<{ character: CharacterSummary }>(`/campaigns/${campaignId}/characters`, data)).data
|
|
.character,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] }),
|
|
});
|
|
}
|
|
|
|
export function useCharacter(characterId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['character', characterId],
|
|
enabled: Boolean(characterId),
|
|
refetchInterval: false,
|
|
queryFn: async () => (await apiClient.get<{ character: CharacterDetail }>(`/characters/${characterId}`)).data
|
|
.character,
|
|
});
|
|
}
|
|
|
|
// `gold` is deliberately not patchable — it only moves through useAdjustGold.
|
|
export function usePatchCharacter(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: Partial<Omit<CharacterDetail, 'gold'>> & { hitDiceUsed?: number }) =>
|
|
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
|
|
onSuccess: (character) => {
|
|
qc.setQueryData(['character', characterId], character);
|
|
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// What can be done for a character at 0 HP: enter a death save (what the d20
|
|
// showed), steady them, or — the DM only — set the tallies by hand or bring
|
|
// back the dead.
|
|
export type DeathSaveAction =
|
|
| { action: 'roll'; roll: number }
|
|
| { action: 'stabilize' }
|
|
| { action: 'set'; successes: number; failures: number }
|
|
| { action: 'revive' };
|
|
|
|
export function useDeathSave(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: DeathSaveAction) =>
|
|
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/death-saves`, data)).data.character,
|
|
onSuccess: (character) => {
|
|
invalidateCharacter(qc, characterId); // the sheet and its quick stats
|
|
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
|
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// The slot tracker. Spending a slot is the player's (or the DM's); setting how
|
|
// many are spent, and giving them all back, are the DM's alone.
|
|
export type SpellSlotAction = { action: 'use'; level: number } | { action: 'set'; level: number; used: number } | { action: 'restore' };
|
|
|
|
export function useSpellSlots(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: SpellSlotAction) =>
|
|
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/spell-slots`, data)).data.character,
|
|
onSuccess: (character) => {
|
|
invalidateCharacter(qc, characterId); // the sheet and its quick stats
|
|
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// The class counters. Spending is for the character's own player and the DM,
|
|
// one use at a time or `amount` from a pool; setting how many are spent, and
|
|
// giving them all back, are the DM's alone.
|
|
export type ResourceAction = { action: 'use'; key: string; amount?: number } | { action: 'set'; key: string; used: number } | { action: 'restore' };
|
|
|
|
export function useResources(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: ResourceAction) =>
|
|
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/resources`, data)).data.character,
|
|
onSuccess: (character) => {
|
|
invalidateCharacter(qc, characterId);
|
|
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows its counters
|
|
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Inspiration, exhaustion and conditions. The DM sets all three; the
|
|
// character's own player may spend inspiration (set it to false).
|
|
export function useCharacterState(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { inspiration?: boolean; exhaustion?: number; conditions?: string[] }) =>
|
|
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
|
|
onSuccess: (character) => {
|
|
invalidateCharacter(qc, characterId);
|
|
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows all of it
|
|
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// What a character's next level brings. DM only.
|
|
export function useLevelUpPlan(characterId: string, enabled: boolean) {
|
|
return useQuery({
|
|
queryKey: ['character', characterId, 'level-up'],
|
|
enabled,
|
|
gcTime: 0, // never open on the plan for a level the character has already reached
|
|
queryFn: async () => (await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`)).data.plan,
|
|
});
|
|
}
|
|
|
|
// Takes the character up one level, with the choices that go with it.
|
|
export function useLevelUp(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
|
|
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
|
|
onSuccess: (character) => {
|
|
invalidateCharacter(qc, characterId);
|
|
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
|
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// A short or long rest for any of the party at once. On a short rest each
|
|
// character spends `hitDice` Hit Dice; `rolled` is what they added up to at
|
|
// the table, and left out the site rolls them.
|
|
export function useRest(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { type: RestType; characters: { characterId: string; hitDice?: number; rolled?: number | null }[] }) =>
|
|
(await apiClient.post<{ type: RestType; results: RestResult[] }>(`/campaigns/${campaignId}/rest`, data)).data,
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['character'] });
|
|
qc.invalidateQueries({ queryKey: ['encounter'] });
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteCharacter(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (characterId: string) => apiClient.delete(`/characters/${characterId}`),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] }),
|
|
});
|
|
}
|
|
|
|
// What one character can hand to another. Whatever is given leaves the giver.
|
|
export type Gift =
|
|
| { kind: 'gold'; amount: number; currency?: Currency }
|
|
| { kind: 'weapon' | 'item'; id: string; quantity: number }
|
|
| { kind: 'spell'; id: string };
|
|
|
|
export function useGive(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (gift: Gift & { toCharacterId: string }) =>
|
|
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/give`, gift)).data.character,
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['character'] }); // both sheets, and both money logs
|
|
qc.invalidateQueries({ queryKey: ['campaign'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCopyCharacter(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (targetCampaignId: string) =>
|
|
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/copy`, { targetCampaignId })).data
|
|
.character,
|
|
onSuccess: (character) => {
|
|
qc.setQueryData(['character', character.id], character);
|
|
// The copy can add missing weapons/spells to the target campaign's pools.
|
|
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useQuickStats(characterId: string | undefined, enabled: boolean) {
|
|
return useQuery({
|
|
queryKey: ['character', characterId, 'quick-stats'],
|
|
enabled: Boolean(characterId) && enabled,
|
|
queryFn: async () =>
|
|
(await apiClient.get<{ quickStats: QuickStats }>(`/characters/${characterId}/quick-stats`)).data.quickStats,
|
|
});
|
|
}
|
|
|
|
// Changes `amount` coins of one kind into another at their fixed worth.
|
|
export function useExchangeCoins(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { from: Currency; to: Currency; amount: number }) =>
|
|
(await apiClient.post<{ character: CharacterDetail; received: number }>(`/characters/${characterId}/gold/exchange`, data)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Character build flow
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useBuildOptions() {
|
|
return useQuery({
|
|
queryKey: ['build-options'],
|
|
staleTime: Infinity, // fixed lists — they never change while the app is open
|
|
queryFn: async () => (await apiClient.get<BuildOptions>('/characters/build/options')).data,
|
|
});
|
|
}
|
|
|
|
function invalidateCharacter(qc: ReturnType<typeof useQueryClient>, characterId: string) {
|
|
qc.invalidateQueries({ queryKey: ['character', characterId] });
|
|
}
|
|
|
|
export function usePatchDiceMode(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (diceMode: DiceMode) =>
|
|
(await apiClient.patch<{ diceMode: DiceMode }>(`/characters/${characterId}/build/dice-mode`, { diceMode })).data
|
|
.diceMode,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function useBuildRoll(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (body?: { value?: number; abilitySets?: number[][] }) =>
|
|
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(
|
|
`/characters/${characterId}/build/roll`,
|
|
body
|
|
)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function useBuildChoose(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (body: Record<string, unknown>) =>
|
|
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(
|
|
`/characters/${characterId}/build/choose`,
|
|
body
|
|
)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
// Steps back one step so its choice can be changed. Dice are never re-rolled.
|
|
export function useBuildBack(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async () =>
|
|
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(`/characters/${characterId}/build/back`)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function useBuildFinalize(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async () =>
|
|
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/build/finalize`)).data
|
|
.character,
|
|
onSuccess: (character) => {
|
|
qc.setQueryData(['character', characterId], character);
|
|
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Weapons / inventory / spells on a character
|
|
// --------------------------------------------------------------------------
|
|
|
|
// Handing out, removing and re-counting gear is DM-only on the server; the
|
|
// owning player can only toggle equipped / prepared.
|
|
export function useAssignWeapon(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { weaponId: string; quantity?: number }) =>
|
|
(await apiClient.post(`/characters/${characterId}/weapons`, data)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function useRemoveCharacterWeapon(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (weaponId: string) => apiClient.delete(`/characters/${characterId}/weapons/${weaponId}`),
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function usePatchCharacterWeapon(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ weaponId, ...data }: { weaponId: string; equipped?: boolean; quantity?: number; level?: number }) =>
|
|
(await apiClient.patch(`/characters/${characterId}/weapons/${weaponId}`, data)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
// itemId here is the campaign pool item's id, as with weapons and spells.
|
|
export function useAssignItem(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { itemId: string; quantity?: number }) =>
|
|
(await apiClient.post(`/characters/${characterId}/inventory`, data)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function usePatchInventoryItem(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ itemId, ...data }: { itemId: string; quantity?: number; equipped?: boolean; level?: number }) =>
|
|
(await apiClient.patch(`/characters/${characterId}/inventory/${itemId}`, data)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function useRemoveInventoryItem(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (itemId: string) => apiClient.delete(`/characters/${characterId}/inventory/${itemId}`),
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function useAssignSpell(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { spellId: string; prepared?: boolean }) =>
|
|
(await apiClient.post(`/characters/${characterId}/spells`, data)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function usePatchCharacterSpell(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ spellId, prepared }: { spellId: string; prepared: boolean }) =>
|
|
(await apiClient.patch(`/characters/${characterId}/spells/${spellId}`, { prepared })).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
export function useRemoveCharacterSpell(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (spellId: string) => apiClient.delete(`/characters/${characterId}/spells/${spellId}`),
|
|
onSuccess: () => invalidateCharacter(qc, characterId),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Money tracker
|
|
// --------------------------------------------------------------------------
|
|
|
|
// Only the character's own player and the DM may read it.
|
|
export function useGoldLedger(characterId: string | undefined, enabled = true) {
|
|
return useQuery({
|
|
queryKey: ['character', characterId, 'gold'],
|
|
enabled: Boolean(characterId) && enabled,
|
|
queryFn: async () =>
|
|
(await apiClient.get<{ gold: number; transactions: GoldTransaction[] }>(`/characters/${characterId}/gold`)).data
|
|
.transactions,
|
|
});
|
|
}
|
|
|
|
// amount is signed: positive = gained, negative = spent.
|
|
export function useAdjustGold(characterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { amount: number; currency?: Currency; reason?: string }) =>
|
|
(await apiClient.post<{ transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
|
|
onSuccess: () => invalidateCharacter(qc, characterId), // prefix match also refreshes the ledger
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Campaign weapons / spells / monsters pools
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useCampaignWeapons(campaignId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'weapons'],
|
|
enabled: Boolean(campaignId),
|
|
queryFn: async () => (await apiClient.get<{ weapons: Weapon[] }>(`/campaigns/${campaignId}/weapons`)).data.weapons,
|
|
});
|
|
}
|
|
|
|
export function useCreateWeapon(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: Omit<Weapon, 'id' | 'campaignId'>) =>
|
|
(await apiClient.post<{ weapon: Weapon }>(`/campaigns/${campaignId}/weapons`, data)).data.weapon,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'weapons'] }),
|
|
});
|
|
}
|
|
|
|
// Edits (or, with useDeletePoolEntry, removes) one entry of a campaign pool.
|
|
// Used for the "starting option" toggle on weapons, spells and items.
|
|
type Pool = 'weapons' | 'spells' | 'items' | 'npcs';
|
|
|
|
export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
id,
|
|
...data
|
|
}: {
|
|
id: string;
|
|
isStartingOption?: boolean;
|
|
damageDice?: string | null;
|
|
damageScaling?: number;
|
|
revives?: boolean;
|
|
minLevel?: number;
|
|
name?: string;
|
|
description?: string | null;
|
|
exchangeGold?: number | null;
|
|
exchangeCash?: number | null;
|
|
effects?: GearEffect[];
|
|
effectNote?: string | null;
|
|
}) =>
|
|
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] });
|
|
// What an entry does while equipped changes the sheet of whoever holds it.
|
|
qc.invalidateQueries({ queryKey: ['character'] });
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeletePoolEntry(campaignId: string, pool: Pool) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (id: string) => apiClient.delete(`/campaigns/${campaignId}/${pool}/${id}`),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] });
|
|
qc.invalidateQueries({ queryKey: ['character'] }); // characters holding it just lost it
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCampaignItems(campaignId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'items'],
|
|
enabled: Boolean(campaignId),
|
|
queryFn: async () => (await apiClient.get<{ items: Item[] }>(`/campaigns/${campaignId}/items`)).data.items,
|
|
});
|
|
}
|
|
|
|
export function useCreateItem(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: Omit<Item, 'id' | 'campaignId'>) =>
|
|
(await apiClient.post<{ item: Item }>(`/campaigns/${campaignId}/items`, data)).data.item,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'items'] }),
|
|
});
|
|
}
|
|
|
|
export function useCampaignSpells(campaignId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'spells'],
|
|
enabled: Boolean(campaignId),
|
|
queryFn: async () => (await apiClient.get<{ spells: Spell[] }>(`/campaigns/${campaignId}/spells`)).data.spells,
|
|
});
|
|
}
|
|
|
|
export function useCreateSpell(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: Omit<Spell, 'id' | 'campaignId'>) =>
|
|
(await apiClient.post<{ spell: Spell }>(`/campaigns/${campaignId}/spells`, data)).data.spell,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'spells'] }),
|
|
});
|
|
}
|
|
|
|
// Monster stat blocks are DM-only on the server, so only the DM asks for them.
|
|
export function useCampaignMonsters(campaignId: string | undefined, isDM: boolean) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'monsters'],
|
|
enabled: Boolean(campaignId) && isDM,
|
|
queryFn: async () =>
|
|
(await apiClient.get<{ monsters: Monster[] }>(`/campaigns/${campaignId}/monsters`)).data.monsters,
|
|
});
|
|
}
|
|
|
|
export function useCreateMonster(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: Omit<Monster, 'id' | 'campaignId'>) =>
|
|
(await apiClient.post<{ monster: Monster }>(`/campaigns/${campaignId}/monsters`, data)).data.monster,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
|
|
});
|
|
}
|
|
|
|
// Changes a monster's stat block. Encounters read it live, so copies already
|
|
// in a scene show the new numbers (their HP stays as it was).
|
|
export function useUpdateMonster(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ id, ...data }: { id: string } & Partial<Omit<Monster, 'id' | 'campaignId'>>) =>
|
|
(await apiClient.patch<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/${id}`, data)).data.monster,
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] });
|
|
qc.invalidateQueries({ queryKey: ['encounter'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteMonster(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (monsterId: string) => apiClient.delete(`/campaigns/${campaignId}/monsters/${monsterId}`),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Default content library — search/browse, "add to campaign"
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useCampaignLibrary(campaignId: string | undefined, enabled = true) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'library'],
|
|
enabled: Boolean(campaignId) && enabled, // DM only
|
|
staleTime: Infinity, // static reference data — never changes under a campaign
|
|
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
|
|
});
|
|
}
|
|
|
|
export function useAddWeaponFromLibrary(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (key: string) =>
|
|
(await apiClient.post<{ weapon: Weapon }>(`/campaigns/${campaignId}/weapons/from-library`, { key })).data
|
|
.weapon,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'weapons'] }),
|
|
});
|
|
}
|
|
|
|
export function useAddItemFromLibrary(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (key: string) => (await apiClient.post<{ item: Item }>(`/campaigns/${campaignId}/items/from-library`, { key })).data.item,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'items'] }),
|
|
});
|
|
}
|
|
|
|
export function useAddSpellFromLibrary(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (key: string) =>
|
|
(await apiClient.post<{ spell: Spell }>(`/campaigns/${campaignId}/spells/from-library`, { key })).data.spell,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'spells'] }),
|
|
});
|
|
}
|
|
|
|
export function useAddMonsterFromLibrary(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (key: string) =>
|
|
(await apiClient.post<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/from-library`, { key })).data
|
|
.monster,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Locations
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useCampaignLocations(campaignId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'locations'],
|
|
enabled: Boolean(campaignId),
|
|
queryFn: async () =>
|
|
(await apiClient.get<{ locations: Location[] }>(`/campaigns/${campaignId}/locations`)).data.locations,
|
|
});
|
|
}
|
|
|
|
export function useCreateLocation(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { name: string; description?: string | null }) =>
|
|
(await apiClient.post<{ location: Location }>(`/campaigns/${campaignId}/locations`, data)).data.location,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] }),
|
|
});
|
|
}
|
|
|
|
export function useDeleteLocation(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (locationId: string) => apiClient.delete(`/campaigns/${campaignId}/locations/${locationId}`),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] }),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Encounters
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useCampaignEncounters(campaignId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'encounters'],
|
|
enabled: Boolean(campaignId),
|
|
queryFn: async () =>
|
|
(await apiClient.get<{ encounters: Encounter[] }>(`/campaigns/${campaignId}/encounters`)).data.encounters,
|
|
});
|
|
}
|
|
|
|
export function useCreateEncounter(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
|
|
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
|
|
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
|
|
});
|
|
}
|
|
|
|
// Sets the campaign's own encounter order to the ids given, first to last.
|
|
export function useReorderEncounters(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
const key = ['campaign', campaignId, 'encounters'];
|
|
return useMutation({
|
|
mutationFn: async (encounterIds: string[]) => apiClient.put(`/campaigns/${campaignId}/encounters/order`, { encounterIds }),
|
|
// Show the move straight away; the refetch below confirms or undoes it.
|
|
onMutate: (encounterIds) => {
|
|
qc.setQueryData<Encounter[]>(key, (old) => {
|
|
if (!old) return old;
|
|
const byId = new Map(old.map((e) => [e.id, e]));
|
|
return encounterIds.flatMap((id, sortOrder) => {
|
|
const encounter = byId.get(id);
|
|
return encounter ? [{ ...encounter, sortOrder }] : [];
|
|
});
|
|
});
|
|
},
|
|
onSettled: () => qc.invalidateQueries({ queryKey: key }),
|
|
});
|
|
}
|
|
|
|
export function useSetEncounterArchived(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ encounterId, archived }: { encounterId: string; archived: boolean }) =>
|
|
(await apiClient.patch(`/encounters/${encounterId}`, { archived })).data,
|
|
onSuccess: (_data, { encounterId }) => {
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
|
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Permanent. Rewards already handed out stay with the characters.
|
|
export function useDeleteEncounter(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (encounterId: string) => apiClient.delete(`/encounters/${encounterId}`),
|
|
onSuccess: (_res, encounterId) => {
|
|
qc.removeQueries({ queryKey: ['encounter', encounterId] });
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useEncounter(encounterId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['encounter', encounterId],
|
|
enabled: Boolean(encounterId),
|
|
queryFn: async () => (await apiClient.get<{ encounter: Encounter }>(`/encounters/${encounterId}`)).data.encounter,
|
|
});
|
|
}
|
|
|
|
// Every change to an encounter is answered with the encounter as this viewer
|
|
// sees it, so the cache takes that as it is: no second request for what just
|
|
// came back.
|
|
function keepEncounter(qc: QueryClient, encounterId: string, data: unknown) {
|
|
const encounter = (data as { encounter?: Encounter } | undefined)?.encounter;
|
|
if (encounter) qc.setQueryData(['encounter', encounterId], encounter);
|
|
else qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
|
}
|
|
|
|
export function useAddParticipant(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number; count?: number }) =>
|
|
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
export function usePatchParticipant(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ participantId, ...data }: Partial<EncounterParticipant> & { participantId: string }) =>
|
|
(await apiClient.patch(`/encounters/${encounterId}/participants/${participantId}`, data)).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
export function useRemoveParticipant(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (participantId: string) =>
|
|
(await apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`)).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
// Rolls a d20 for every monster and NPC that has no initiative roll yet. DM only.
|
|
export function useRollInitiative(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async () => (await apiClient.post<{ rolled: number }>(`/encounters/${encounterId}/roll-initiative`)).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
export function useAdvanceRound(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/advance-round`)).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
export function usePatchEncounter(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: {
|
|
status?: EncounterStatusInput;
|
|
mode?: EncounterMode;
|
|
name?: string;
|
|
description?: string | null;
|
|
dmNotes?: string | null;
|
|
locationId?: string | null;
|
|
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
|
onSuccess: (data, variables) => {
|
|
keepEncounter(qc, encounterId, data);
|
|
// The campaign page lists encounters by name, mode and status, and an
|
|
// encounter in progress carries its characters to its location.
|
|
if (variables.status || variables.mode || variables.name || variables.locationId !== undefined) {
|
|
qc.invalidateQueries({ queryKey: ['campaign'] });
|
|
qc.invalidateQueries({ queryKey: ['character'] });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
|
|
|
// Sets which combatants are marked as targets (an empty list clears them).
|
|
// The DM at any time; a player while their own character is on turn.
|
|
export function useSetTargets(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
// The character on turn trying to bring a dead ally back; the server decides
|
|
// whether they have the means.
|
|
export function useReviveParticipant(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (participantId: string) => (await apiClient.post(`/encounters/${encounterId}/participants/${participantId}/revive`)).data,
|
|
onSuccess: (data) => {
|
|
keepEncounter(qc, encounterId, data);
|
|
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useNextTurn(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/next-turn`)).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Encounter rewards — DM lines loot up, then awards it to a character
|
|
// --------------------------------------------------------------------------
|
|
|
|
export type NewReward =
|
|
| { type: 'GOLD'; gold: number; currency?: Currency; note?: string }
|
|
| { type: 'WEAPON'; weaponId: string; quantity?: number; level?: number; note?: string }
|
|
| { type: 'ITEM'; itemId: string; quantity?: number; level?: number; note?: string };
|
|
|
|
export function useAddReward(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: NewReward) => (await apiClient.post(`/encounters/${encounterId}/rewards`, data)).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
export function useDeleteReward(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (rewardId: string) => (await apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`)).data,
|
|
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
// Awarding is what actually changes the character: a gold ledger entry, or
|
|
// the weapon/item added to what they hold.
|
|
// Hands out the encounter's XP, shared evenly between the characters in it:
|
|
// what its monsters are worth, or the amount given. Once only. DM only.
|
|
export function useAwardXp(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (xp?: number) =>
|
|
(await apiClient.post<{ xp: number; share: number; awardedTo: string[] }>(`/encounters/${encounterId}/award-xp`, xp ? { xp } : {})).data,
|
|
onSuccess: (data) => {
|
|
keepEncounter(qc, encounterId, data);
|
|
qc.invalidateQueries({ queryKey: ['character'] });
|
|
qc.invalidateQueries({ queryKey: ['campaign'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useAwardReward(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ rewardId, ...target }: { rewardId: string } & ({ characterId: string } | { split: true })) =>
|
|
(await apiClient.post(`/encounters/${encounterId}/rewards/${rewardId}/award`, target)).data,
|
|
onSuccess: (data) => {
|
|
keepEncounter(qc, encounterId, data);
|
|
qc.invalidateQueries({ queryKey: ['character'] });
|
|
qc.invalidateQueries({ queryKey: ['campaign'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// NPCs — a DM-only pool; players meet them through encounters
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useCampaignNpcs(campaignId: string | undefined, isDM: boolean) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'npcs'],
|
|
enabled: Boolean(campaignId) && isDM,
|
|
queryFn: async () => (await apiClient.get<{ npcs: Npc[] }>(`/campaigns/${campaignId}/npcs`)).data.npcs,
|
|
});
|
|
}
|
|
|
|
export function useCreateNpc(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: Partial<Omit<Npc, 'id'>> & { name: string }) =>
|
|
(await apiClient.post<{ npc: Npc }>(`/campaigns/${campaignId}/npcs`, data)).data.npc,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'npcs'] }),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Trade — an NPC's exchange rate and wares are set up by the DM; in a quest
|
|
// in progress, characters in the scene change money and buy
|
|
// --------------------------------------------------------------------------
|
|
|
|
export type NewWare = ({ npcId: string } | { encounterId: string }) &
|
|
({ weaponId: string } | { itemId: string }) & { price: number; currency: Currency; level?: number; stock: number | null };
|
|
|
|
// Wares reach the screen inside the NPC list and inside each encounter.
|
|
function invalidateWares(qc: QueryClient, campaignId: string) {
|
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'npcs'] });
|
|
qc.invalidateQueries({ queryKey: ['encounter'] });
|
|
}
|
|
|
|
export function useCreateWare(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: NewWare) => (await apiClient.post<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares`, data)).data.ware,
|
|
onSuccess: () => invalidateWares(qc, campaignId),
|
|
});
|
|
}
|
|
|
|
// The price and what is left can change; what is sold, and by whom, cannot.
|
|
export function usePatchWare(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ id, ...data }: { id: string; price?: number; currency?: Currency; stock?: number | null }) =>
|
|
(await apiClient.patch<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares/${id}`, data)).data.ware,
|
|
onSuccess: () => invalidateWares(qc, campaignId),
|
|
});
|
|
}
|
|
|
|
export function useDeleteWare(campaignId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (wareId: string) => apiClient.delete(`/campaigns/${campaignId}/wares/${wareId}`),
|
|
onSuccess: () => invalidateWares(qc, campaignId),
|
|
});
|
|
}
|
|
|
|
// Both change what a character holds, so the sheet and the party list refresh too.
|
|
function invalidateAfterTrade(qc: QueryClient, encounterId: string, data: unknown) {
|
|
keepEncounter(qc, encounterId, data);
|
|
qc.invalidateQueries({ queryKey: ['character'] });
|
|
qc.invalidateQueries({ queryKey: ['campaign'] });
|
|
}
|
|
|
|
// Changes gold into cash or cash into gold with an NPC in the scene, at their rate.
|
|
export function useExchangeWithNpc(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { characterId: string; npcId: string; from: 'GOLD' | 'CASH'; amount: number }) =>
|
|
(await apiClient.post<{ received: number; currency: Currency }>(`/encounters/${encounterId}/exchange`, data)).data,
|
|
onSuccess: (data) => invalidateAfterTrade(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
export function useBuyWare(encounterId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({ wareId, ...data }: { wareId: string; characterId: string; quantity?: number }) =>
|
|
(await apiClient.post(`/encounters/${encounterId}/wares/${wareId}/buy`, data)).data,
|
|
onSuccess: (data) => invalidateAfterTrade(qc, encounterId, data),
|
|
});
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Notes
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function useCampaignNotes(campaignId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['campaign', campaignId, 'notes'],
|
|
enabled: Boolean(campaignId),
|
|
queryFn: async () => (await apiClient.get<{ notes: Note[] }>(`/campaigns/${campaignId}/notes`)).data.notes,
|
|
});
|
|
}
|
|
|
|
export function useCharacterNotes(characterId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ['character', characterId, 'notes'],
|
|
enabled: Boolean(characterId),
|
|
queryFn: async () => (await apiClient.get<{ notes: Note[] }>(`/characters/${characterId}/notes`)).data.notes,
|
|
});
|
|
}
|
|
|
|
export function useCreateNote() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (data: { campaignId?: string; characterId?: string; body: string; visibility?: 'DM_ONLY' | 'SHARED' }) =>
|
|
(await apiClient.post<{ note: Note }>('/notes', data)).data.note,
|
|
onSuccess: (note) => {
|
|
if (note.campaignId) qc.invalidateQueries({ queryKey: ['campaign', note.campaignId, 'notes'] });
|
|
if (note.characterId) qc.invalidateQueries({ queryKey: ['character', note.characterId, 'notes'] });
|
|
},
|
|
});
|
|
}
|