From c133039e97a9cb7d5d60788446353641f77f7cf3 Mon Sep 17 00:00:00 2001 From: Jay Turner Date: Wed, 7 Oct 2026 20:13:00 +0100 Subject: [PATCH 01/15] Bump version to 0.1.3 --- hoard/campaigns/api.py | 2 +- package-lock.json | 4 ++-- package.json | 2 +- pyproject.toml | 2 +- uv.lock | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/hoard/campaigns/api.py b/hoard/campaigns/api.py index 874247281..67a253f66 100644 --- a/hoard/campaigns/api.py +++ b/hoard/campaigns/api.py @@ -51,7 +51,7 @@ class SharedXpAwardCreate(Schema): description: str = "" -api = NinjaAPI(title="Hoard API", version="0.1.2", auth=django_auth) +api = NinjaAPI(title="Hoard API", version="0.1.3", auth=django_auth) def unprocessable(error: DjangoValidationError) -> HttpError: diff --git a/package-lock.json b/package-lock.json index e9f82e579..b4db9c64d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoard", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoard", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@mdi/font": "^7.4.47", "@primeuix/themes": "^2.0.3", diff --git a/package.json b/package.json index ba43290a9..4e3108f68 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hoard", "private": true, - "version": "0.1.2", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index dace95b16..35abc56e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Hoard" -version = "0.1.2" +version = "0.1.3" description = "A D&D 5e tool for our campaign with specific tools for our homebrew rules. You won't be interested." readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index e39452c35..6ed74c9fa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14" resolution-markers = [ "platform_python_implementation == 'PyPy' and sys_platform == 'win32'", @@ -415,7 +415,7 @@ wheels = [ [[package]] name = "hoard" -version = "0.1.2" +version = "0.1.3" source = { virtual = "." } dependencies = [ { name = "celery" }, From ed4cb6a6bac88245ba6c32870d4d89a618ed3c0b Mon Sep 17 00:00:00 2001 From: Jay Turner Date: Tue, 20 Oct 2026 01:16:24 +0100 Subject: [PATCH 02/15] Link DiceCloud --- README.md | 21 +- docs/api.md | 11 +- docs/compendium.md | 50 - docs/equipment-catalogue.md | 35 - hoard/api.test.ts | 182 +- hoard/api.ts | 271 +- hoard/campaigns/admin.py | 105 +- hoard/campaigns/api.py | 583 +--- hoard/campaigns/components/AbilityCard.vue | 130 - .../components/CampaignNavigation.vue | 20 - hoard/campaigns/components/GmCoinForm.vue | 139 - .../components/GmEncounterControl.vue | 23 +- hoard/campaigns/components/GmSharedXpForm.vue | 15 +- .../campaigns/components/PartyRailMember.vue | 16 +- hoard/campaigns/components/PartyRoster.vue | 2 +- hoard/campaigns/consumers.py | 335 +-- .../0004_dicecloud_character_shims.py | 243 ++ hoard/campaigns/models/__init__.py | 26 +- hoard/campaigns/models/audit.py | 35 - hoard/campaigns/models/combat.py | 6 - hoard/campaigns/models/core.py | 332 +-- hoard/campaigns/models/experience.py | 78 - hoard/campaigns/models/history.py | 24 - hoard/campaigns/models/money.py | 78 - hoard/campaigns/models/party_experience.py | 47 + .../pages/CharacterDirectoryView.vue | 116 +- .../campaigns/pages/CharacterProfileView.vue | 2478 +++-------------- hoard/campaigns/pages/GmConsoleView.vue | 29 +- hoard/campaigns/pages/LedgerView.vue | 237 +- hoard/campaigns/pages/ManageCampaignView.vue | 467 +--- hoard/campaigns/payloads/__init__.py | 24 +- hoard/campaigns/payloads/campaign.py | 8 +- hoard/campaigns/payloads/characters.py | 76 +- hoard/campaigns/payloads/dicecloud.py | 11 + hoard/campaigns/protocol.py | 57 +- hoard/campaigns/services/__init__.py | 32 +- hoard/campaigns/services/actions.py | 132 - hoard/campaigns/services/characters.py | 106 - hoard/campaigns/services/combat.py | 116 +- hoard/campaigns/services/dicecloud.py | 342 +++ hoard/campaigns/services/experience.py | 168 +- hoard/campaigns/services/health.py | 104 - hoard/campaigns/services/invitations.py | 4 - hoard/campaigns/services/ledger.py | 129 - hoard/campaigns/tests/helpers.py | 73 - hoard/campaigns/tests/test_actions.py | 27 - hoard/campaigns/tests/test_admin.py | 41 - hoard/campaigns/tests/test_api.py | 362 --- hoard/campaigns/tests/test_audit.py | 57 - hoard/campaigns/tests/test_character_shims.py | 78 + .../tests/test_character_statistics.py | 79 - hoard/campaigns/tests/test_combat.py | 179 -- hoard/campaigns/tests/test_core.py | 91 - hoard/campaigns/tests/test_dicecloud.py | 96 + hoard/campaigns/tests/test_experience.py | 99 - hoard/campaigns/tests/test_health.py | 65 - hoard/campaigns/tests/test_invites.py | 109 - hoard/campaigns/tests/test_ledger.py | 108 - hoard/campaigns/tests/test_protocol.py | 9 +- hoard/campaigns/tests/test_websocket.py | 195 -- hoard/campaigns/views.py | 69 - hoard/realtime.ts | 1 - hoard/releaseScope.test.ts | 31 +- hoard/urls.py | 5 - pyproject.toml | 1 + uv.lock | 129 + 66 files changed, 2027 insertions(+), 7320 deletions(-) delete mode 100644 docs/compendium.md delete mode 100644 docs/equipment-catalogue.md delete mode 100644 hoard/campaigns/components/AbilityCard.vue delete mode 100644 hoard/campaigns/components/GmCoinForm.vue create mode 100644 hoard/campaigns/migrations/0004_dicecloud_character_shims.py delete mode 100644 hoard/campaigns/models/experience.py delete mode 100644 hoard/campaigns/models/money.py create mode 100644 hoard/campaigns/models/party_experience.py create mode 100644 hoard/campaigns/payloads/dicecloud.py delete mode 100644 hoard/campaigns/services/actions.py delete mode 100644 hoard/campaigns/services/characters.py create mode 100644 hoard/campaigns/services/dicecloud.py delete mode 100644 hoard/campaigns/services/health.py delete mode 100644 hoard/campaigns/services/ledger.py delete mode 100644 hoard/campaigns/tests/helpers.py delete mode 100644 hoard/campaigns/tests/test_actions.py delete mode 100644 hoard/campaigns/tests/test_admin.py delete mode 100644 hoard/campaigns/tests/test_api.py delete mode 100644 hoard/campaigns/tests/test_audit.py create mode 100644 hoard/campaigns/tests/test_character_shims.py delete mode 100644 hoard/campaigns/tests/test_character_statistics.py delete mode 100644 hoard/campaigns/tests/test_combat.py delete mode 100644 hoard/campaigns/tests/test_core.py create mode 100644 hoard/campaigns/tests/test_dicecloud.py delete mode 100644 hoard/campaigns/tests/test_experience.py delete mode 100644 hoard/campaigns/tests/test_health.py delete mode 100644 hoard/campaigns/tests/test_invites.py delete mode 100644 hoard/campaigns/tests/test_ledger.py delete mode 100644 hoard/campaigns/tests/test_websocket.py delete mode 100644 hoard/campaigns/views.py diff --git a/README.md b/README.md index 48653eade..60ca562d1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Hoard -Hoard is a small campaign companion for shared XP, coin ledgers, calendar tracking, -character profiles, invitations, and campaign administration. +Hoard is a small campaign companion for shared XP, calendar tracking, DiceCloud +character sheets, invitations, and campaign administration. ## Screenshots @@ -23,22 +23,17 @@ character profiles, invitations, and campaign administration. Hoard `0.1.0` focuses on the shared campaign tools that are ready for regular use: -- shared XP for active player characters, including level progress and immutable - ledger history; -- personal and campaign coin accounts with grants, spending, transfers, exact-value - exchanges, and reversals; +- shared XP divided by the configured party size, including players without + DiceCloud sheets, with level progress and immutable party-ledger history; - a campaign calendar whose date is recorded against ledger and audit events; -- player-character and NPC profiles with portraits, free-text race and class, and - profile editing; +- read-only DiceCloud player sheets with player and GM connection management; - campaign membership, shareable invitations, roster management, and GM controls; - a responsive campaign shell with live WebSocket updates and desktop/mobile party navigation. -Character profiles retain the full sheet layout as a preview of planned features. -Unavailable statistics, abilities, skills, inventory, equipment, and character-detail -sections are clearly marked **Coming soon** and use skeleton values rather than -placeholder character data. The Compendium is likewise visible in navigation but is -not part of this release. +DiceCloud is the source of truth for player-sheet data. Hoard fetches its public, +read-only creature data live and uses a short-lived outage fallback without storing a +local character sheet. ## Development diff --git a/docs/api.md b/docs/api.md index 554a7410c..e3eb3327b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -28,12 +28,11 @@ Query-result data, command payloads, and domain-event data are defined with Pydantic models. These models are transport-neutral: WebSocket consumers and temporary HTTP compatibility handlers serialize them with `model_dump(mode="json")`. -The context socket exposes campaign state/calendar, members, invitations, group -levels, characters and builder drafts, HP history, `.cah` imports, sheet records, -inventory, money, XP, history, and Compendium operations. Commands follow the -names visible in `ContextConsumer`, for example `campaign.get`, -`characters.builder.save`, `characters.health.post`, -`inventory.transactions.create`, and `compendium.search`. +The context socket exposes campaign state/calendar, members, invitations, party XP, +encounters, and read-only DiceCloud character projections. Commands include +`campaign.get`, `characters.dicecloud.link`, `characters.dicecloud.unlink`, +`characters.active.set`, `campaign.experience.recipients.set`, and +`experience.shared_awards.create`. The invite socket supports `invite.inspect`, `invite.accept`, and `invite.register_and_accept`. The user socket supports `user.contexts.list`. diff --git a/docs/compendium.md b/docs/compendium.md deleted file mode 100644 index ee6c38a06..000000000 --- a/docs/compendium.md +++ /dev/null @@ -1,50 +0,0 @@ -# Compendium repositories and sources - -The Compendium is Hoard's single catalogue of items, armour, weapons, spells, -feats, classes, races, backgrounds, and other reference entries. A campaign -uses only the sources it has enabled; there is no separate item-source setting. - -The repository directory is the RPG Companion community registry. Each record -there is a `CompendiumRepository`; a repository can contain one or more -`CompendiumSource` collections, and each source contains Compendium entries. - -## Custom content - -Any campaign member can add custom items. Hoard stores them in a -campaign-owned repository and source, so they never become global catalogue content. -Campaign GMs manage changes and removal; imported entries remain read-only. - -## RPG Companion community directory - -Download the upstream archive and import it: - -```sh -uv run python manage.py update_compendium_registries -``` - -The command fetches the canonical community directory, synchronises its -repository records, and imports the `5e` and `5e2024` sources from the -`default` repository. It is idempotent: running it again updates -entries by source identifier and adds newly available entries. - -Use `--source /path/to/checkout` to import a deliberate checkout for that -same `default` repository instead of downloading its Git archive. - -## Enable sources for a campaign - -Importing a repository makes its sources available to Hoard; it does not -automatically expose them to every campaign. Game masters enable sources in -the Compendium screen. - -The 5e Companion App remains supported for `.cah` character imports only; its -legacy catalogue is intentionally not bundled. - -## Community repositories - -Campaign GMs manage sources in the Compendium screen. The main discovery source -is the RPG Companion community registry at -`https://raw.githubusercontent.com/blastervla/rpg-companion-community-registry/master/registry.json`. -Hoard only lists compatible 5e and 5e2024 records there. The Compendium UI -only imports repositories discovered from this registry: it does not accept -arbitrary Git URLs, archive URLs, or uploaded archives. This keeps installed -sources attributable to a discoverable registry record. diff --git a/docs/equipment-catalogue.md b/docs/equipment-catalogue.md deleted file mode 100644 index 8817b046c..000000000 --- a/docs/equipment-catalogue.md +++ /dev/null @@ -1,35 +0,0 @@ -# Equipment catalogue - -Hoard imports global equipment from repositories discovered through the RPG -Companion community registry. -The importer supports `item`, `weapon`, and `armor` resources from both `5e` -and `5e2024`. - -Each imported record preserves the complete upstream JSON for provenance and -also extracts facts useful to a DM and players: source book, category, type, -cost, weight, rarity, magic, and attunement. A missing fact means the upstream -resource did not provide a usable value; it is not treated as zero or false. - -Synchronise the registry and install its default repository with: - -```sh -uv run python manage.py update_compendium_registries -``` - -It upserts imported equipment by repository, source, equipment category, and -upstream identifier. Rerunning it updates facts without duplicating entries. - -Campaign item source settings control which global systems appear in a -campaign. Campaign custom items are always available, and can record the same -facts as imported items. Costs are descriptive catalogue data only; granting, -spending, and exchanging money continue to use the ledger actions. - -## Frontend picker - -Every item action opens the same equipment picker. It searches names, -descriptions, sources, categories, and types, and filters system, source book, -category, type, rarity, magic, attunement, cost, and weight. Cost filters use -gold-piece equivalents; cards retain the original denomination. - -For transfers and item removal, the picker only lists positive balances in the -chosen character’s inventory and displays the recorded quantity. diff --git a/hoard/api.test.ts b/hoard/api.test.ts index 5a415a73e..e09d4a5c5 100644 --- a/hoard/api.test.ts +++ b/hoard/api.test.ts @@ -1,37 +1,20 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { campaignRequest, ensureCampaignRealtime } from "./realtime"; import { - addCharacterToEncounter, - addEncounterCombatant, - chooseInitiativeTie, - createMoneyExchange, - createMoneyTransfer, createSharedXpAward, - endEncounter, - endPlayerTurn, - initialiseCsrf, - login, - removeEncounterCombatant, - reorderEncounterCombatants, - reverseTransaction, - rollPlayerInitiative, - setCurrentEncounterCombatant, - startEncounter, - updateCharacter, - type LedgerTransaction, + linkDiceCloudCharacter, + setCharacterActive, + unlinkDiceCloudCharacter, } from "./api"; const { httpRequest } = vi.hoisted(() => ({ httpRequest: vi.fn() })); vi.mock("axios", () => ({ - default: { - create: () => ({ request: httpRequest }), - isAxiosError: () => false, - }, + default: { create: () => ({ request: httpRequest }), isAxiosError: () => false }, })); vi.mock("./realtime", () => ({ - campaignRequest: vi.fn().mockResolvedValue({ id: 4, ledger: "test" }), + campaignRequest: vi.fn().mockResolvedValue(undefined), ensureCampaignRealtime: vi.fn().mockResolvedValue(undefined), inviteRequest: vi.fn(), userRequest: vi.fn(), @@ -40,158 +23,33 @@ vi.mock("./realtime", () => ({ describe("API client", () => { afterEach(() => { vi.clearAllMocks(); - vi.unstubAllGlobals(); }); - it("uses the CSRF token when posting login credentials", async () => { - httpRequest - .mockResolvedValueOnce({ status: 200, data: { csrfToken: "token" } }) - .mockResolvedValueOnce({ status: 200, data: { id: 1, username: "jay" } }); + it("manages a DiceCloud link through the campaign socket", async () => { + await linkDiceCloudCharacter(8, 2, "sfdxGdwav3hoNATLJ"); + await setCharacterActive(8, 2, false); + await unlinkDiceCloudCharacter(8, 2); - await initialiseCsrf(); - await login("jay", "secret"); - - expect(httpRequest).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - url: "/api/auth/session/", - method: "POST", - headers: expect.objectContaining({ "X-CSRFToken": "token" }), - }), - ); - }); - - it("sends money transfers and exchanges over the acting context socket", async () => { - await createMoneyTransfer(8, { - from_character_id: 2, - to_character_id: null, - amounts: { gp: 3, sp: 4, cp: 7 }, - }); - await createMoneyExchange(8, { + expect(ensureCampaignRealtime).toHaveBeenCalledWith(8); + expect(campaignRequest).toHaveBeenNthCalledWith(1, "characters.dicecloud.link", { character_id: 2, - given: { gp: 1 }, - received: { sp: 10 }, + creature: "sfdxGdwav3hoNATLJ", }); - - expect(ensureCampaignRealtime).toHaveBeenCalledWith(8); - expect(campaignRequest).toHaveBeenNthCalledWith(1, "money.transfers.create", { - from_character_id: 2, - to_character_id: null, - amounts: { gp: 3, sp: 4, cp: 7 }, + expect(campaignRequest).toHaveBeenNthCalledWith(2, "characters.active.set", { + character_id: 2, + is_active: false, }); - expect(campaignRequest).toHaveBeenNthCalledWith(2, "money.exchanges.create", { + expect(campaignRequest).toHaveBeenNthCalledWith(3, "characters.dicecloud.unlink", { character_id: 2, - given: { gp: 1 }, - received: { sp: 10 }, }); }); - it("sends shared XP awards and ledger reversals over the socket", async () => { - await createSharedXpAward(8, { + it("sends party XP awards without individual character data", async () => { + await createSharedXpAward(8, { amount: 250, description: "Milestone" }); + + expect(campaignRequest).toHaveBeenCalledWith("experience.shared_awards.create", { amount: 250, description: "Milestone", }); - await reverseTransaction(8, { - id: 41, - ledger: "experience", - } as LedgerTransaction); - - expect(campaignRequest).toHaveBeenNthCalledWith( - 1, - "experience.shared_awards.create", - { - amount: 250, - description: "Milestone", - }, - ); - expect(campaignRequest).toHaveBeenNthCalledWith(2, "transactions.reverse", { - ledger: "experience", - transaction_id: 41, - }); - }); - - it("maps the display class field to the character command payload", async () => { - await updateCharacter(8, 2, { - name: "Wren", - class: "Wizard", - }); - - expect(campaignRequest).toHaveBeenCalledWith("characters.update", { - character_id: 2, - fields: { - name: "Wren", - character_class: "Wizard", - }, - }); - }); - - it("manages encounters through the historical socket commands", async () => { - await startEncounter(8); - await addCharacterToEncounter(8, 2, 18); - await addEncounterCombatant(8, { - name: "Goblin 2", - initiative: 12, - current_hp: 7, - max_hp: 7, - }); - await reorderEncounterCombatants(8, [31, 30]); - await setCurrentEncounterCombatant(8, 30); - await removeEncounterCombatant(8, 31); - await endEncounter(8); - - expect(campaignRequest).toHaveBeenNthCalledWith(1, "campaign.encounter.start", {}); - expect(campaignRequest).toHaveBeenNthCalledWith( - 2, - "campaign.encounter.combatants.add_character", - { character_id: 2, initiative: 18 }, - ); - expect(campaignRequest).toHaveBeenNthCalledWith( - 3, - "campaign.encounter.combatants.add", - { - name: "Goblin 2", - initiative: 12, - current_hp: 7, - max_hp: 7, - }, - ); - expect(campaignRequest).toHaveBeenNthCalledWith( - 4, - "campaign.encounter.combatants.reorder", - { combatant_ids: [31, 30] }, - ); - expect(campaignRequest).toHaveBeenNthCalledWith( - 5, - "campaign.encounter.current.set", - { combatant_id: 30 }, - ); - expect(campaignRequest).toHaveBeenNthCalledWith( - 6, - "campaign.encounter.combatants.remove", - { combatant_id: 31 }, - ); - expect(campaignRequest).toHaveBeenNthCalledWith(7, "campaign.encounter.end", {}); - }); - - it("sends player initiative and turn commands", async () => { - await rollPlayerInitiative(8, 17); - await chooseInitiativeTie(8, 31); - await endPlayerTurn(8); - - expect(campaignRequest).toHaveBeenNthCalledWith( - 1, - "campaign.encounter.initiative.roll", - { roll: 17 }, - ); - expect(campaignRequest).toHaveBeenNthCalledWith( - 2, - "campaign.encounter.initiative.tie.choose", - { combatant_id: 31 }, - ); - expect(campaignRequest).toHaveBeenNthCalledWith( - 3, - "campaign.encounter.turn.end", - {}, - ); }); }); diff --git a/hoard/api.ts b/hoard/api.ts index d1bc7cd0c..df3362cfc 100644 --- a/hoard/api.ts +++ b/hoard/api.ts @@ -25,50 +25,32 @@ export type Calculation = { }>; }; -export type CharacterSheet = { - level: number; - rolled_hit_points: number; - hp_ability: string; - hp_adjustment: number; - initiative_adjustment: number; - proficiency_bonus_adjustment: number; - max_hp: number; - hp_calculation: Calculation; - current_hp: number; - temporary_hp: number; - initiative: Calculation; - proficiency_bonus: number; - proficiency_bonus_calculation: Calculation; - jack_of_all_trades: boolean; - remarkable_athlete: boolean; - abilities: Record< - string, - { - score: number; - raw: number; - ancestry_bonus: number; - background_bonus: number; - score_adjustment: number; - modifier: number; - check_bonus: number; - check_formula: Calculation; - formula: Calculation; - } - >; - saves: Record< - string, - { proficiency: string; adjustment: number; bonus: number; formula: Calculation } - >; - skills: Record< - string, - { - ability: string; - proficiency: string; - adjustment: number; - bonus: number; - formula: Calculation; - } - >; +export type DiceCloudProfile = { + unavailable?: boolean; + error?: string; + id: string; + source_url: string; + fetched_at: string; + stale: boolean; + name: string; + image_url: string | null; + details: Record; + stats: Record; + abilities: Record; + saves: Record; + skills: Record; + resources: Array; + equipment: Array; + spells: Array; + features: Array; + currency: Record; +}; + +export type DiceCloudEntry = { + name: string; + description: string; + type: string; + value: string | number | null; }; export type CampaignSummary = { @@ -109,14 +91,15 @@ export type Character = { context_id: number | null; name: string; portrait_url: string | null; - kind: "pc" | "npc"; is_player_character: boolean; is_active: boolean; + is_connected: boolean; + dicecloud_creature_id: string | null; + current_hp: number | null; + max_hp: number | null; + profile: DiceCloudProfile | null; race: string; class: string; - experience: number; - sheet: CharacterSheet; - money: Record; }; export type EncounterCombatant = { @@ -164,8 +147,23 @@ export type CampaignInvitation = { link?: string; }; +export type PartyExperienceAward = { + id: number; + amount: number; + recipient_count: number; + awarded_per_player: number; + discarded_amount: number; + reason: string; + description: string; + occurred_at: string; + campaign_date: string | null; + actor: string | null; +}; + export type Campaign = CampaignSummary & { + active_context_id: number; shared_experience: number; + experience_recipient_count: number; level: number; eligible_level: number; calendar: CampaignCalendar; @@ -173,48 +171,10 @@ export type Campaign = CampaignSummary & { members: CampaignMember[]; characters: Character[]; invitations: CampaignInvitation[]; + party_experience_awards: PartyExperienceAward[]; encounter: Encounter | null; }; -export type LedgerEntry = { - account_id: number; - account_name: string; - is_system_account: boolean; - amount: number; - denomination?: string; -}; - -export type LedgerTransaction = { - id: number; - ledger: string; - ledger_label?: string; - description: string; - occurred_at: string; - campaign_date: string | null; - entries: LedgerEntry[]; - reversal_of_id: number | null; - is_reversed: boolean; - reason?: string; - requested_amount?: number; - discarded_amount?: number; - actor: string | null; - changes?: Record; -}; - -export type MoneyTransferInput = { - from_character_id: number | null; - to_character_id: number | null; - amounts: Record; - description?: string; -}; - -export type MoneyExchangeInput = { - character_id: number; - given: Record; - received: Record; - description?: string; -}; - export type InviteDetails = { campaign_name: string; expires_at: string; @@ -410,69 +370,34 @@ export function getMembers(contextId: number): Promise { return contextRequest(contextId, "campaign.members.list"); } -export function createCharacter( - contextId: number, - payload: { - name: string; - race: string; - character_class: string; - is_npc?: boolean; - }, -): Promise { - return contextRequest(contextId, "characters.create", { - fields: payload, - }); -} - -export function archiveCharacter( +export function linkDiceCloudCharacter( contextId: number, characterId: number, + creature: string, ): Promise { - return contextRequest(contextId, "characters.archive", { + return contextRequest(contextId, "characters.dicecloud.link", { character_id: characterId, + creature, }); } -export function updateCharacter( +export function unlinkDiceCloudCharacter( contextId: number, characterId: number, - payload: Record, ): Promise { - const { class: characterClass, ...fields } = payload as { - class?: string; - } & Record; - - return contextRequest(contextId, "characters.update", { + return contextRequest(contextId, "characters.dicecloud.unlink", { character_id: characterId, - fields: { ...fields, character_class: characterClass }, }); } -export function postHealth( - contextId: number, - payload: { - character_id: number; - reason: "damage" | "healing" | "temporary" | "correction"; - current_hp_delta?: number; - temporary_hp_delta?: number; - current_hp?: number; - temporary_hp?: number; - description?: string; - }, -): Promise { - return contextRequest(contextId, "characters.health.post", payload); -} - -export function takeRest( +export function setCharacterActive( contextId: number, characterId: number, - kind: "short" | "long", - regainedHp = 0, + isActive: boolean, ): Promise { - return contextRequest(contextId, "characters.rest", { + return contextRequest(contextId, "characters.active.set", { character_id: characterId, - kind, - regained_hp: regainedHp, + is_active: isActive, }); } @@ -570,89 +495,19 @@ export function removeEncounterCombatant( }); } -export function uploadCharacterPortrait( - contextId: number, - characterId: number, - file: File, -): Promise<{ portrait_url: string }> { - const body = new FormData(); - body.append("file", file); - - return request<{ portrait_url: string }>( - `/api/uploads/character-portraits/${contextId}/${characterId}/`, - { method: "POST", body }, - ); -} - -export function removeCharacterPortrait( - contextId: number, - characterId: number, -): Promise { - return contextRequest(contextId, "characters.portrait.remove", { - character_id: characterId, - }); -} - -export function createMoneyTransfer( - contextId: number, - payload: MoneyTransferInput, -): Promise { - return contextRequest( - contextId, - "money.transfers.create", - payload, - ); -} - -export function createMoneyExchange( - contextId: number, - payload: MoneyExchangeInput, -): Promise { - return contextRequest( - contextId, - "money.exchanges.create", - payload, - ); -} - export function createSharedXpAward( contextId: number, payload: { amount: number; description?: string }, -): Promise { - return contextRequest( - contextId, - "experience.shared_awards.create", - payload, - ); -} - -export function getTransactions( - contextId: number, - ledger = "all", - page = 1, - characterId?: number, -): Promise<{ - count: number; - page: number; - page_size: number; - results: LedgerTransaction[]; -}> { - const payload = { - ledger, - page, - ...(characterId ? { character_id: characterId } : {}), - }; - - return contextRequest(contextId, "transactions.list", payload); +): Promise { + return contextRequest(contextId, "experience.shared_awards.create", payload); } -export function reverseTransaction( +export function setExperienceRecipientCount( contextId: number, - transaction: LedgerTransaction, + experienceRecipientCount: number, ): Promise { - return contextRequest(contextId, "transactions.reverse", { - ledger: transaction.ledger, - transaction_id: transaction.id, + return contextRequest(contextId, "campaign.experience.recipients.set", { + experience_recipient_count: experienceRecipientCount, }); } diff --git a/hoard/campaigns/admin.py b/hoard/campaigns/admin.py index a92faf82f..35adbc826 100644 --- a/hoard/campaigns/admin.py +++ b/hoard/campaigns/admin.py @@ -1,31 +1,18 @@ -from __future__ import annotations - from django.contrib import admin -from django.forms.models import BaseModelForm -from django.http import HttpRequest from .models import ( Campaign, CampaignContext, Character, - ExperienceAccount, - ExperienceEntry, - ExperienceTransaction, - MoneyAccount, - MoneyEntry, - MoneyTransaction, + Encounter, + EncounterCombatant, + PartyExperienceAward, ) @admin.register(Campaign) class CampaignAdmin(admin.ModelAdmin): - list_display = ( - "name", - "calendar_era_abbreviation", - "calendar_year", - "calendar_day", - "shared_experience", - ) + list_display = ("name", "calendar_era_abbreviation", "calendar_year", "calendar_day", "shared_experience") search_fields = ("name",) @@ -38,85 +25,11 @@ class CampaignContextAdmin(admin.ModelAdmin): @admin.register(Character) class CharacterAdmin(admin.ModelAdmin): - list_display = ( - "name", - "campaign", - "context", - "is_active", - "race", - "character_class", - ) + list_display = ("context", "campaign", "dicecloud_creature_id", "is_active") list_filter = ("campaign", "is_active") - search_fields = ("name", "context__user__username") - - def save_model( - self, request: HttpRequest, obj: Character, form: BaseModelForm, change: bool - ) -> None: - activate = obj.is_active and (not change or "is_active" in form.changed_data) - if activate: - obj.is_active = False - super().save_model(request, obj, form, change) - if activate: - obj.activate() - - -class ReadOnlyLedgerAdmin(admin.ModelAdmin): - list_display = ("id", "campaign", "description", "occurred_at") - readonly_fields = ("id", "campaign", "description", "occurred_at") - - def has_add_permission(self, request: HttpRequest) -> bool: - return False - - def has_change_permission( - self, request: HttpRequest, obj: object | None = None - ) -> bool: - return False - - def has_delete_permission( - self, request: HttpRequest, obj: object | None = None - ) -> bool: - return False - - -class ReadOnlyAccountAdmin(admin.ModelAdmin): - list_display = ("id", "campaign", "character", "is_system") - readonly_fields = ("id", "campaign", "character", "is_system") - - def has_add_permission(self, request: HttpRequest) -> bool: - return False - - def has_change_permission( - self, request: HttpRequest, obj: object | None = None - ) -> bool: - return False - - def has_delete_permission( - self, request: HttpRequest, obj: object | None = None - ) -> bool: - return False - - -class ReadOnlyEntryAdmin(admin.ModelAdmin): - list_display = ("id", "transaction", "account", "amount") - readonly_fields = ("id", "transaction", "account", "amount") - - def has_add_permission(self, request: HttpRequest) -> bool: - return False - - def has_change_permission( - self, request: HttpRequest, obj: object | None = None - ) -> bool: - return False - - def has_delete_permission( - self, request: HttpRequest, obj: object | None = None - ) -> bool: - return False + search_fields = ("context__user__username", "dicecloud_creature_id") -admin.site.register(MoneyAccount, ReadOnlyAccountAdmin) -admin.site.register(ExperienceAccount, ReadOnlyAccountAdmin) -admin.site.register(MoneyTransaction, ReadOnlyLedgerAdmin) -admin.site.register(ExperienceTransaction, ReadOnlyLedgerAdmin) -admin.site.register(MoneyEntry, ReadOnlyEntryAdmin) -admin.site.register(ExperienceEntry, ReadOnlyEntryAdmin) +admin.site.register(Encounter) +admin.site.register(EncounterCombatant) +admin.site.register(PartyExperienceAward) diff --git a/hoard/campaigns/api.py b/hoard/campaigns/api.py index 67a253f66..e459c5097 100644 --- a/hoard/campaigns/api.py +++ b/hoard/campaigns/api.py @@ -1,11 +1,9 @@ from __future__ import annotations from decimal import Decimal -from typing import Literal from django.contrib.auth import authenticate, login, logout -from django.core.exceptions import ValidationError as DjangoValidationError -from django.db.models import Prefetch, Q, Sum +from django.db.models import Q from django.http import JsonResponse from django.middleware.csrf import CsrfViewMiddleware, get_token from django.shortcuts import get_object_or_404 @@ -14,17 +12,7 @@ from ninja.errors import HttpError from ninja.security import django_auth -from .models import ( - Campaign, - CampaignContext, - Character, - ExperienceTransaction, - MoneyEntry, - MoneyTransaction, -) -from .realtime import notify_campaign_changed -from .services import exchange_coins, reverse_transaction -from .services.ledger import post_money_transaction +from .models import Campaign, CampaignContext, Character, PartyExperienceAward class Credentials(Schema): @@ -32,42 +20,9 @@ class Credentials(Schema): password: str -class MoneyTransferCreate(Schema): - from_character_id: int | None - to_character_id: int | None - amounts: dict[str, int] - description: str = "" - - -class MoneyExchangeCreate(Schema): - character_id: int - given: dict[str, int] - received: dict[str, int] - description: str = "" - - -class SharedXpAwardCreate(Schema): - amount: int - description: str = "" - - api = NinjaAPI(title="Hoard API", version="0.1.3", auth=django_auth) -def unprocessable(error: DjangoValidationError) -> HttpError: - messages = error.message_dict if hasattr(error, "message_dict") else error.messages - return HttpError(422, str(messages)) - - -def context_access(request, context_id: int) -> CampaignContext: - return get_object_or_404( - CampaignContext.objects.select_related("campaign", "user", "character"), - pk=context_id, - user=request.auth, - is_active=True, - ) - - def gm_context(context: CampaignContext) -> None: if context.kind != CampaignContext.Kind.GM: raise HttpError(403, "This action requires a game-master context.") @@ -108,257 +63,79 @@ def visible_characters(context: CampaignContext): def context_data(context: CampaignContext) -> dict[str, object]: character = getattr(context, "character", None) + + if character is None: + character_name = None + elif not character.dicecloud_creature_id and character.name: + character_name = character.name + else: + character_name = context.user.get_username() + return { "id": context.pk, + "active_context_id": context.pk, "campaign_id": context.campaign_id, "campaign_name": context.campaign.name, "kind": context.kind, "character_id": character.pk if character else None, - "character_name": character.name if character else None, - } - - -def money_data(character: Character) -> dict[str, int | str]: - return { - "cp": character.money.copper, - "sp": character.money.silver, - "ep": character.money.electrum, - "gp": character.money.gold, - "pp": character.money.platinum, - "gold_value": str(character.money.gold_value), + "character_name": character_name, } -ABILITIES = ( - "strength", - "dexterity", - "constitution", - "intelligence", - "wisdom", - "charisma", -) -SKILL_ABILITIES = { - "acrobatics": "dexterity", - "animal_handling": "wisdom", - "arcana": "intelligence", - "athletics": "strength", - "deception": "charisma", - "history": "intelligence", - "insight": "wisdom", - "intimidation": "charisma", - "investigation": "intelligence", - "medicine": "wisdom", - "nature": "intelligence", - "perception": "wisdom", - "performance": "charisma", - "persuasion": "charisma", - "religion": "intelligence", - "sleight_of_hand": "dexterity", - "stealth": "dexterity", - "survival": "wisdom", -} - - -def calculation( - value: int, base: int, components: list[dict[str, object]] -) -> dict[str, object]: - return {"value": value, "base": base, "components": components} +def dicecloud_projection(character: Character) -> dict[str, object] | None: + if not character.dicecloud_creature_id: + return None + from .services.dicecloud import DiceCloudUnavailable, load_creature -def character_sheet_data(character: Character) -> dict[str, object]: - hp_modifier = character.ability_modifier(character.hp_ability) - initiative_half = character.half_proficiency("dexterity", "none") - base_proficiency = 2 + (character.level - 1) // 4 - return { - "level": character.level, - "rolled_hit_points": character.rolled_hit_points, - "hp_ability": character.hp_ability, - "hp_adjustment": character.hp_adjustment, - "initiative_adjustment": character.initiative_adjustment, - "proficiency_bonus_adjustment": character.proficiency_bonus_adjustment, - "max_hp": character.max_hp, - "hp_calculation": calculation( - character.max_hp, - character.rolled_hit_points, - [ - { - "label": f"{character.hp_ability.title()} modifier × level", - "value": hp_modifier * character.level, - "formula": f"{hp_modifier} × {character.level}", - "source": "ability", - }, - { - "label": "Custom modifier", - "value": character.hp_adjustment, - "source": "override", - }, - ], - ), - "current_hp": character.current_hp, - "temporary_hp": character.temporary_hp, - "initiative": calculation( - character.initiative_bonus, - character.ability_modifier("dexterity"), - [ - { - "label": "Half proficiency", - "value": initiative_half, - "source": "feature", - }, - { - "label": "Custom modifier", - "value": character.initiative_adjustment, - "source": "override", - }, - ], - ), - "proficiency_bonus": character.proficiency_bonus, - "proficiency_bonus_calculation": calculation( - character.proficiency_bonus, - base_proficiency, - [ - { - "label": "Level progression", - "value": base_proficiency, - "source": "rules", - }, - { - "label": "Custom modifier", - "value": character.proficiency_bonus_adjustment, - "source": "override", - }, - ], - ), - "jack_of_all_trades": character.jack_of_all_trades, - "remarkable_athlete": character.remarkable_athlete, - "abilities": { - ability: { - "score": character.ability_score(ability), - "raw": getattr(character, ability), - "ancestry_bonus": int(character.ability_bonuses.get(ability, 0)), - "background_bonus": int( - character.background_ability_bonuses.get(ability, 0) - ), - "score_adjustment": int( - character.ability_score_adjustments.get(ability, 0) - ), - "modifier": character.ability_modifier(ability), - "check_bonus": character.ability_check(ability), - "check_formula": calculation( - character.ability_check(ability), - character.ability_modifier(ability), - [ - { - "label": "Half proficiency", - "value": character.half_proficiency(ability, "none"), - "source": "feature", - } - ], - ), - "formula": calculation( - character.ability_score(ability), - getattr(character, ability), - [ - { - "label": "Ancestry modifier", - "value": int(character.ability_bonuses.get(ability, 0)), - "source": "ancestry", - }, - { - "label": "Background modifier", - "value": int( - character.background_ability_bonuses.get(ability, 0) - ), - "source": "background", - }, - { - "label": "Custom modifier", - "value": int( - character.ability_score_adjustments.get(ability, 0) - ), - "source": "override", - }, - ], - ), - } - for ability in ABILITIES - }, - "saves": { - ability: { - "proficiency": character.save_proficiencies.get(ability, "none"), - "adjustment": int(character.save_adjustments.get(ability, 0)), - "bonus": character.saving_throw(ability), - "formula": calculation( - character.saving_throw(ability), - character.ability_modifier(ability), - [ - { - "label": "Proficiency bonus", - "value": character.proficiency_bonus - if character.save_proficiencies.get(ability) == "proficient" - else 0, - "source": "proficiency", - }, - { - "label": "Custom modifier", - "value": int(character.save_adjustments.get(ability, 0)), - "source": "override", - }, - ], - ), - } - for ability in ABILITIES - }, - "skills": { - skill: { - "ability": ability, - "proficiency": character.skill_proficiencies.get(skill, "none"), - "adjustment": int(character.skill_adjustments.get(skill, 0)), - "bonus": character.skill_bonus(skill, ability), - "formula": calculation( - character.skill_bonus(skill, ability), - character.ability_modifier(ability), - [ - { - "label": "Proficiency contribution", - "value": character.skill_bonus(skill, ability) - - character.ability_modifier(ability) - - int(character.skill_adjustments.get(skill, 0)), - "source": "proficiency", - }, - { - "label": "Custom modifier", - "value": int(character.skill_adjustments.get(skill, 0)), - "source": "override", - }, - ], - ), - } - for skill, ability in SKILL_ABILITIES.items() - }, - } + try: + return load_creature(character.dicecloud_creature_id) + except DiceCloudUnavailable as error: + return {"unavailable": True, "error": str(error)} def character_data( - character: Character, viewing_context: CampaignContext | None = None + character: Character, + viewing_context: CampaignContext | None = None, + projection: dict[str, object] | None = None, ) -> dict[str, object]: + if projection is None: + projection = dicecloud_projection(character) + if character.dicecloud_creature_id: + fallback_name = ( + character.context.user.get_username() + if character.context_id is not None + else f"Linked player {character.pk}" + ) + else: + fallback_name = character.name or ( + character.context.user.get_username() + if character.context_id is not None + else f"Unlinked player {character.pk}" + ) + name = projection.get("name", fallback_name) if projection else fallback_name + image_url = projection.get("image_url") if projection else None + details = projection.get("details", {}) if projection else {} return { "id": character.pk, "context_id": character.context_id, - "name": character.name, - "portrait_url": character.portrait.url if character.portrait else None, - "kind": character.kind, - "is_player_character": character.is_player_character, + "name": name, + "portrait_url": image_url, + "is_player_character": True, "is_active": character.is_active, - "race": character.race, - "class": character.character_class, - "experience": character.experience, - "sheet": character_sheet_data(character), - "money": money_data(character), + "is_connected": bool(character.dicecloud_creature_id), + "dicecloud_creature_id": character.dicecloud_creature_id or None, + "current_hp": None if character.dicecloud_creature_id else character.current_hp, + "max_hp": None if character.dicecloud_creature_id else character.max_hp, + "profile": projection, + "race": details.get("race", "") if isinstance(details, dict) else "", + "class": details.get("class", "") if isinstance(details, dict) else "", } -def party_money(campaign: Campaign) -> dict[str, int | str]: +def party_money( + campaign: Campaign, projections: dict[int, dict[str, object] | None] +) -> dict[str, int | str]: totals: dict[str, int | str] = { "cp": 0, "sp": 0, @@ -366,17 +143,19 @@ def party_money(campaign: Campaign) -> dict[str, int | str]: "gp": 0, "pp": 0, } - rows = ( - MoneyEntry.objects.filter( - account__character__campaign=campaign, - account__character__is_active=True, - account__character__kind=Character.Kind.PC, - ) - .values("denomination") - .annotate(total=Sum("amount")) - ) - for row in rows: - totals[row["denomination"]] = row["total"] + for character_id, projection in projections.items(): + if projection is None: + continue + character = campaign.characters.get(pk=character_id) + if not character.is_active: + continue + currency = projection.get("currency") + if not isinstance(currency, dict): + continue + for denomination in ("cp", "sp", "ep", "gp", "pp"): + value = currency.get(denomination, 0) + if isinstance(value, int): + totals[denomination] += value totals["gold_value"] = str( Decimal(totals["cp"]) / 100 + Decimal(totals["sp"]) / 10 @@ -387,229 +166,25 @@ def party_money(campaign: Campaign) -> dict[str, int | str]: return totals -def coin_amounts( - amounts: dict[str, int], -) -> dict[MoneyEntry.Denomination, int]: - result = {} - for denomination, amount in amounts.items(): - try: - key = MoneyEntry.Denomination(denomination) - except ValueError as error: - raise HttpError(422, "Unknown currency denomination.") from error - if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0: - raise HttpError(422, "Coin amounts must be positive integers.") - result[key] = amount - if not result: - raise HttpError(422, "At least one coin amount is required.") - return result - - -def can_act_for(context: CampaignContext, character: Character) -> bool: - return context.kind == CampaignContext.Kind.GM or is_character_owner( - context, character - ) - - -def transaction_data( - posted: MoneyTransaction | ExperienceTransaction, -) -> dict[str, object]: +def party_experience_award_data(award: PartyExperienceAward) -> dict[str, object]: return { - "id": posted.pk, - "ledger": posted._meta.model_name.removesuffix("transaction"), - "ledger_label": str(posted._meta.verbose_name), - "description": posted.description, - "created_at": posted.occurred_at.isoformat(), - "occurred_at": posted.occurred_at.isoformat(), - "campaign_date": posted.campaign_date, - "created_by_id": posted.created_by_id, + "id": award.pk, + "amount": award.amount, + "recipient_count": award.recipient_count, + "awarded_per_player": award.awarded_per_player, + "discarded_amount": award.discarded_amount, + "reason": award.reason, + "description": award.description, + "occurred_at": award.occurred_at.isoformat(), + "campaign_date": award.campaign_date, "actor": ( - actor_name(posted.created_by.user) - if posted.created_by_id - else posted.actor_username or None + award.created_by.user.get_username() + if award.created_by_id + else award.actor_username or None ), } -def actor_name(user) -> str: - return getattr(user, "name", "") or user.get_username() - - -def history_data(posted) -> dict[str, object]: - data = transaction_data(posted) - entries = [] - for entry in posted.entries.all(): - value = { - "account_id": entry.account_id, - "account_name": f"Campaign {data['ledger']} system" - if entry.account.is_system - else entry.account.character.name, - "is_system_account": entry.account.is_system, - "amount": entry.amount, - } - if isinstance(entry, MoneyEntry): - value["denomination"] = entry.denomination - entries.append(value) - data.update( - entries=entries, - reversal_of_id=posted.reversal_of_id, - is_reversed=hasattr(posted, "reversal"), - ) - return data - - -TRANSACTION_MODELS = { - "money": MoneyTransaction, - "experience": ExperienceTransaction, -} - - -def transaction_queryset(model, campaign: Campaign): - entry_model = model._meta.get_field("entries").related_model - entries = entry_model.objects.select_related("account__character") - return ( - model.objects.filter(campaign=campaign) - .select_related("created_by__user") - .prefetch_related(Prefetch("entries", queryset=entries)) - ) - - -def money_transfer_create(request, context_id: int, payload: MoneyTransferCreate): - context = context_access(request, context_id) - source = ( - character_for_context(context, payload.from_character_id) - if payload.from_character_id - else None - ) - destination = ( - character_for_context(context, payload.to_character_id) - if payload.to_character_id - else None - ) - if source is None and destination is None: - raise HttpError(422, "A money transfer needs a source or destination.") - if context.kind != CampaignContext.Kind.GM and ( - source is None or not can_act_for(context, source) - ): - raise HttpError( - 403, "Players may only transfer money from their own character." - ) - amounts = coin_amounts(payload.amounts) - if source: - balances = { - MoneyEntry.Denomination.COPPER: source.money.copper, - MoneyEntry.Denomination.SILVER: source.money.silver, - MoneyEntry.Denomination.ELECTRUM: source.money.electrum, - MoneyEntry.Denomination.GOLD: source.money.gold, - MoneyEntry.Denomination.PLATINUM: source.money.platinum, - } - if any( - amount > balances[denomination] for denomination, amount in amounts.items() - ): - raise HttpError( - 422, "A character cannot transfer more coins than they hold." - ) - try: - posted = post_money_transaction( - [ - ( - source.money_account() - if source - else context.campaign.money_system_account(), - denomination, - -amount, - ) - for denomination, amount in amounts.items() - ] - + [ - ( - destination.money_account() - if destination - else context.campaign.money_system_account(), - denomination, - amount, - ) - for denomination, amount in amounts.items() - ], - description=payload.description, - ) - posted.created_by = context - posted.save(update_fields=("created_by",)) - except DjangoValidationError as error: - raise unprocessable(error) from error - notify_campaign_changed(context.campaign_id) - return 201, transaction_data(posted) - - -def money_exchange_create(request, context_id: int, payload: MoneyExchangeCreate): - context = context_access(request, context_id) - character = character_for_context(context, payload.character_id) - if not can_act_for(context, character): - raise HttpError(403, "Players may only exchange their own coins.") - try: - posted = exchange_coins( - character=character, - given=coin_amounts(payload.given), - received=coin_amounts(payload.received), - description=payload.description, - ) - posted.created_by = context - posted.save(update_fields=("created_by",)) - except DjangoValidationError as error: - raise unprocessable(error) from error - notify_campaign_changed(context.campaign_id) - return 201, transaction_data(posted) - - -def shared_xp_award_create(request, context_id: int, payload: SharedXpAwardCreate): - context = context_access(request, context_id) - gm_context(context) - try: - _, posted = context.campaign.award_shared_experience( - payload.amount, - description=payload.description, - created_by=context, - return_transaction=True, - ) - except DjangoValidationError as error: - raise unprocessable(error) from error - notify_campaign_changed(context.campaign_id) - return 201, transaction_data(posted) - - -def transaction_reverse( - request, - context_id: int, - ledger: Literal["money", "experience"], - transaction_id: int, -): - context = context_access(request, context_id) - model = TRANSACTION_MODELS[ledger] - original = get_object_or_404(model, pk=transaction_id, campaign=context.campaign) - latest = ( - model.objects.filter(campaign=context.campaign) - .order_by("-occurred_at", "-pk") - .first() - ) - if latest is None or latest.pk != original.pk: - raise HttpError( - 409, "Only the latest transaction in this ledger may be reversed." - ) - if original.reversal_of_id or hasattr(original, "reversal"): - raise HttpError(409, "Reversal transactions are final.") - if context.kind != CampaignContext.Kind.GM and original.created_by_id != context.pk: - raise HttpError( - 403, "Only the initiator or a game master may reverse this transaction." - ) - try: - reversed_posted = reverse_transaction(original) - reversed_posted.created_by = context - reversed_posted.save(update_fields=("created_by",)) - except DjangoValidationError as error: - raise unprocessable(error) from error - notify_campaign_changed(context.campaign_id) - return history_data(reversed_posted) - - @api.get("/auth/csrf/", auth=None) @ensure_csrf_cookie @csrf_exempt diff --git a/hoard/campaigns/components/AbilityCard.vue b/hoard/campaigns/components/AbilityCard.vue deleted file mode 100644 index 3b64a36ac..000000000 --- a/hoard/campaigns/components/AbilityCard.vue +++ /dev/null @@ -1,130 +0,0 @@ - - - diff --git a/hoard/campaigns/components/CampaignNavigation.vue b/hoard/campaigns/components/CampaignNavigation.vue index b8a00554e..56249ff97 100644 --- a/hoard/campaigns/components/CampaignNavigation.vue +++ b/hoard/campaigns/components/CampaignNavigation.vue @@ -33,26 +33,6 @@ Characters -
  • - - -
  • -
    -
    -

    -

    -
    - - - -