diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9346fce0..5d52d125b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: push: branches: - main + - develop permissions: contents: read diff --git a/.gitignore b/.gitignore index 37533b5e8..153cf253e 100644 --- a/.gitignore +++ b/.gitignore @@ -204,6 +204,7 @@ cover/ local_settings.py db.sqlite3 db.sqlite3-journal +celerybeat-schedule* # Flask stuff: instance/ diff --git a/CHANGELOG.md b/CHANGELOG.md index be83608cb..7fe3dec12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and Hoard adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +## [0.2.0] - 2026-10-23 + +### Added + +- Read-only public DiceCloud character-sheet links, live sheet projection with a + 24-hour outage fallback, and a direct link back to each connected sheet. +- DiceCloud-backed profile summaries for identity, XP progress, currency, combat + statistics, abilities, saving throws, skills, and temporary hit points. + +### Changed + +- Refresh active linked DiceCloud profiles every 10 seconds while a campaign has + a connected participant, publishing updates only when the projected sheet data + changes and pausing sync after the existing presence window expires. +- Replaced local character sheets with lightweight player-character records that + retain campaign context, legacy name and HP fallback values, DiceCloud link, and + activity state. +- Reworked the profile and Party Rail around live DiceCloud data, including + MeterGroup HP displays and percentage-only health for hidden enemy HP. +- Changed XP sharing to campaign-level party XP, with a configurable number of + shares so players without a connected sheet can still receive a portion. +- Derived party currency from active connected DiceCloud sheets rather than local + money ledgers. +- Preserved generic encounter enemies and their local HP controls while making + connected player HP read-only. + +### Removed + +- Local NPC profiles, editable local character-sheet state, portrait uploads, + personal money ledgers, individual XP accounts, and local HP/rest mutations. +- Compendium pages, routes, and supporting data. +- The upgrade migration permanently removes existing local NPC, portrait, money, + individual-XP, health, and character-sheet data. Back up a `0.1.x` database + before deploying `0.2.0` if any of that information must be retained. + ## [0.1.2] - 2026-10-07 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..fddfb9efd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing to Hoard + +Thank you for contributing to Hoard. This guide covers the local environment, +routine checks, release-version workflow, deployment, and architecture. + +## Local development + +Running `nix-shell` provides Python 3.14, uv, Node.js, npm, Git, and Docker Compose. +Docker Compose is the supported way to run the full stack; the shell is useful for +repository checks and maintenance. + +Start the development environment with Daphne, Vite live reload, PostgreSQL, and +Redis: + +```sh +docker compose -f compose.dev.yml up --build +``` + +Open `http://localhost:5173`. Vite proxies HTTP uploads, media, the release manifest, +and WebSockets to Daphne. Django is also available directly at +`http://localhost:8000`. + +The application container applies migrations when it starts. Stop the stack without +removing its database or uploaded portraits with: + +```sh +docker compose -f compose.dev.yml down +``` + +Add `--volumes` only when intentionally recreating the unsupported pre-release +database and all local uploads. + +## Verification + +Run these checks from a second terminal while the development stack is running: + +```sh +docker compose -f compose.dev.yml exec app uv run pytest --reuse-db +docker compose -f compose.dev.yml exec app uv run ruff check hoard +docker compose -f compose.dev.yml exec frontend npm test +docker compose -f compose.dev.yml exec frontend npm run build +``` + +## Release versions + +Use the project command to update the frontend and Python version declarations +together: + +```sh +npm run version:bump -- patch +``` + +Replace `patch` with `minor` or `major` as appropriate. The command refuses to run +when existing version declarations do not agree. + +## Production deployment + +Production runs Daphne, Celery, PostgreSQL, Redis, and Nginx. Nginx serves static +assets and uploaded media directly. Traefik routes application HTTP and WebSocket +traffic straight to Daphne, while sending `/static/` and `/media/` requests to +Nginx. + +Create the external network and local production environment file once: + +```sh +docker network create web +cp .env.example .env +sudo install -d -o 1000 -g 1000 -m 0755 \ + /data/appdata/hoard/media \ + /data/appdata/hoard/static +``` + +Set a long random `DJANGO_SECRET_KEY` and a strong `POSTGRES_PASSWORD` in `.env`, +then build and start the deployment for `https://dnd.turnr.net`: + +```sh +docker compose up -d --build +``` + +Compose loads `.env` automatically. Database names and users can be changed with +`POSTGRES_DB` and `POSTGRES_USER`. Persistent production data is stored below +`/data/appdata/hoard`. The application image runs as UID/GID `1000`, so its media +and static directories must be writable by that account before the first start. + +To inspect status and logs: + +```sh +docker compose ps +docker compose logs -f app worker nginx +``` + +### Periodic tasks + +Celery Beat runs the static DiceCloud-profile refresh every 10 seconds. Its local +`celerybeat-schedule` file records when tasks last ran, but Hoard does not need to +retain that state between container restarts: a restart may delay one refresh, and +the next scheduled refresh restores normal operation. The entrypoint therefore +stores it in `/tmp`, keeping generated scheduler files out of the repository and +away from persistent application data. + +If Hoard later needs user-managed or durable periodic schedules, use a shared +database-backed scheduler such as `django-celery-beat` rather than persisting the +container-local schedule file. + +## Architecture + +- Django owns authentication, campaign data, portrait uploads, and the initial SPA + response. Nginx serves uploaded media directly in production. +- Application queries and commands use authenticated WebSockets. HTTP is limited to + session/CSRF operations, portrait uploads, static/media delivery, and the release + manifest. +- Redis carries campaign broadcasts, cache data, and Celery messages. +- PostgreSQL stores the campaign, audit, money, and XP ledgers. +- Vue and PrimeVue provide the browser application; Vite builds its production + assets into `hoard/dist`. + +See [the API guide](docs/api.md) and [the initial release brief](docs/initial-release.md) +for the protocol and release scope. + +## AI policy + +Listen here, I'm a software dev, have been making Django apps since before you were +even a stain on your parents bedsheets. I'm taking assistance to speed things up, +and I don't care. diff --git a/README.md b/README.md index 48653eade..945cbfc5d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,26 @@ # Hoard -Hoard is a small campaign companion for shared XP, coin ledgers, calendar tracking, -character profiles, invitations, and campaign administration. +

+ Hoard +

+ +Hoard is a campaign companion for the everyday work of running a tabletop game. +It keeps the party's shared XP, calendar, roster, DiceCloud characters, and +campaign administration in one live, responsive workspace. ## Screenshots ### Desktop +The game-master dashboard keeps the party's progress, character details, and +campaign tools close at hand. + ![Hoard game-master dashboard on desktop](docs/images/hoard-desktop.png) ### Mobile +The same character information and campaign navigation adapt for smaller screens. +

({ 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..773b8a279 100644 --- a/hoard/api.ts +++ b/hoard/api.ts @@ -25,50 +25,46 @@ 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: Array; + resources: Array; + equipment: Array; + spells: Array; + features: Array; + currency: Record; +}; + +export type DiceCloudSkill = { + key: string; + name: string; + ability: string; + value: string | number | null; + proficiency: number | null; + order: number; +}; + +export type DiceCloudSave = { + value: string | number | null; + proficiency: number | null; +}; + +export type DiceCloudEntry = { + name: string; + description: string; + type: string; + value: string | number | null; }; export type CampaignSummary = { @@ -109,14 +105,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 +161,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 +185,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 +384,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 +509,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..6d9d84cf3 100644 --- a/hoard/campaigns/admin.py +++ b/hoard/campaigns/admin.py @@ -1,19 +1,12 @@ -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, ) @@ -38,85 +31,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 874247281..b8d7ba987 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,40 +20,7 @@ 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.2", 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, - ) +api = NinjaAPI(title="Hoard API", version="0.2.0", auth=django_auth) def gm_context(context: CampaignContext) -> None: @@ -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 index 3b64a36ac..28b33632e 100644 --- a/hoard/campaigns/components/AbilityCard.vue +++ b/hoard/campaigns/components/AbilityCard.vue @@ -1,129 +1,135 @@ diff --git a/hoard/campaigns/components/GmCoinForm.vue b/hoard/campaigns/components/GmCoinForm.vue deleted file mode 100644 index bb478cbb2..000000000 --- a/hoard/campaigns/components/GmCoinForm.vue +++ /dev/null @@ -1,139 +0,0 @@ -