Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45d74ce5d7 | |||
| 895807d6c9 | |||
| dd694477b2 | |||
| f75b50adb8 | |||
| ba5ae37111 | |||
| dc0e8f701c | |||
| 3d2c68a1a1 | |||
| 8ba38d893d | |||
| 2651ac03b7 | |||
| 026927b5f5 | |||
| 1b275ed5e6 | |||
| 53854deb81 | |||
| bde21992bd | |||
| 71bca6e70a | |||
| 9a81c04199 | |||
| 740cf20b93 | |||
| 235cdecb53 | |||
| 3f524ce82c | |||
| 426824fd78 |
@@ -19,7 +19,8 @@ test('GM hosts a session; a player on another device sees live combat', async ({
|
|||||||
await gm.getByLabel('Settings').click();
|
await gm.getByLabel('Settings').click();
|
||||||
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
await gm.getByRole('button', { name: '📡 Host' }).click();
|
await gm.getByRole('button', { name: 'Host' }).click();
|
||||||
|
await gm.getByRole('button', { name: 'Start hosting' }).click();
|
||||||
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
||||||
expect(code).toHaveLength(6);
|
expect(code).toHaveLength(6);
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
|
|||||||
await gm.getByRole('button', { name: /Share with/ }).click();
|
await gm.getByRole('button', { name: /Share with/ }).click();
|
||||||
await gm.getByLabel('Share title').fill('Secret passage');
|
await gm.getByLabel('Share title').fill('Secret passage');
|
||||||
await gm.getByRole('button', { name: /Send to/ }).click();
|
await gm.getByRole('button', { name: /Send to/ }).click();
|
||||||
await expect(player.getByText('📨 Just for you')).toBeVisible();
|
await expect(player.getByText('Just for you')).toBeVisible();
|
||||||
await expect(player.getByText('Secret passage')).toBeVisible();
|
await expect(player.getByText('Secret passage')).toBeVisible();
|
||||||
|
|
||||||
await gmCtx.close();
|
await gmCtx.close();
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ test('a player claims a seat and manages their character; edits round-trip to th
|
|||||||
await gm.getByLabel('Settings').click();
|
await gm.getByLabel('Settings').click();
|
||||||
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
await gm.getByRole('button', { name: '📡 Host' }).click();
|
await gm.getByRole('button', { name: 'Host' }).click();
|
||||||
|
await gm.getByRole('button', { name: 'Start hosting' }).click();
|
||||||
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
||||||
expect(code).toHaveLength(6);
|
expect(code).toHaveLength(6);
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ test('level-up advisor offers routes and expands a chosen one (deterministic)',
|
|||||||
test('assistant page renders the campaign insights section', async ({ page }) => {
|
test('assistant page renders the campaign insights section', async ({ page }) => {
|
||||||
await page.getByLabel('Settings').click();
|
await page.getByLabel('Settings').click();
|
||||||
await page.getByRole('button', { name: 'Load sample campaign' }).click();
|
await page.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
await page.getByRole('link', { name: 'Assistant' }).click();
|
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
|
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
|
||||||
// Sample has a single encounter → not enough for a trend yet.
|
// Sample has a single encounter → not enough for a trend yet.
|
||||||
await expect(page.getByText(/No trends detected yet/)).toBeVisible();
|
await expect(page.getByText(/No trends detected yet/)).toBeVisible();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test('assistant flags a bloodied PC and builds an encounter', async ({ page }) =
|
|||||||
|
|
||||||
// Assistant shows a resource suggestion
|
// Assistant shows a resource suggestion
|
||||||
await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'Assistant' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible();
|
||||||
await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible();
|
await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test('homebrew: create a custom monster and find it in the compendium', async ({
|
|||||||
await page.locator('input[data-autofocus]').fill('Brew');
|
await page.locator('input[data-autofocus]').fill('Brew');
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'Homebrew' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Homebrew' }).click();
|
||||||
|
|
||||||
// Create a homebrew monster and name it
|
// Create a homebrew monster and name it
|
||||||
await page.getByRole('button', { name: /New monster/ }).click();
|
await page.getByRole('button', { name: /New monster/ }).click();
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createCharacter } from './helpers';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
indexedDB.deleteDatabase('ttrpg-manager');
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('v2 surfaces: feats tracking, multiclass slot calculator, signals bell, World nav', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('V2 Test');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click();
|
||||||
|
await createCharacter(page, 'Vex');
|
||||||
|
|
||||||
|
// Feats: add one and confirm it becomes a tracked record.
|
||||||
|
await page.getByPlaceholder(/Great Weapon Master/).fill('Sentinel');
|
||||||
|
await page.getByPlaceholder(/Great Weapon Master/).press('Enter');
|
||||||
|
await expect(page.getByLabel('Feat name')).toHaveValue('Sentinel');
|
||||||
|
|
||||||
|
// Multiclass slot calculator: 5 full-caster levels → standard 4/3/2 table.
|
||||||
|
await page.getByText('Multiclass slot calculator').click();
|
||||||
|
await page.getByLabel('Full caster levels').fill('5');
|
||||||
|
await page.getByRole('button', { name: 'Apply slots' }).click();
|
||||||
|
await expect(page.getByLabel('Level 1 max slots')).toHaveValue('4');
|
||||||
|
await expect(page.getByLabel('Level 3 max slots')).toHaveValue('2');
|
||||||
|
|
||||||
|
// Ambient Signals bell opens and renders its panel.
|
||||||
|
await page.getByRole('button', { name: /Signals/ }).click();
|
||||||
|
await expect(page.getByText('Signals', { exact: true })).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// The previously-orphaned routes are now reachable from the rail's World group.
|
||||||
|
await page.getByLabel('Primary').getByRole('link', { name: 'Notes' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/notes/);
|
||||||
|
await page.getByLabel('Primary').getByRole('link', { name: 'Quests' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/quests/);
|
||||||
|
});
|
||||||
@@ -30,13 +30,13 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
|
|||||||
|
|
||||||
// NPC
|
// NPC
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'NPCs' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'NPCs' }).click();
|
||||||
await page.getByRole('button', { name: '+ New NPC' }).first().click();
|
await page.getByRole('button', { name: '+ New NPC' }).first().click();
|
||||||
await expect(page.getByLabel('NPC name')).toBeVisible();
|
await expect(page.getByLabel('NPC name')).toBeVisible();
|
||||||
|
|
||||||
// Quest with an objective
|
// Quest with an objective
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'Quests' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Quests' }).click();
|
||||||
await page.getByRole('button', { name: '+ New quest' }).first().click();
|
await page.getByRole('button', { name: '+ New quest' }).first().click();
|
||||||
await page.getByPlaceholder('Add objective…').fill('Find the Tome');
|
await page.getByPlaceholder('Add objective…').fill('Find the Tome');
|
||||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||||
@@ -44,7 +44,7 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
|
|||||||
|
|
||||||
// Calendar advances days
|
// Calendar advances days
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
await page.getByRole('link', { name: 'Calendar' }).click();
|
await page.getByLabel('Primary').getByRole('link', { name: 'Calendar' }).click();
|
||||||
await page.getByRole('button', { name: '+1 week' }).click();
|
await page.getByRole('button', { name: '+1 week' }).click();
|
||||||
await expect(page.getByText('7', { exact: true })).toBeVisible();
|
await expect(page.getByText('7', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ describe('AccountStore', () => {
|
|||||||
if (!r.ok) throw new Error('register failed');
|
if (!r.ok) throw new Error('register failed');
|
||||||
const u = await store.userByToken(r.token);
|
const u = await store.userByToken(r.token);
|
||||||
expect(u).toBeTruthy();
|
expect(u).toBeTruthy();
|
||||||
await store.saveBlob(u!.id, '{"x":1}');
|
const ver = await store.saveBlob(u!.id, '{"x":1}');
|
||||||
|
expect(typeof ver).toBe('number');
|
||||||
expect(await store.loadBlob(u!.id)).toBe('{"x":1}');
|
expect(await store.loadBlob(u!.id)).toBe('{"x":1}');
|
||||||
|
// the version token round-trips and is null for a user with no blob
|
||||||
|
expect(await store.blobSavedAt(u!.id)).toBe(ver);
|
||||||
|
expect(await store.blobSavedAt('no-such-user')).toBeNull();
|
||||||
expect(await store.userByToken('garbage-token')).toBeNull();
|
expect(await store.userByToken('garbage-token')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -132,16 +132,25 @@ export class AccountStore {
|
|||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveBlob(id: string, blob: string): Promise<void> {
|
/** Write the backup blob and return its version token (the file mtime, ms). */
|
||||||
|
async saveBlob(id: string, blob: string): Promise<number> {
|
||||||
await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true });
|
await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true });
|
||||||
const tmp = `${this.blobFile(id)}.tmp`;
|
const tmp = `${this.blobFile(id)}.tmp`;
|
||||||
await fs.writeFile(tmp, blob);
|
await fs.writeFile(tmp, blob);
|
||||||
await fs.rename(tmp, this.blobFile(id));
|
await fs.rename(tmp, this.blobFile(id));
|
||||||
|
return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadBlob(id: string): Promise<string | null> {
|
async loadBlob(id: string): Promise<string | null> {
|
||||||
try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; }
|
try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Version token of the stored blob (file mtime, ms), or null if none. Used for
|
||||||
|
* optimistic-concurrency conflict detection so a second device can't silently
|
||||||
|
* overwrite a newer cloud copy. */
|
||||||
|
async blobSavedAt(id: string): Promise<number | null> {
|
||||||
|
try { return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
userCount(): number { return this.users.size; }
|
userCount(): number { return this.users.size; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,4 +55,25 @@ describe('CloudStore', () => {
|
|||||||
expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id);
|
expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id);
|
||||||
expect(await store2.listCharacters(c.id)).toHaveLength(0);
|
expect(await store2.listCharacters(c.id)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rotates the invite code (owner only) so the old code stops working', async () => {
|
||||||
|
const c = await store.createCampaign('gm1', 'C', '5e');
|
||||||
|
const old = c.inviteCode;
|
||||||
|
expect(await store.rotateInvite('p1', c.id)).toBeNull(); // non-owner can't
|
||||||
|
const fresh = await store.rotateInvite('gm1', c.id);
|
||||||
|
expect(fresh).toBeTruthy();
|
||||||
|
expect(fresh).not.toBe(old);
|
||||||
|
expect(await store.joinByInvite('p1', old)).toBeNull(); // old code is dead
|
||||||
|
expect((await store.joinByInvite('p1', fresh!))?.id).toBe(c.id); // new code works
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a member and their characters (owner only)', async () => {
|
||||||
|
const c = await store.createCampaign('gm1', 'C', '5e');
|
||||||
|
await store.joinByInvite('p1', c.inviteCode);
|
||||||
|
await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{}' });
|
||||||
|
expect(await store.removeMember('p1', c.id, 'p1')).toBe(false); // non-owner can't
|
||||||
|
expect(await store.removeMember('gm1', c.id, 'p1')).toBe(true);
|
||||||
|
expect(store.isMember(c.id, 'p1')).toBe(false);
|
||||||
|
expect(await store.listCharacters(c.id)).toHaveLength(0); // their char is gone
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,6 +83,32 @@ export class CloudStore {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Owner-only: invalidate the current invite code and mint a new one, so a
|
||||||
|
* leaked/old code can no longer be used to join. Returns the new code. */
|
||||||
|
async rotateInvite(ownerUserId: string, campaignId: string): Promise<string | null> {
|
||||||
|
await this.load();
|
||||||
|
const c = this.campaigns.get(campaignId);
|
||||||
|
if (!c || c.ownerUserId !== ownerUserId) return null;
|
||||||
|
this.byInvite.delete(c.inviteCode);
|
||||||
|
c.inviteCode = this.mintInvite();
|
||||||
|
c.updatedAt = this.now();
|
||||||
|
this.byInvite.set(c.inviteCode, c.id);
|
||||||
|
await this.persist();
|
||||||
|
return c.inviteCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner-only: remove a member and all of their published characters. */
|
||||||
|
async removeMember(ownerUserId: string, campaignId: string, memberUserId: string): Promise<boolean> {
|
||||||
|
await this.load();
|
||||||
|
const c = this.campaigns.get(campaignId);
|
||||||
|
if (!c || c.ownerUserId !== ownerUserId || memberUserId === ownerUserId) return false;
|
||||||
|
c.members = c.members.filter((m) => m !== memberUserId);
|
||||||
|
c.updatedAt = this.now();
|
||||||
|
for (const [id, ch] of this.characters) if (ch.campaignId === campaignId && ch.ownerUserId === memberUserId) this.characters.delete(id);
|
||||||
|
await this.persist();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
isMember(campaignId: string, userId: string): boolean {
|
isMember(campaignId: string, userId: string): boolean {
|
||||||
const c = this.campaigns.get(campaignId);
|
const c = this.campaigns.get(campaignId);
|
||||||
return !!c && (c.ownerUserId === userId || c.members.includes(userId));
|
return !!c && (c.ownerUserId === userId || c.members.includes(userId));
|
||||||
|
|||||||
+38
-6
@@ -23,6 +23,10 @@ const bearer = (req: FastifyRequest): string | undefined => {
|
|||||||
|
|
||||||
// per-socket token bucket: 80 messages / 10s
|
// per-socket token bucket: 80 messages / 10s
|
||||||
const RATE = { capacity: 80, refillMs: 10_000 };
|
const RATE = { capacity: 80, refillMs: 10_000 };
|
||||||
|
// Protocol-level keepalive: ping each socket on an interval and terminate one
|
||||||
|
// that misses a pong, so a vanished player (closed laptop, dead network) is
|
||||||
|
// detected and drops out of the GM's roster within ~one interval.
|
||||||
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
export function buildServer() {
|
export function buildServer() {
|
||||||
const app = Fastify({ bodyLimit: BODY_LIMIT });
|
const app = Fastify({ bodyLimit: BODY_LIMIT });
|
||||||
@@ -61,17 +65,25 @@ export function buildServer() {
|
|||||||
app.put('/api/save', async (req, reply) => {
|
app.put('/api/save', async (req, reply) => {
|
||||||
const u = await accounts.userByToken(bearer(req));
|
const u = await accounts.userByToken(bearer(req));
|
||||||
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
const blob = (req.body as { blob?: string } | undefined)?.blob;
|
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
|
||||||
if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
|
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
|
||||||
await accounts.saveBlob(u.id, blob);
|
// Optimistic concurrency: if the caller names the version it last synced and
|
||||||
return { ok: true, size: blob.length };
|
// the cloud has since moved on (another device pushed), refuse — no silent
|
||||||
|
// overwrite. The client then resolves (pull, or force-push).
|
||||||
|
const current = await accounts.blobSavedAt(u.id);
|
||||||
|
if (current !== null && body.baseSavedAt !== undefined && body.baseSavedAt !== null && body.baseSavedAt !== current) {
|
||||||
|
return reply.code(409).send({ error: 'conflict', savedAt: current });
|
||||||
|
}
|
||||||
|
const savedAt = await accounts.saveBlob(u.id, body.blob);
|
||||||
|
return { ok: true, size: body.blob.length, savedAt };
|
||||||
});
|
});
|
||||||
app.get('/api/save', async (req, reply) => {
|
app.get('/api/save', async (req, reply) => {
|
||||||
const u = await accounts.userByToken(bearer(req));
|
const u = await accounts.userByToken(bearer(req));
|
||||||
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
const blob = await accounts.loadBlob(u.id);
|
const blob = await accounts.loadBlob(u.id);
|
||||||
if (blob === null) return reply.code(404).send({ error: 'no-save' });
|
if (blob === null) return reply.code(404).send({ error: 'no-save' });
|
||||||
return reply.header('content-type', 'application/json').send(blob);
|
const savedAt = await accounts.blobSavedAt(u.id);
|
||||||
|
return reply.header('content-type', 'application/json').header('x-saved-at', String(savedAt ?? '')).send(blob);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- shared cloud campaigns + member-owned characters ----
|
// ---- shared cloud campaigns + member-owned characters ----
|
||||||
@@ -94,6 +106,17 @@ export function buildServer() {
|
|||||||
if (!c) return reply.code(404).send({ error: 'no-campaign', message: 'No campaign with that invite code.' });
|
if (!c) return reply.code(404).send({ error: 'no-campaign', message: 'No campaign with that invite code.' });
|
||||||
return { id: c.id, name: c.name, system: c.system, role: c.ownerUserId === u.id ? 'owner' : 'member' };
|
return { id: c.id, name: c.name, system: c.system, role: c.ownerUserId === u.id ? 'owner' : 'member' };
|
||||||
});
|
});
|
||||||
|
app.post('/api/campaigns/:id/invite', async (req, reply) => {
|
||||||
|
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
const code = await cloud.rotateInvite(u.id, (req.params as { id: string }).id);
|
||||||
|
return code ? { inviteCode: code } : reply.code(403).send({ error: 'forbidden', message: 'Only the campaign owner can rotate the invite.' });
|
||||||
|
});
|
||||||
|
app.delete('/api/campaigns/:id/members/:userId', async (req, reply) => {
|
||||||
|
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
|
const p = req.params as { id: string; userId: string };
|
||||||
|
const ok = await cloud.removeMember(u.id, p.id, p.userId);
|
||||||
|
return ok ? { ok: true } : reply.code(403).send({ error: 'forbidden' });
|
||||||
|
});
|
||||||
app.put('/api/characters', async (req, reply) => {
|
app.put('/api/characters', async (req, reply) => {
|
||||||
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
|
const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
|
||||||
@@ -155,6 +178,15 @@ export function buildServer() {
|
|||||||
let tokens = RATE.capacity;
|
let tokens = RATE.capacity;
|
||||||
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
|
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
|
||||||
|
|
||||||
|
// Heartbeat: a socket that doesn't answer the previous ping is dead → drop it.
|
||||||
|
let alive = true;
|
||||||
|
socket.on('pong', () => { alive = true; });
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (!alive) { try { socket.terminate(); } catch { /* already gone */ } return; }
|
||||||
|
alive = false;
|
||||||
|
try { socket.ping(); } catch { /* closed */ }
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
|
||||||
socket.on('message', (raw: Buffer) => {
|
socket.on('message', (raw: Buffer) => {
|
||||||
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
|
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
|
||||||
let parsed;
|
let parsed;
|
||||||
@@ -177,7 +209,7 @@ export function buildServer() {
|
|||||||
case 'setName': hub.setName(sender, m.name); break;
|
case 'setName': hub.setName(sender, m.name); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
|
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function next(ws: WebSocket, t: ServerMessage['t']): Promise<ServerMessage> {
|
|||||||
ws.on('message', onMsg);
|
ws.on('message', onMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null };
|
const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null, quests: [] };
|
||||||
|
|
||||||
describe('realtime server (integration)', () => {
|
describe('realtime server (integration)', () => {
|
||||||
it('host → join → snapshot flow; players cannot push state', async () => {
|
it('host → join → snapshot flow; players cannot push state', async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function fake(): Sender & { msgs: ServerMessage[] } {
|
|||||||
const msgs: ServerMessage[] = [];
|
const msgs: ServerMessage[] = [];
|
||||||
return { msgs, send: (m) => msgs.push(m) };
|
return { msgs, send: (m) => msgs.push(m) };
|
||||||
}
|
}
|
||||||
const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null };
|
const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null, quests: [] };
|
||||||
const char: Character = characterSchema.parse({
|
const char: Character = characterSchema.parse({
|
||||||
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
|
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
|
||||||
abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 },
|
abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 },
|
||||||
|
|||||||
+16
-2
@@ -3,6 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
|||||||
import {
|
import {
|
||||||
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
||||||
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
|
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
|
||||||
|
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
@@ -17,11 +18,14 @@ import { SessionControl } from '@/features/play/SessionControl';
|
|||||||
import { HandoutControl } from '@/features/play/HandoutControl';
|
import { HandoutControl } from '@/features/play/HandoutControl';
|
||||||
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
||||||
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
||||||
|
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
|
||||||
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
||||||
|
import { SignalsBell } from '@/features/assistant/SignalsBell';
|
||||||
|
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
|
||||||
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
|
|
||||||
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'reference' };
|
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' };
|
||||||
const NAV: NavItem[] = [
|
const NAV: NavItem[] = [
|
||||||
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
|
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
|
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
|
||||||
@@ -29,12 +33,19 @@ const NAV: NavItem[] = [
|
|||||||
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
|
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
|
||||||
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
|
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
|
||||||
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
|
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
|
||||||
|
{ to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' },
|
||||||
|
{ to: '/npcs', label: 'NPCs', icon: Drama, exact: false, group: 'world' },
|
||||||
|
{ to: '/quests', label: 'Quests', icon: Target, exact: false, group: 'world' },
|
||||||
|
{ to: '/calendar', label: 'Calendar', icon: CalendarDays, exact: false, group: 'world' },
|
||||||
{ to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' },
|
{ to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' },
|
||||||
|
{ to: '/homebrew', label: 'Homebrew', icon: FlaskConical, exact: false, group: 'reference' },
|
||||||
|
{ to: '/assistant', label: 'Assistant', icon: Sparkles, exact: false, group: 'reference' },
|
||||||
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
|
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
|
||||||
];
|
];
|
||||||
const GROUPS: { id: NavItem['group']; label: string | null }[] = [
|
const GROUPS: { id: NavItem['group']; label: string | null }[] = [
|
||||||
{ id: 'top', label: null },
|
{ id: 'top', label: null },
|
||||||
{ id: 'play', label: 'At the Table' },
|
{ id: 'play', label: 'At the Table' },
|
||||||
|
{ id: 'world', label: 'World' },
|
||||||
{ id: 'reference', label: 'Reference' },
|
{ id: 'reference', label: 'Reference' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,7 +60,7 @@ function CampaignSwitcher() {
|
|||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
aria-label="Active campaign"
|
aria-label="Active campaign"
|
||||||
className="w-auto min-w-44"
|
className="w-auto min-w-0 max-w-[34vw] shrink truncate sm:min-w-44"
|
||||||
value={active?.id ?? ''}
|
value={active?.id ?? ''}
|
||||||
onChange={(e) => setActive(e.target.value || null)}
|
onChange={(e) => setActive(e.target.value || null)}
|
||||||
>
|
>
|
||||||
@@ -87,6 +98,7 @@ export function RootLayout() {
|
|||||||
// While the GM is hosting, mirror state to players (inert unless hosting).
|
// While the GM is hosting, mirror state to players (inert unless hosting).
|
||||||
useSessionBroadcaster(activeCampaign ?? null);
|
useSessionBroadcaster(activeCampaign ?? null);
|
||||||
usePlayerConnection();
|
usePlayerConnection();
|
||||||
|
useCloudAutosave();
|
||||||
|
|
||||||
// Global Ctrl/Cmd+K opens the command palette.
|
// Global Ctrl/Cmd+K opens the command palette.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -203,6 +215,8 @@ export function RootLayout() {
|
|||||||
</Button>
|
</Button>
|
||||||
{activeCampaign && <HandoutControl />}
|
{activeCampaign && <HandoutControl />}
|
||||||
<SessionControl />
|
<SessionControl />
|
||||||
|
<SyncStatusIndicator />
|
||||||
|
<SignalsBell />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
|
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
|
||||||
<SettingsIcon size={18} aria-hidden />
|
<SettingsIcon size={18} aria-hidden />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from 'react';
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
|
||||||
@@ -15,17 +16,33 @@ interface ModalProps {
|
|||||||
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
|
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
// Focus into the dialog ONCE when it opens (not on every parent re-render —
|
||||||
|
// that was stealing focus to the close button on each keystroke). Prefer the
|
||||||
|
// first real field over the X button so composers land in their input.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||||
const panel = panelRef.current;
|
const panel = panelRef.current;
|
||||||
panel?.querySelector<HTMLElement>('[data-autofocus], input, button, textarea, select')?.focus();
|
const target =
|
||||||
|
panel?.querySelector<HTMLElement>('[data-autofocus]') ??
|
||||||
|
panel?.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, select') ??
|
||||||
|
panel?.querySelector<HTMLElement>('a[href], button:not([disabled])');
|
||||||
|
target?.focus();
|
||||||
|
return () => { previouslyFocused.current?.focus(); };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Escape to close + focus trap. Reads onClose via a ref so a changing
|
||||||
|
// onClose identity never re-binds (and never re-triggers the focus effect).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const panel = panelRef.current;
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onCloseRef.current();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Tab' && panel) {
|
if (e.key === 'Tab' && panel) {
|
||||||
@@ -45,11 +62,8 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
document.removeEventListener('keydown', onKey);
|
}, [open]);
|
||||||
previouslyFocused.current?.focus();
|
|
||||||
};
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -69,7 +83,7 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
|
|||||||
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||||
<h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
|
<h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
|
||||||
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
|
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
|
||||||
✕
|
<X size={16} aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">{children}</div>
|
<div className="px-5 py-4">{children}</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Sparkles, Skull, X } from 'lucide-react';
|
||||||
import { useRollStore, type TrayRoll } from '@/stores/rollStore';
|
import { useRollStore, type TrayRoll } from '@/stores/rollStore';
|
||||||
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
|
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
|
||||||
import { naturalD20 } from '@/lib/dice/notation';
|
import { naturalD20 } from '@/lib/dice/notation';
|
||||||
@@ -35,8 +36,8 @@ export function RollTray() {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>}
|
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>}
|
||||||
{crit === 'crit' && <div className="text-xs font-bold uppercase tracking-wide text-warning">✦ Critical ✦</div>}
|
{crit === 'crit' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-warning"><Sparkles size={12} aria-hidden /> Critical <Sparkles size={12} aria-hidden /></div>}
|
||||||
{crit === 'fumble' && <div className="text-xs font-bold uppercase tracking-wide text-danger">✦ Fumble ✦</div>}
|
{crit === 'fumble' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-danger"><Skull size={12} aria-hidden /> Fumble</div>}
|
||||||
{last.degree && (
|
{last.degree && (
|
||||||
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
|
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
|
||||||
{DEGREE_LABEL[last.degree]}
|
{DEGREE_LABEL[last.degree]}
|
||||||
@@ -44,7 +45,7 @@ export function RollTray() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink">✕</button>
|
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"><X size={14} aria-hidden /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('mt-1 font-display text-4xl font-bold', crit === 'crit' ? 'text-warning' : crit === 'fumble' ? 'text-danger' : 'text-accent')}>{last.result.total}</div>
|
<div className={cn('mt-1 font-display text-4xl font-bold', crit === 'crit' ? 'text-warning' : crit === 'fumble' ? 'text-danger' : 'text-accent')}>{last.result.total}</div>
|
||||||
<div className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
|
<div className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { Campaign } from '@/lib/schemas';
|
import type { Campaign } from '@/lib/schemas';
|
||||||
import { charactersRepo, encountersRepo, notesRepo } from '@/lib/db/repositories';
|
import { encountersRepo } from '@/lib/db/repositories';
|
||||||
import { getSystem, applyRest } from '@/lib/rules';
|
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { addCombatant } from '@/lib/combat/engine';
|
import { addCombatant } from '@/lib/combat/engine';
|
||||||
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||||
import { resourceSuggestions, planningSuggestions, combatSuggestions, type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
|
import { type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
|
||||||
|
import { buildSignals } from '@/lib/assistant/signals';
|
||||||
|
import { useSignalAction } from './useSignalAction';
|
||||||
import { buildSuggestedEncounter } from '@/lib/assistant/encounter';
|
import { buildSuggestedEncounter } from '@/lib/assistant/encounter';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
@@ -18,6 +19,8 @@ import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { CampaignInsights } from './CampaignInsights';
|
import { CampaignInsights } from './CampaignInsights';
|
||||||
|
import { SessionPrepCard } from './SessionPrepCard';
|
||||||
|
import { NpcGenCard } from './NpcGenCard';
|
||||||
|
|
||||||
export function AssistantPage() {
|
export function AssistantPage() {
|
||||||
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
|
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
|
||||||
@@ -41,25 +44,11 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
|||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const suggestions = useMemo<Suggestion[]>(
|
const suggestions = useMemo<Suggestion[]>(
|
||||||
() => [...combatSuggestions(activeEnc), ...resourceSuggestions(characters), ...planningSuggestions(notes, quests)],
|
() => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }),
|
||||||
[activeEnc, characters, notes, quests],
|
[activeEnc, characters, notes, quests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runAction = async (a: SuggestAction) => {
|
const runAction = useSignalAction(campaign.id, setMsg);
|
||||||
if (a.type === 'goto') { void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to }); return; }
|
|
||||||
if (a.type === 'longRest') {
|
|
||||||
const c = await charactersRepo.get(a.characterId);
|
|
||||||
if (!c) return;
|
|
||||||
const opt = getSystem(c.system).restOptions.find((o) => o.restoresHp);
|
|
||||||
if (opt) await charactersRepo.update(c.id, applyRest(c, opt));
|
|
||||||
setMsg('Applied a long rest.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (a.type === 'createNote') {
|
|
||||||
await notesRepo.create(campaign.id, a.title);
|
|
||||||
void navigate({ to: '/notes' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildEncounter = async (difficulty: string) => {
|
const buildEncounter = async (difficulty: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -93,6 +82,8 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
|||||||
await encountersRepo.save(enc);
|
await encountersRepo.save(enc);
|
||||||
setActiveEncounter(enc.id);
|
setActiveEncounter(enc.id);
|
||||||
void navigate({ to: '/combat' });
|
void navigate({ to: '/combat' });
|
||||||
|
} catch {
|
||||||
|
setMsg('Could not build an encounter — the monster data failed to load. Try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -107,7 +98,7 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
|||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Encounter builder</h2>
|
<h2 className="mb-2 smallcaps">Encounter builder</h2>
|
||||||
<div className="rounded-lg border border-line bg-panel p-4">
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
<p className="mb-3 text-sm text-muted">Build a balanced encounter for your party and jump into combat.</p>
|
<p className="mb-3 text-sm text-muted">Build a balanced encounter for your party and jump into combat.</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -117,18 +108,24 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Combat</h2>
|
<h2 className="mb-2 mt-6 smallcaps">Combat</h2>
|
||||||
<SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
|
<SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Party resources</h2>
|
<h2 className="mb-2 smallcaps">Party resources</h2>
|
||||||
<SuggestionList items={byCat('resources')} onAction={runAction} empty="The party looks ready." />
|
<SuggestionList items={byCat('resources')} onAction={runAction} empty="The party looks ready." />
|
||||||
|
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2>
|
<h2 className="mb-2 mt-6 smallcaps">Session prep</h2>
|
||||||
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
|
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
|
||||||
|
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2>
|
<h2 className="mb-2 mt-6 smallcaps">Session hooks</h2>
|
||||||
|
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} />
|
||||||
|
|
||||||
|
<h2 className="mb-2 mt-6 smallcaps">Quick NPC</h2>
|
||||||
|
<NpcGenCard campaign={campaign} />
|
||||||
|
|
||||||
|
<h2 className="mb-2 mt-6 smallcaps">Campaign insights</h2>
|
||||||
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
|
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Brain } from 'lucide-react';
|
||||||
import type { Campaign, Encounter } from '@/lib/schemas';
|
import type { Campaign, Encounter } from '@/lib/schemas';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
@@ -22,7 +23,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
|
|||||||
data-testid="encounter-tip"
|
data-testid="encounter-tip"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="text-lg" aria-hidden>🧠</span>
|
<Brain size={18} className="shrink-0 text-muted" aria-hidden />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{theme ? (
|
{theme ? (
|
||||||
<>
|
<>
|
||||||
@@ -45,7 +46,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
|
|||||||
{state === 'ready' && suggestion && (
|
{state === 'ready' && suggestion && (
|
||||||
<div className="mt-2 rounded-md border border-line bg-surface p-2">
|
<div className="mt-2 rounded-md border border-line bg-surface p-2">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
|
<span className="smallcaps">
|
||||||
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
|
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted">→ {suggestion.targetDifficulty}</span>
|
<span className="text-xs text-muted">→ {suggestion.targetDifficulty}</span>
|
||||||
@@ -53,11 +54,20 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
|
|||||||
<p className="mb-2 text-sm text-ink">{suggestion.reasoning}</p>
|
<p className="mb-2 text-sm text-ink">{suggestion.reasoning}</p>
|
||||||
<ul className="mb-2 text-sm text-ink">
|
<ul className="mb-2 text-sm text-ink">
|
||||||
{suggestion.add.map((a) => (
|
{suggestion.add.map((a) => (
|
||||||
<li key={a.name}>• {a.count}× {a.name}</li>
|
<li key={`add-${a.name}`}>• Add {a.count}× {a.name}</li>
|
||||||
|
))}
|
||||||
|
{(suggestion.remove ?? []).map((r) => (
|
||||||
|
<li key={`remove-${r.name}`}>• Remove {r.count}× {r.name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="primary" onClick={apply}>Add to encounter</Button>
|
{(suggestion.add.length > 0 || (suggestion.remove?.length ?? 0) > 0) && (
|
||||||
|
<Button size="sm" variant="primary" onClick={apply}>
|
||||||
|
{suggestion.add.length === 0 && (suggestion.remove?.length ?? 0) > 0
|
||||||
|
? 'Apply to encounter'
|
||||||
|
: 'Add to encounter'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button size="sm" variant="ghost" onClick={run}>Regenerate</Button>
|
<Button size="sm" variant="ghost" onClick={run}>Regenerate</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import type { Campaign } from '@/lib/schemas';
|
||||||
|
import { npcsRepo } from '@/lib/db/repositories';
|
||||||
|
import { complete } from '@/lib/llm/client';
|
||||||
|
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||||
|
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
|
||||||
|
import { getSystem } from '@/lib/rules';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
campaign: Campaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NpcGenCard({ campaign }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||||
|
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||||
|
const canUseLlm = llmEnabled && hasKey;
|
||||||
|
|
||||||
|
const [hint, setHint] = useState('');
|
||||||
|
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||||
|
const [npc, setNpc] = useState<NpcQuick | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
|
||||||
|
systemLabel: getSystem(campaign.system).label,
|
||||||
|
questsSummary: 'No active quests.',
|
||||||
|
notesSummary: 'No notes yet.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setState('loading');
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
if (canUseLlm) {
|
||||||
|
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
|
||||||
|
const result = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
|
||||||
|
if (result.ok && 'data' in result) {
|
||||||
|
setNpc(result.data);
|
||||||
|
setState('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template NPC instead.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNpc(fallbackNpc());
|
||||||
|
setState('ready');
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToCampaign = async () => {
|
||||||
|
if (!npc) return;
|
||||||
|
const created = await npcsRepo.create(campaign.id, npc.name);
|
||||||
|
await npcsRepo.update(created.id, {
|
||||||
|
role: npc.role,
|
||||||
|
description: `Motivation: ${npc.motivation}\n\nSecret: ${npc.secret}\n\nIntro hook: ${npc.hook}`,
|
||||||
|
});
|
||||||
|
void navigate({ to: '/npcs' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
|
<p className="mb-3 text-sm text-muted">
|
||||||
|
Generate a ready-to-use NPC with motivation, secret, and an intro hook for the table.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={hint}
|
||||||
|
onChange={(e) => setHint(e.target.value)}
|
||||||
|
placeholder={`Optional: "a nervous blacksmith" or "a corrupt city guard"`}
|
||||||
|
className="flex-1 text-sm"
|
||||||
|
aria-label="NPC hint"
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
|
||||||
|
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Generate (AI)' : 'Generate'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
|
||||||
|
|
||||||
|
{state === 'ready' && npc && (
|
||||||
|
<div className="mt-4 rounded-md border border-line bg-surface-2 p-3 text-sm">
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-display text-lg font-semibold text-ink">{npc.name}</span>
|
||||||
|
<span className="ml-2 text-xs text-muted">{npc.role}</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => void addToCampaign()}>
|
||||||
|
Add to campaign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<dl className="space-y-1.5 text-xs">
|
||||||
|
<NpcField label="Motivation" value={npc.motivation} />
|
||||||
|
<NpcField label="Secret" value={npc.secret} />
|
||||||
|
<NpcField label="Intro hook" value={npc.hook} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NpcField({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="inline font-semibold text-ink">{label}: </dt>
|
||||||
|
<dd className="inline text-muted">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Copy, Check } from 'lucide-react';
|
||||||
|
import type { Campaign, Character, Note, Quest } from '@/lib/schemas';
|
||||||
|
import { complete } from '@/lib/llm/client';
|
||||||
|
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||||
|
import { buildCampaignContext } from '@/lib/assistant/context';
|
||||||
|
import { buildSessionHookPrompt, sessionHookSchema, fallbackSessionHooks, type SessionHook } from '@/lib/assistant/session';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
campaign: Campaign;
|
||||||
|
characters: Character[];
|
||||||
|
notes: Note[];
|
||||||
|
quests: Quest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionPrepCard({ campaign, characters, notes, quests }: Props) {
|
||||||
|
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||||
|
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||||
|
const canUseLlm = llmEnabled && hasKey;
|
||||||
|
|
||||||
|
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||||
|
const [hooks, setHooks] = useState<SessionHook[]>([]);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setState('loading');
|
||||||
|
setMessage(null);
|
||||||
|
const ctx = buildCampaignContext({ campaign, characters, encounters: [], quests, notes });
|
||||||
|
|
||||||
|
if (canUseLlm) {
|
||||||
|
const { system, user } = buildSessionHookPrompt(ctx);
|
||||||
|
const result = await complete<{ hooks: SessionHook[] }>(getLlmConfig(), { system, user, schema: sessionHookSchema });
|
||||||
|
if (result.ok && 'data' in result) {
|
||||||
|
setHooks(result.data.hooks);
|
||||||
|
setState('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template hooks instead.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHooks(fallbackSessionHooks(ctx));
|
||||||
|
setState('ready');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
|
<p className="mb-3 text-sm text-muted">
|
||||||
|
Generate three distinct session opening hooks grounded in your campaign's current threads.
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
|
||||||
|
{state === 'loading' ? 'Thinking…' : state === 'ready' ? 'Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
|
||||||
|
|
||||||
|
{state === 'ready' && hooks.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{hooks.map((h, i) => (
|
||||||
|
<HookCard key={i} hook={h} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HookCard({ hook }: { hook: SessionHook }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = () => {
|
||||||
|
void navigator.clipboard?.writeText(`${hook.title}\n\n${hook.opening}\n\n${hook.tension}`).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-line bg-surface-2 p-3 text-sm">
|
||||||
|
<div className="mb-1 flex items-start justify-between gap-2">
|
||||||
|
<span className="font-display font-semibold text-ink">{hook.title}</span>
|
||||||
|
<button onClick={copy} title="Copy hook" className="shrink-0 text-muted hover:text-ink">
|
||||||
|
{copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted">{hook.opening}</p>
|
||||||
|
<p className="mt-1.5 text-xs italic text-faint">{hook.tension}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Bell, X } from 'lucide-react';
|
||||||
|
import type { Campaign } from '@/lib/schemas';
|
||||||
|
import { buildSignals } from '@/lib/assistant/signals';
|
||||||
|
import type { Suggestion } from '@/lib/assistant/advisors';
|
||||||
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||||
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
|
import { useNotes, useQuests } from '@/features/world/hooks';
|
||||||
|
import { useEncounters } from '@/features/combat/hooks';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { useSignalAction } from './useSignalAction';
|
||||||
|
|
||||||
|
const DOT: Record<Suggestion['severity'], string> = {
|
||||||
|
danger: 'bg-danger',
|
||||||
|
warn: 'bg-warning',
|
||||||
|
info: 'bg-info',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambient, app-wide "what needs my attention" surface. Watches the active
|
||||||
|
* campaign's state and surfaces one-click signals from anywhere — the proactive
|
||||||
|
* core of the assistant. Renders nothing without an active campaign.
|
||||||
|
*/
|
||||||
|
export function SignalsBell() {
|
||||||
|
const campaign = useActiveCampaign();
|
||||||
|
if (!campaign) return null;
|
||||||
|
return <SignalsBellInner campaign={campaign} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalsBellInner({ campaign }: { campaign: Campaign }) {
|
||||||
|
const characters = useCharacters(campaign.id);
|
||||||
|
const notes = useNotes(campaign.id);
|
||||||
|
const quests = useQuests(campaign.id);
|
||||||
|
const encounters = useEncounters(campaign.id);
|
||||||
|
const activeEnc = encounters.find((e) => e.status === 'active');
|
||||||
|
|
||||||
|
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const runAction = useSignalAction(campaign.id, setMsg);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const signals = useMemo(
|
||||||
|
() => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }).filter((s) => !dismissed.has(s.id)),
|
||||||
|
[activeEnc, characters, notes, quests, dismissed],
|
||||||
|
);
|
||||||
|
// The badge counts only things that genuinely want attention.
|
||||||
|
const attention = signals.filter((s) => s.severity !== 'info').length;
|
||||||
|
|
||||||
|
// Close the popover on an outside click or Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-label={`Signals${attention > 0 ? ` (${attention} need attention)` : ''}`}
|
||||||
|
title="Signals — things that may need your attention"
|
||||||
|
className="relative grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink"
|
||||||
|
>
|
||||||
|
<Bell size={18} aria-hidden />
|
||||||
|
{attention > 0 && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 grid min-w-[16px] place-items-center rounded-full bg-danger px-1 text-[10px] font-semibold leading-4 text-white">
|
||||||
|
{attention}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-11 z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border border-line bg-panel p-2 shadow-xl">
|
||||||
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
|
<span className="smallcaps">Signals</span>
|
||||||
|
{signals.length > 0 && (
|
||||||
|
<button className="text-xs text-faint hover:text-ink" onClick={() => setDismissed(new Set(signals.map((s) => s.id)))}>Clear all</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg && <p className="px-2 pb-1 text-xs text-success" aria-live="polite">{msg}</p>}
|
||||||
|
{signals.length === 0 ? (
|
||||||
|
<p className="px-2 py-3 text-sm text-muted">Nothing needs your attention.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-[60vh] space-y-1 overflow-y-auto">
|
||||||
|
{signals.map((s) => (
|
||||||
|
<li key={s.id} className="flex items-start gap-2 rounded-lg border border-line/70 bg-surface p-2">
|
||||||
|
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', DOT[s.severity])} aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-ink">{s.title}</div>
|
||||||
|
{s.detail && <div className="text-xs text-muted">{s.detail}</div>}
|
||||||
|
{s.action && (
|
||||||
|
<Button size="sm" variant="secondary" className="mt-1.5" onClick={() => { void runAction(s.action!); if (s.action!.type !== 'goto') setMsg(null); setOpen(s.action!.type !== 'goto'); }}>
|
||||||
|
{s.action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="text-faint hover:text-ink" aria-label="Dismiss" onClick={() => setDismissed((d) => new Set(d).add(s.id))}>
|
||||||
|
<X size={13} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { encountersRepo } from '@/lib/db/repositories';
|
|||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { addCombatant } from '@/lib/combat/engine';
|
import { addCombatant, removeCombatant } from '@/lib/combat/engine';
|
||||||
import { computeBudget } from '@/lib/combat/budget';
|
import { computeBudget } from '@/lib/combat/budget';
|
||||||
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||||
import { complete } from '@/lib/llm/client';
|
import { complete } from '@/lib/llm/client';
|
||||||
@@ -108,6 +108,11 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
|||||||
const existing: ReinforceMonster[] = monsterCombatants.map((c) => ({ name: c.name, cr: c.cr, level: c.level }));
|
const existing: ReinforceMonster[] = monsterCombatants.map((c) => ({ name: c.name, cr: c.cr, level: c.level }));
|
||||||
const current = computeBudget(campaign.system, partyLevels, existing.map((m) => ({ cr: m.cr, level: m.level }))).difficulty;
|
const current = computeBudget(campaign.system, partyLevels, existing.map((m) => ({ cr: m.cr, level: m.level }))).difficulty;
|
||||||
const target = nextTarget(campaign.system, current);
|
const target = nextTarget(campaign.system, current);
|
||||||
|
// When the fight is already at (or above) the hardest tier, "add creatures to
|
||||||
|
// reach <target>" is a contradiction — it makes reasoning models spiral and burn
|
||||||
|
// their whole token budget. Only ask the AI when the target is genuinely harder;
|
||||||
|
// otherwise fall through to the deterministic path (which eases an over-tuned fight).
|
||||||
|
const targetIsHarder = (ORDINAL[target.toLowerCase()] ?? 99) > (ORDINAL[current.toLowerCase()] ?? -1);
|
||||||
|
|
||||||
// Candidates the AI may choose from: the creatures already in the fight
|
// Candidates the AI may choose from: the creatures already in the fight
|
||||||
// (so it can say "add another goblin") plus level-appropriate bestiary picks.
|
// (so it can say "add another goblin") plus level-appropriate bestiary picks.
|
||||||
@@ -125,9 +130,9 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
|||||||
const candidates = [...existingCands, ...poolCands];
|
const candidates = [...existingCands, ...poolCands];
|
||||||
if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; }
|
if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; }
|
||||||
|
|
||||||
if (canUseLlm) {
|
if (canUseLlm && targetIsHarder) {
|
||||||
const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates });
|
const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates });
|
||||||
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
|
const res = await complete<BalanceSuggestion>(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
|
||||||
if (res.ok && 'data' in res) {
|
if (res.ok && 'data' in res) {
|
||||||
const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name));
|
const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name));
|
||||||
if (valid.length) {
|
if (valid.length) {
|
||||||
@@ -137,7 +142,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (!res.ok) {
|
} else if (!res.ok) {
|
||||||
setMessage(`AI unavailable (${res.error}); showing a deterministic pick.`);
|
setMessage(`AI unavailable: ${res.message} Showing a deterministic pick.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +167,16 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
|||||||
if (!suggestion) return;
|
if (!suggestion) return;
|
||||||
await encountersRepo.mutate(encounter.id, (e) => {
|
await encountersRepo.mutate(encounter.id, (e) => {
|
||||||
let next = e;
|
let next = e;
|
||||||
|
// Over-tuned fights: take out the suggested monsters (newest copies first).
|
||||||
|
for (const r of suggestion.remove ?? []) {
|
||||||
|
for (let i = 0; i < r.count; i++) {
|
||||||
|
const victim = [...next.combatants].reverse().find(
|
||||||
|
(c) => c.kind === 'monster' && baseName(c.name) === r.name,
|
||||||
|
);
|
||||||
|
if (!victim) break;
|
||||||
|
next = removeCombatant(next, victim.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const a of suggestion.add) {
|
for (const a of suggestion.add) {
|
||||||
// Prefer cloning a creature already in the fight (handles "add another goblin"
|
// Prefer cloning a creature already in the fight (handles "add another goblin"
|
||||||
// and works even for custom/homebrew combatants); otherwise pull from the pool.
|
// and works even for custom/homebrew combatants); otherwise pull from the pool.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function useLevelUpAdvisor(campaign: Campaign, character: Character) {
|
|||||||
setState('ready');
|
setState('ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.ok) setMessage(`AI unavailable (${res.error}); showing general routes.`);
|
if (!res.ok) setMessage(`AI unavailable: ${res.message} Showing general routes.`);
|
||||||
}
|
}
|
||||||
setRoutes(deterministicRoutes(campaign.system, character.className));
|
setRoutes(deterministicRoutes(campaign.system, character.className));
|
||||||
setSource('deterministic');
|
setSource('deterministic');
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { charactersRepo, notesRepo, questsRepo } from '@/lib/db/repositories';
|
||||||
|
import { getSystem, applyRest } from '@/lib/rules';
|
||||||
|
import type { SuggestAction } from '@/lib/assistant/advisors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One place that turns a signal's `SuggestAction` into a real mutation/navigation,
|
||||||
|
* shared by the ambient signals bell and the Assistant page so behaviour can't
|
||||||
|
* drift. `onDone` receives a short confirmation message for an aria-live region.
|
||||||
|
*/
|
||||||
|
export function useSignalAction(campaignId: string, onDone?: (msg: string) => void) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return async (a: SuggestAction): Promise<void> => {
|
||||||
|
switch (a.type) {
|
||||||
|
case 'goto':
|
||||||
|
void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to });
|
||||||
|
return;
|
||||||
|
case 'longRest':
|
||||||
|
case 'shortRest': {
|
||||||
|
const c = await charactersRepo.get(a.characterId);
|
||||||
|
if (!c) return;
|
||||||
|
const opts = getSystem(c.system).restOptions;
|
||||||
|
const opt = a.type === 'longRest'
|
||||||
|
? opts.find((o) => o.restoresHp) ?? opts.at(-1)
|
||||||
|
: opts.find((o) => !o.restoresHp) ?? opts[0];
|
||||||
|
if (opt) await charactersRepo.update(c.id, applyRest(c, opt));
|
||||||
|
onDone?.(`Applied ${opt?.label ?? 'a rest'} for ${c.name}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'completeQuest':
|
||||||
|
await questsRepo.update(a.questId, { status: 'completed' });
|
||||||
|
onDone?.('Quest marked complete.');
|
||||||
|
return;
|
||||||
|
case 'createNote':
|
||||||
|
await notesRepo.create(campaignId, a.title);
|
||||||
|
void navigate({ to: '/notes' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { ArrowRight, BookOpenText, CalendarDays, ScrollText } from 'lucide-react';
|
import { ArrowRight, BookOpenText, CalendarDays, Map, RadioTower, ScrollText, Users } from 'lucide-react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { campaignsRepo } from '@/lib/db/repositories';
|
import { campaignsRepo } from '@/lib/db/repositories';
|
||||||
import { seedSampleCampaign } from '@/lib/sample';
|
import { seedSampleCampaign } from '@/lib/sample';
|
||||||
@@ -70,9 +70,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
|
|||||||
</p>
|
</p>
|
||||||
<hr className="gilt-rule mx-auto mt-5 max-w-xs" />
|
<hr className="gilt-rule mx-auto mt-5 max-w-xs" />
|
||||||
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3">
|
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3">
|
||||||
<FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." />
|
<FeatCard icon={<Users size={20} />} title="Build characters" desc="A friendly, data-driven builder for new players." />
|
||||||
<FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
|
<FeatCard icon={<Map size={20} />} title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
|
||||||
<FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." />
|
<FeatCard icon={<RadioTower size={20} />} title="Play live" desc="Host a room; players manage their own character." />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||||
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
|
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
|
||||||
@@ -83,10 +83,10 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
|
function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
|
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
|
||||||
<div className="text-xl" aria-hidden>{icon}</div>
|
<div className="text-accent" aria-hidden>{icon}</div>
|
||||||
<div className="mt-1 font-display font-semibold text-ink">{title}</div>
|
<div className="mt-1 font-display font-semibold text-ink">{title}</div>
|
||||||
<div className="text-xs text-muted">{desc}</div>
|
<div className="text-xs text-muted">{desc}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,12 +258,10 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
|
|||||||
placeholder="Curse of Strahd"
|
placeholder="Curse of Strahd"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{/* System is fixed once a campaign exists (content + characters depend on it). */}
|
||||||
|
{!campaign && (
|
||||||
<Field label="System">
|
<Field label="System">
|
||||||
<Select
|
<Select value={system} onChange={(e) => setSystem(e.target.value as typeof system)}>
|
||||||
value={system}
|
|
||||||
disabled={!!campaign}
|
|
||||||
onChange={(e) => setSystem(e.target.value as typeof system)}
|
|
||||||
>
|
|
||||||
{SYSTEM_OPTIONS.map((o) => (
|
{SYSTEM_OPTIONS.map((o) => (
|
||||||
<option key={o.id} value={o.id}>
|
<option key={o.id} value={o.id}>
|
||||||
{o.label}
|
{o.label}
|
||||||
@@ -271,6 +269,7 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
<Field label="Description">
|
<Field label="Description">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={description}
|
value={description}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||||
|
import { allArmorMechanics5e, type ArmorMechanics } from '@/lib/mechanics';
|
||||||
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
|
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
|
||||||
import { encodeClaim } from '@/lib/sync/playerLink';
|
import { encodeClaim } from '@/lib/sync/playerLink';
|
||||||
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
||||||
@@ -13,6 +14,7 @@ import { rollCheck } from '@/lib/useRoll';
|
|||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { Page } from '@/components/ui/Page';
|
import { Page } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Badge, Meter } from '@/components/ui/Codex';
|
import { Badge, Meter } from '@/components/ui/Codex';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
@@ -22,6 +24,7 @@ import { AttacksSection } from './sheet/AttacksSection';
|
|||||||
import { ResourcesSection } from './sheet/ResourcesSection';
|
import { ResourcesSection } from './sheet/ResourcesSection';
|
||||||
import { SpellcastingSection } from './sheet/SpellcastingSection';
|
import { SpellcastingSection } from './sheet/SpellcastingSection';
|
||||||
import { DefensesSection } from './sheet/DefensesSection';
|
import { DefensesSection } from './sheet/DefensesSection';
|
||||||
|
import { FeatsSection } from './sheet/FeatsSection';
|
||||||
import { AbilityGenModal } from './sheet/AbilityGenModal';
|
import { AbilityGenModal } from './sheet/AbilityGenModal';
|
||||||
import { LevelUpModal } from './sheet/LevelUpModal';
|
import { LevelUpModal } from './sheet/LevelUpModal';
|
||||||
|
|
||||||
@@ -43,6 +46,9 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
const [levelUp, setLevelUp] = useState(false);
|
const [levelUp, setLevelUp] = useState(false);
|
||||||
const [genScores, setGenScores] = useState(false);
|
const [genScores, setGenScores] = useState(false);
|
||||||
const [shared, setShared] = useState(false);
|
const [shared, setShared] = useState(false);
|
||||||
|
// Manual fallback: holds the link to show in a selectable dialog when neither
|
||||||
|
// Web Share nor Clipboard is available (e.g. iPad without a secure context).
|
||||||
|
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||||
|
|
||||||
const onPortrait = async (file: File) => {
|
const onPortrait = async (file: File) => {
|
||||||
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
|
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
|
||||||
@@ -52,9 +58,27 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
const shareWithPlayer = async () => {
|
const shareWithPlayer = async () => {
|
||||||
const campaign = await campaignsRepo.get(c.campaignId);
|
const campaign = await campaignsRepo.get(c.campaignId);
|
||||||
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
|
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
|
||||||
try { await navigator.clipboard?.writeText(link); } catch { /* clipboard blocked */ }
|
// Prefer the native share sheet (best on iOS/iPadOS), then the clipboard,
|
||||||
|
// then a selectable dialog so the link is always recoverable.
|
||||||
|
if (typeof navigator.share === 'function') {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title: `${c.name} — character link`, url: link });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// User dismissed the share sheet: respect that and do nothing further.
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
|
// Otherwise fall through to clipboard / manual fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
setShared(true);
|
setShared(true);
|
||||||
setTimeout(() => setShared(false), 1500);
|
setTimeout(() => setShared(false), 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* clipboard blocked (e.g. insecure context on iPad) */ }
|
||||||
|
setShareLink(link);
|
||||||
};
|
};
|
||||||
const save = useDebouncedCallback((next: Character) => {
|
const save = useDebouncedCallback((next: Character) => {
|
||||||
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
||||||
@@ -77,6 +101,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
|
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
|
||||||
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
|
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
|
||||||
armorBonus: c.armorBonus,
|
armorBonus: c.armorBonus,
|
||||||
|
...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}),
|
||||||
perceptionRank: c.perceptionRank,
|
perceptionRank: c.perceptionRank,
|
||||||
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
|
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
|
||||||
};
|
};
|
||||||
@@ -110,7 +135,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
|
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
|
||||||
<Input
|
<Input
|
||||||
className="mt-0.5 max-w-md border-transparent bg-transparent px-0 font-display text-3xl font-semibold leading-tight tracking-tight focus-visible:border-line"
|
className="mt-0.5 w-full max-w-md truncate border-transparent bg-transparent px-0 font-display text-2xl font-semibold leading-tight tracking-tight focus-visible:border-line sm:text-3xl"
|
||||||
value={c.name}
|
value={c.name}
|
||||||
aria-label="Character name"
|
aria-label="Character name"
|
||||||
onChange={(e) => update({ name: e.target.value })}
|
onChange={(e) => update({ name: e.target.value })}
|
||||||
@@ -124,13 +149,13 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex w-full items-center gap-2 sm:w-auto">
|
||||||
{[
|
{[
|
||||||
{ icon: Shield, value: ac, label: 'Armor' },
|
{ icon: Shield, value: ac, label: 'Armor' },
|
||||||
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' },
|
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' },
|
||||||
{ icon: Footprints, value: c.speed, label: 'Speed' },
|
{ icon: Footprints, value: c.speed, label: 'Speed' },
|
||||||
].map(({ icon: Ic, value, label }) => (
|
].map(({ icon: Ic, value, label }) => (
|
||||||
<div key={label} className="flex w-[4.5rem] flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center">
|
<div key={label} className="flex flex-1 flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center sm:w-[4.5rem] sm:flex-none">
|
||||||
<Ic className="h-4 w-4 text-accent-deep" aria-hidden />
|
<Ic className="h-4 w-4 text-accent-deep" aria-hidden />
|
||||||
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
|
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
|
||||||
<span className="smallcaps text-[8.5px]">{label}</span>
|
<span className="smallcaps text-[8.5px]">{label}</span>
|
||||||
@@ -139,7 +164,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
|
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? <><Check size={12} aria-hidden /> Link copied</> : 'Share with player'}</Button>}
|
||||||
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
|
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
|
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,11 +187,14 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
|
|
||||||
{/* Vital stats */}
|
{/* Vital stats */}
|
||||||
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||||
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
|
<StatCard label="Armor Class" value={ac} hint={c.equippedArmor ? `${c.equippedArmor.name}${c.armorBonus ? ` + ${c.armorBonus}` : ''}` : `10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
|
||||||
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted">
|
<div className="mt-2 flex flex-col items-center gap-2 text-xs text-muted">
|
||||||
<span>Armor/shield bonus</span>
|
{c.system === '5e' && <ArmorPicker c={c} update={update} />}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Shield/misc bonus</span>
|
||||||
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
|
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
|
|
||||||
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
|
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
|
||||||
@@ -201,6 +229,23 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */}
|
||||||
|
{c.system === 'pf2e' && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<SectionTitle>Perception</SectionTitle>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<RankRow
|
||||||
|
label="Perception (WIS)"
|
||||||
|
modifier={sys.passivePerception(rulesInput) - 10}
|
||||||
|
rank={c.perceptionRank}
|
||||||
|
ranks={ranks}
|
||||||
|
onRank={(rank) => update({ perceptionRank: rank })}
|
||||||
|
system={c.system}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Saves */}
|
{/* Saves */}
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<SectionTitle>Saving Throws</SectionTitle>
|
<SectionTitle>Saving Throws</SectionTitle>
|
||||||
@@ -261,6 +306,9 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
{/* Class resources + rest */}
|
{/* Class resources + rest */}
|
||||||
<ResourcesSection c={c} update={update} />
|
<ResourcesSection c={c} update={update} />
|
||||||
|
|
||||||
|
{/* Feats & features */}
|
||||||
|
<FeatsSection c={c} update={update} />
|
||||||
|
|
||||||
{/* Inventory, currency, encumbrance */}
|
{/* Inventory, currency, encumbrance */}
|
||||||
<InventorySection c={c} update={update} />
|
<InventorySection c={c} update={update} />
|
||||||
|
|
||||||
@@ -281,6 +329,24 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
{levelUp && (
|
{levelUp && (
|
||||||
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
|
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
|
||||||
)}
|
)}
|
||||||
|
{shareLink !== null && (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => setShareLink(null)}
|
||||||
|
title="Share with player"
|
||||||
|
footer={<Button size="sm" variant="ghost" onClick={() => setShareLink(null)}>Done</Button>}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-sm text-muted">Copy this link and send it to the player:</p>
|
||||||
|
<Input
|
||||||
|
data-autofocus
|
||||||
|
readOnly
|
||||||
|
value={shareLink}
|
||||||
|
aria-label="Player link"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -388,6 +454,28 @@ function StatCard({ label, value, hint, children }: { label: string; value: stri
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ArmorPicker({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
|
||||||
|
const [armors, setArmors] = useState<ArmorMechanics[]>([]);
|
||||||
|
useEffect(() => { void allArmorMechanics5e().then(setArmors); }, []);
|
||||||
|
// Body armor only — shields are handled via the misc bonus field.
|
||||||
|
const body = armors.filter((a) => a.category !== 'shield');
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
className="w-auto py-1 text-xs"
|
||||||
|
aria-label="Equipped armor"
|
||||||
|
value={c.equippedArmor?.name ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const name = e.target.value;
|
||||||
|
const found = body.find((a) => a.name === name);
|
||||||
|
update({ equippedArmor: found ? { name: found.name, category: found.category, baseAc: found.baseAc, dexCap: found.dexCap, stealthDisadvantage: found.stealthDisadvantage } : null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Unarmored</option>
|
||||||
|
{body.map((a) => <option key={a.name} value={a.name}>{a.name} (AC {a.baseAc}{a.dexCap === 0 ? '' : a.dexCap === null ? ' + Dex' : ` + Dex≤${a.dexCap}`})</option>)}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
|
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Campaign, Character } from '@/lib/schemas';
|
|||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
|
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
|
||||||
import { pickTextFile } from '@/lib/io/file';
|
import { pickTextFile } from '@/lib/io/file';
|
||||||
import { useCharacters } from './hooks';
|
import { useCharacters, useAllPcs } from './hooks';
|
||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Avatar, Badge } from '@/components/ui/Codex';
|
import { Avatar, Badge } from '@/components/ui/Codex';
|
||||||
@@ -18,11 +18,11 @@ export function CharactersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CharactersList({ campaign }: { campaign: Campaign }) {
|
function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||||
const characters = useCharacters(campaign.id);
|
// PCs are campaign-agnostic — show every player character; NPCs stay per-campaign.
|
||||||
|
const pcs = useAllPcs();
|
||||||
|
const npcs = useCharacters(campaign.id).filter((c) => c.kind === 'npc');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
|
||||||
const npcs = characters.filter((c) => c.kind === 'npc');
|
|
||||||
|
|
||||||
const importCharacter = async () => {
|
const importCharacter = async () => {
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
@@ -60,7 +60,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{characters.length === 0 ? (
|
{pcs.length === 0 && npcs.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No characters yet"
|
title="No characters yet"
|
||||||
hint="Add player characters and NPCs, or import a character file."
|
hint="Add player characters and NPCs, or import a character file."
|
||||||
@@ -98,7 +98,7 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
|
|||||||
|
|
||||||
function CharacterCard({ c }: { c: Character }) {
|
function CharacterCard({ c }: { c: Character }) {
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
|
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="paper-grain flex flex-col rounded-xl border border-line bg-panel transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-accent/50 hover:shadow-[0_8px_24px_-12px_var(--app-accent-glow)]">
|
<div className="paper-grain flex flex-col rounded-xl border border-line bg-panel transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-accent/50 hover:shadow-[0_8px_24px_-12px_var(--app-accent-glow)]">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
|
import { Lightbulb, RotateCcw, Star } from 'lucide-react';
|
||||||
|
import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas';
|
||||||
import { charactersRepo } from '@/lib/db/repositories';
|
import { charactersRepo } from '@/lib/db/repositories';
|
||||||
import {
|
import {
|
||||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter,
|
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
||||||
type AbilityKey, type AbilityScores, type ProficiencyRank,
|
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
||||||
} from '@/lib/rules';
|
} from '@/lib/rules';
|
||||||
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
||||||
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
|
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
|
||||||
@@ -12,8 +13,12 @@ import type { RulesetClass } from '@/lib/ruleset/normalize';
|
|||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { formatModifier } from '@/lib/format';
|
import { formatModifier } from '@/lib/format';
|
||||||
|
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
|
||||||
|
import { briefOverview, dedupeByName } from './overview';
|
||||||
|
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Codex';
|
||||||
import { Field, Input, Select } from '@/components/ui/Input';
|
import { Field, Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
@@ -33,22 +38,25 @@ function playstyle(c: RulesetClass): string {
|
|||||||
|
|
||||||
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = {
|
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = {
|
||||||
'5e': [
|
'5e': [
|
||||||
{ label: '🛡️ Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
|
{ label: 'Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
|
||||||
{ label: '🔥 Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
|
{ label: 'Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
|
||||||
{ label: '🗡️ Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
|
{ label: 'Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
|
||||||
{ label: '✨ Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
|
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
|
||||||
],
|
],
|
||||||
pf2e: [
|
pf2e: [
|
||||||
{ label: '🛡️ Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
|
{ label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
|
||||||
{ label: '🔥 Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
|
{ label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
|
||||||
{ label: '🗡️ Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
|
{ label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
|
||||||
{ label: '✨ Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
|
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
|
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const sys = getSystem(campaign.system);
|
// Characters are campaign-agnostic: the wizard asks which system to build for,
|
||||||
|
// defaulting to the campaign's. Changing it resets all dependent selections.
|
||||||
|
const [system, setSystem] = useState<SystemId>(campaign.system);
|
||||||
|
const sys = getSystem(system);
|
||||||
const allSkillKeys = sys.skills.map((s) => s.key);
|
const allSkillKeys = sys.skills.map((s) => s.key);
|
||||||
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
|
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
|
||||||
|
|
||||||
@@ -58,18 +66,63 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
|
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
|
||||||
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
|
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
|
||||||
|
|
||||||
useEffect(() => { let on = true; void loadClasses(campaign.system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [campaign.system]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let on = true;
|
let on = true;
|
||||||
if (campaign.system === '5e') {
|
void loadClasses(system).then((c) => {
|
||||||
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed }))));
|
if (!on) return;
|
||||||
|
// The pf2e class data loads with caster:'none' and no subclasses; merge the
|
||||||
|
// curated caster ability (so pf2e spellcasters get the Spells step + "Caster"
|
||||||
|
// tag) and the defining 1st-level choice list (instinct/doctrine/bloodline…).
|
||||||
|
const enriched = system === 'pf2e'
|
||||||
|
? c.map((rc) => {
|
||||||
|
const def = getClassDef('pf2e', rc.name);
|
||||||
|
const subclasses = PF2E_SUBCLASSES[rc.name] ?? rc.subclasses;
|
||||||
|
const caster = def && def.caster !== 'none' && def.spellAbility ? def.spellAbility : rc.caster;
|
||||||
|
return { ...rc, caster, subclasses };
|
||||||
|
})
|
||||||
|
: c;
|
||||||
|
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
});
|
||||||
|
return () => { on = false; };
|
||||||
|
}, [system]);
|
||||||
|
useEffect(() => {
|
||||||
|
let on = true;
|
||||||
|
if (system === '5e') {
|
||||||
|
void loadRaces5e().then((rs) => {
|
||||||
|
if (!on) return;
|
||||||
|
const strip = (s: string) => s.replace(/\*{2,3}[^*]+\*{2,3}\s*/g, '').trim();
|
||||||
|
setOrigins(rs.map((r) => {
|
||||||
|
const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n');
|
||||||
|
const speedFt = /(\d+) feet/.exec(r.speed)?.[1];
|
||||||
|
const asiMatches = [...r.asi.matchAll(/(\w+) score (?:each )?increases? by (\d+)/g)];
|
||||||
|
const asiSummary = asiMatches.map(([, ability, n]) => ability && n ? `+${n} ${ability.slice(0, 3).toUpperCase()}` : '').filter(Boolean).join(', ');
|
||||||
|
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
|
||||||
|
return { name: r.name, desc, meta };
|
||||||
|
}));
|
||||||
|
});
|
||||||
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
|
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
|
||||||
} else {
|
} else {
|
||||||
void loadPf2e('ancestries').then((rs) => on && setOrigins(rs.map((r) => ({ name: String(r.name), desc: String((r.description ?? r.text) ?? ''), ...(typeof r.hp === 'number' ? { hp: r.hp } : {}), ...((r.speed as { land?: number } | undefined)?.land ? { speed: (r.speed as { land: number }).land } : {}) }))));
|
void loadPf2e('ancestries').then((rs) => {
|
||||||
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') }))));
|
if (!on) return;
|
||||||
|
setOrigins(dedupeByName(rs.map((r) => {
|
||||||
|
const hp = typeof r.hp === 'number' ? r.hp : undefined;
|
||||||
|
const speed = (r.speed as { land?: number } | undefined)?.land;
|
||||||
|
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
|
||||||
|
const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/');
|
||||||
|
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
|
||||||
|
return {
|
||||||
|
name: String(r.name),
|
||||||
|
desc: briefOverview(String((r.summary ?? r.text) ?? '')),
|
||||||
|
...(hp !== undefined ? { hp } : {}),
|
||||||
|
...(speed ? { speed } : {}),
|
||||||
|
meta,
|
||||||
|
};
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ name: String(b.name), desc: briefOverview(String((b.description ?? b.text) ?? '')) })))));
|
||||||
}
|
}
|
||||||
return () => { on = false; };
|
return () => { on = false; };
|
||||||
}, [campaign.system]);
|
}, [system]);
|
||||||
|
|
||||||
// ---- selections ----
|
// ---- selections ----
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
@@ -108,7 +161,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
const [skills, setSkills] = useState<string[]>([]);
|
const [skills, setSkills] = useState<string[]>([]);
|
||||||
const intMod = abilityModifier(abilities.int);
|
const intMod = abilityModifier(abilities.int);
|
||||||
const skillCount = selectedClass
|
const skillCount = selectedClass
|
||||||
? selectedClass.skillCount + (campaign.system === 'pf2e' ? Math.max(0, intMod) : 0)
|
? selectedClass.skillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
|
||||||
: 2;
|
: 2;
|
||||||
const skillOptions = useMemo(() => {
|
const skillOptions = useMemo(() => {
|
||||||
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
|
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
|
||||||
@@ -127,10 +180,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCaster || allSpells.length) return;
|
if (!isCaster || allSpells.length) return;
|
||||||
let on = true;
|
let on = true;
|
||||||
if (campaign.system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
|
if (system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
|
||||||
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => ({ name: String(s.name), level: Number(s.level) || 0, meta: Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '' }))));
|
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => ({ name: String(s.name), level: Number(s.level) || 0, meta: Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '' }))));
|
||||||
return () => { on = false; };
|
return () => { on = false; };
|
||||||
}, [isCaster, campaign.system, allSpells.length]);
|
}, [isCaster, system, allSpells.length]);
|
||||||
const spellResults = useMemo(() => {
|
const spellResults = useMemo(() => {
|
||||||
const q = spellQuery.trim().toLowerCase();
|
const q = spellQuery.trim().toLowerCase();
|
||||||
const maxLevel = Math.min(9, Math.ceil(level / 2));
|
const maxLevel = Math.min(9, Math.ceil(level / 2));
|
||||||
@@ -140,15 +193,36 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
|
setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
|
||||||
|
|
||||||
// ---- derived build ----
|
// ---- derived build ----
|
||||||
const built = useMemo(() => buildCharacter(campaign.system, {
|
const built = useMemo(() => buildCharacter(system, {
|
||||||
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
|
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
|
||||||
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
|
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
|
||||||
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
|
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
|
||||||
}), [campaign.system, selectedClass, level, abilities, skills, selectedOrigin]);
|
}), [system, selectedClass, level, abilities, skills, selectedOrigin]);
|
||||||
|
|
||||||
|
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
|
||||||
|
// just guidance: a few cantrips plus a handful of starting leveled spells.
|
||||||
|
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
|
||||||
|
const suggestedSpells = Math.max(4, leveledSlots + 2);
|
||||||
|
|
||||||
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
|
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
|
||||||
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
|
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
|
||||||
|
|
||||||
|
const changeSystem = (next: SystemId) => {
|
||||||
|
if (next === system) return;
|
||||||
|
setSystem(next);
|
||||||
|
// Reset everything keyed on the system so stale picks don't leak across systems.
|
||||||
|
// The class/origin/background/spell lists reload from the loaders above.
|
||||||
|
setClassSlug('');
|
||||||
|
setSubclass('');
|
||||||
|
setSkills([]);
|
||||||
|
setSpellPicks([]);
|
||||||
|
setSpellQuery('');
|
||||||
|
setAllSpells([]); // force the spell loader (guarded on length) to refetch for the new system
|
||||||
|
setAncestry('');
|
||||||
|
setBackground('');
|
||||||
|
setStep(0);
|
||||||
|
};
|
||||||
|
|
||||||
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
|
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
|
||||||
const c = classes.find((x) => x.name === t.className);
|
const c = classes.find((x) => x.name === t.className);
|
||||||
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
|
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
|
||||||
@@ -169,7 +243,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
const finish = async () => {
|
const finish = async () => {
|
||||||
if (!selectedClass) return;
|
if (!selectedClass) return;
|
||||||
const created = await charactersRepo.create(campaign.id, {
|
const created = await charactersRepo.create(campaign.id, {
|
||||||
system: campaign.system, name: name.trim() || selectedClass.name, kind,
|
system, name: name.trim() || selectedClass.name, kind,
|
||||||
ancestry: ancestry.trim(), className: selectedClass.name, level,
|
ancestry: ancestry.trim(), className: selectedClass.name, level,
|
||||||
});
|
});
|
||||||
// For classes outside the curated tables (esp. PF2e), fill saves from data.
|
// For classes outside the curated tables (esp. PF2e), fill saves from data.
|
||||||
@@ -177,7 +251,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
|
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
|
||||||
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
|
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
|
||||||
}
|
}
|
||||||
const spells: SpellEntry[] = spellPicks.map((s) => ({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)), prepared: false, notes: '' }));
|
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
|
||||||
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
|
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
|
||||||
await charactersRepo.update(created.id, {
|
await charactersRepo.update(created.id, {
|
||||||
...built,
|
...built,
|
||||||
@@ -222,17 +296,29 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
<Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
|
<Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 smallcaps">Game system</div>
|
||||||
|
<div className="flex gap-1" role="group" aria-label="Game system">
|
||||||
|
{SYSTEM_OPTIONS.map((opt) => (
|
||||||
|
<button key={opt.id} type="button" onClick={() => changeSystem(opt.id)} aria-pressed={system === opt.id}
|
||||||
|
className={cn('rounded-md px-3 py-1.5 text-sm', system === opt.id ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
|
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">New here? Start from a ready-made hero</div>
|
<div className="mb-1 smallcaps">New here? Start from a ready-made hero</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(TEMPLATES[campaign.system] ?? []).map((t) => (
|
{(TEMPLATES[system] ?? []).map((t) => (
|
||||||
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
|
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Choose a class</div>
|
<div className="mb-1 smallcaps">Choose a class</div>
|
||||||
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }}
|
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }}
|
||||||
@@ -241,30 +327,70 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
<span className="font-display font-semibold text-ink">{c.name}</span>
|
<span className="font-display font-semibold text-ink">{c.name}</span>
|
||||||
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
|
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] text-muted">{campaign.system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
|
<div className="mt-0.5 text-[11px] text-muted">{system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
|
||||||
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{c.description}</p>}
|
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{briefOverview(c.description)}</p>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{classes.length === 0 && <p className="text-sm text-muted">Loading classes…</p>}
|
{classes.length === 0 && <p className="text-sm text-muted">Loading classes…</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedClass && selectedClass.subclasses.length > 0 && (
|
{selectedClass && (() => {
|
||||||
<Field label={campaign.system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
|
const tip = getClassTip(system, selectedClass.slug);
|
||||||
|
if (!tip) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Lightbulb size={15} className="mt-0.5 shrink-0 text-accent" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-ink">{selectedClass.name}</div>
|
||||||
|
<p className="mt-0.5 text-muted">{tip.role}</p>
|
||||||
|
<div className="mt-1.5 text-xs text-muted">
|
||||||
|
<span className="font-medium text-ink">Key abilities:</span> {tip.primaryAbilities}
|
||||||
|
</div>
|
||||||
|
{tip.beginner && <Badge tone="verdigris" className="mt-1.5">Good for beginners</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{selectedClass && selectedClass.subclasses.length > 0 && (() => {
|
||||||
|
const sc = selectedClass.subclasses.find((s) => s.name === subclass);
|
||||||
|
return (
|
||||||
|
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
|
||||||
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
|
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
|
||||||
<option value="">— decide later —</option>
|
<option value="">— decide later —</option>
|
||||||
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
|
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
{sc?.desc && (
|
||||||
|
<p className="mt-1 max-h-28 overflow-y-auto rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">
|
||||||
|
{sc.desc}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stepName === 'Origin' && (
|
{stepName === 'Origin' && (
|
||||||
|
<div className="space-y-3">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<OriginPicker title={campaign.system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||||
</div>
|
</div>
|
||||||
|
{selectedClass && selectedOrigin && (() => {
|
||||||
|
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
|
||||||
|
if (!synergy) return null;
|
||||||
|
return (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-accent">
|
||||||
|
<Lightbulb size={12} className="shrink-0" aria-hidden />
|
||||||
|
{synergy}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stepName === 'Abilities' && (
|
{stepName === 'Abilities' && (
|
||||||
@@ -274,9 +400,20 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
|
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}>↻ Reroll</Button>}
|
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}><RotateCcw size={13} aria-hidden /> Reroll</Button>}
|
||||||
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
|
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
|
||||||
{selectedClass && selectedClass.keyAbilities.length > 0 && <p className="mb-3 text-xs text-muted">Tip: prioritise <span className="text-ink">{selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')}</span> for a {selectedClass.name}.</p>}
|
{selectedClass && (() => {
|
||||||
|
const tip = getClassTip(system, selectedClass.slug);
|
||||||
|
const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
|
||||||
|
? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.`
|
||||||
|
: null);
|
||||||
|
return text ? (
|
||||||
|
<p className="mb-3 flex items-start gap-1.5 text-xs text-muted">
|
||||||
|
<Lightbulb size={12} className="mt-0.5 shrink-0 text-accent" aria-hidden />
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
|
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
{ABILITIES.map((a, i) => {
|
{ABILITIES.map((a, i) => {
|
||||||
@@ -284,7 +421,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
const isKey = selectedClass?.keyAbilities.includes(a);
|
const isKey = selectedClass?.keyAbilities.includes(a);
|
||||||
return (
|
return (
|
||||||
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey ? ' ★' : ''}</div>
|
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
|
||||||
{usesPool ? (
|
{usesPool ? (
|
||||||
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
|
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
|
||||||
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
|
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
|
||||||
@@ -302,7 +439,17 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
|
|
||||||
{stepName === 'Skills' && (
|
{stepName === 'Skills' && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> trained {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
|
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
|
||||||
|
<p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
|
||||||
|
{selectedClass && (() => {
|
||||||
|
const tip = getClassTip(system, selectedClass.slug);
|
||||||
|
return tip?.skillSuggestions ? (
|
||||||
|
<p className="mb-3 flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<Lightbulb size={12} className="shrink-0 text-accent" aria-hidden />
|
||||||
|
{selectedClass.name}s often pick: <span className="ml-1 text-ink">{tip.skillSuggestions}</span>
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{skillOptions.map((key) => {
|
{skillOptions.map((key) => {
|
||||||
const checked = skills.includes(key);
|
const checked = skills.includes(key);
|
||||||
@@ -320,7 +467,11 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
|
|
||||||
{stepName === 'Spells' && (
|
{stepName === 'Spells' && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-sm text-muted">Pick starting spells (search by name). You can add more on the sheet later. {spellPicks.length} selected.</p>
|
<p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells — about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
|
||||||
|
<p className="mb-1 text-xs text-muted">These are the spells you <span className="text-ink">{system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells you’ve learned)'}</span>. You prepare or cast them from slots on the character sheet.</p>
|
||||||
|
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
|
||||||
|
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s quite a few; you can trim some later if you like.' : ''}
|
||||||
|
</p>
|
||||||
<Input value={spellQuery} onChange={(e) => setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" />
|
<Input value={spellQuery} onChange={(e) => setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" />
|
||||||
<div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
|
<div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||||
{spellResults.map((s) => {
|
{spellResults.map((s) => {
|
||||||
@@ -366,7 +517,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</span>
|
<span className="smallcaps">{title}</span>
|
||||||
{value && <button className="text-[11px] text-muted hover:text-ink" onClick={() => onPick('')}>clear</button>}
|
{value && <button className="text-[11px] text-muted hover:text-ink" onClick={() => onPick('')}>clear</button>}
|
||||||
</div>
|
</div>
|
||||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}…`} className="mb-1" aria-label={`Search ${title}`} />
|
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}…`} className="mb-1" aria-label={`Search ${title}`} />
|
||||||
@@ -378,7 +529,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
|
|||||||
))}
|
))}
|
||||||
{options.length === 0 && <p className="text-xs text-muted">Loading…</p>}
|
{options.length === 0 && <p className="text-xs text-muted">Loading…</p>}
|
||||||
</div>
|
</div>
|
||||||
{sel?.desc && <p className="mt-1 line-clamp-3 text-xs text-muted">{sel.desc}</p>}
|
{sel?.desc && <p className="mt-1 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">{sel.desc}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { briefOverview, dedupeByName } from './overview';
|
||||||
|
|
||||||
|
describe('briefOverview', () => {
|
||||||
|
it('leaves short text untouched (5e feature names)', () => {
|
||||||
|
expect(briefOverview('Rage')).toBe('Rage');
|
||||||
|
expect(briefOverview('Fighting Style')).toBe('Fighting Style');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first clean sentence of a long run-on, no mid-word cut', () => {
|
||||||
|
const fighter = 'Fighting for honor, greed, loyalty, or simply the thrill of battle, you are an undisputed master of weaponry and combat techniques. You combine your actions through clever combinations…';
|
||||||
|
expect(briefOverview(fighter)).toBe('Fighting for honor, greed, loyalty, or simply the thrill of battle, you are an undisputed master of weaponry and combat techniques.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips the Foundry "Name Source … pg. N" citation prefix, then takes the first sentence', () => {
|
||||||
|
const bg = ' Acolyte Source Core Rulebook pg. 60 You spent your early days in a religious monastery or cloister. You may have traveled out into the world to spread the message of your religion or because you cast away the teachings of your faith.';
|
||||||
|
expect(briefOverview(bg)).toBe('You spent your early days in a religious monastery or cloister.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a stray trailing ellipsis from a pre-truncated summary', () => {
|
||||||
|
expect(briefOverview('Dwarves are a stoic and stern people…')).toBe('Dwarves are a stoic and stern people');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a word-boundary trim when the first sentence is very long', () => {
|
||||||
|
const long = `${'word '.repeat(60)}end.`;
|
||||||
|
const out = briefOverview(long);
|
||||||
|
expect(out.endsWith('…')).toBe(true);
|
||||||
|
expect(out.length).toBeLessThanOrEqual(141);
|
||||||
|
expect(out).not.toMatch(/wor…$/); // never cuts mid-word
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dedupeByName', () => {
|
||||||
|
it('keeps the first of each name, case-insensitively', () => {
|
||||||
|
const out = dedupeByName([{ name: 'Human' }, { name: 'Elf' }, { name: 'human' }, { name: 'Elf' }]);
|
||||||
|
expect(out.map((x) => x.name)).toEqual(['Human', 'Elf']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Helpers that make the pf2e creator read as cleanly as the 5e one. The pf2e
|
||||||
|
* (Foundry) data carries long, citation-prefixed descriptions and reprinted
|
||||||
|
* entries under duplicate names; these trim and de-duplicate at the edge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short, clean overview from possibly-long text. Strips the
|
||||||
|
* "<Name> Source Core Rulebook pg. N" citation prefix Foundry entries carry,
|
||||||
|
* drops a stray trailing ellipsis from prior truncation, then keeps the first
|
||||||
|
* sentence (or a clean word-boundary trim). A no-op on already-short text.
|
||||||
|
*/
|
||||||
|
export function briefOverview(text: string): string {
|
||||||
|
let t = (text || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const citation = /^.{0,80}?\bSource\b.*?\bpg\.\s*\d+\s*/i.exec(t);
|
||||||
|
if (citation) t = t.slice(citation[0].length).trim();
|
||||||
|
t = t.replace(/…\s*$/, '').trim();
|
||||||
|
if (t.length <= 140) return t;
|
||||||
|
const sentence = /^(.{40,180}?[.!?])(?:\s|$)/.exec(t);
|
||||||
|
if (sentence) return sentence[1]!;
|
||||||
|
const cut = t.slice(0, 140);
|
||||||
|
const sp = cut.lastIndexOf(' ');
|
||||||
|
return `${(sp > 40 ? cut.slice(0, sp) : cut).trim()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the first entry of each name (case-insensitive) — drops reprints/variants. */
|
||||||
|
export function dedupeByName<T extends { name: string }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return items.filter((it) => {
|
||||||
|
const k = it.name.trim().toLowerCase();
|
||||||
|
if (seen.has(k)) return false;
|
||||||
|
seen.add(k);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ export function useCharacters(campaignId: string): Character[] {
|
|||||||
return useLiveQuery(() => charactersRepo.listByCampaign(campaignId), [campaignId], []);
|
return useLiveQuery(() => charactersRepo.listByCampaign(campaignId), [campaignId], []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All player characters across every campaign — PCs are campaign-agnostic. */
|
||||||
|
export function useAllPcs(): Character[] {
|
||||||
|
return useLiveQuery(() => charactersRepo.listAllPcs(), [], []);
|
||||||
|
}
|
||||||
|
|
||||||
export function useCharacter(id: string): Character | undefined {
|
export function useCharacter(id: string): Character | undefined {
|
||||||
return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
|
return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { RotateCcw } from 'lucide-react';
|
||||||
import type { AbilityKey, AbilityScores } from '@/lib/rules';
|
import type { AbilityKey, AbilityScores } from '@/lib/rules';
|
||||||
import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
|
import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
@@ -75,7 +76,7 @@ export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: Abilit
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{method === 'roll' && (
|
{method === 'roll' && (
|
||||||
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}>↻ Reroll</Button>
|
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}><RotateCcw size={13} aria-hidden /> Reroll</Button>
|
||||||
)}
|
)}
|
||||||
{method === 'pointbuy' && (
|
{method === 'pointbuy' && (
|
||||||
<p className={cn('mb-3 text-sm', remaining < 0 ? 'text-danger' : 'text-muted')}>
|
<p className={cn('mb-3 text-sm', remaining < 0 ? 'text-danger' : 'text-muted')}>
|
||||||
@@ -89,7 +90,7 @@ export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: Abilit
|
|||||||
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
|
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
|
||||||
return (
|
return (
|
||||||
<div key={a} className="rounded-lg border border-line bg-surface p-3 text-center">
|
<div key={a} className="rounded-lg border border-line bg-surface p-3 text-center">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
|
<div className="smallcaps">{ABILITY_ABBR[a]}</div>
|
||||||
{usesPool ? (
|
{usesPool ? (
|
||||||
<Select
|
<Select
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||||
@@ -84,7 +86,7 @@ export function AttacksSection({ c, update }: SectionProps) {
|
|||||||
{result.damage}{a.damageType ? ` ${a.damageType}` : ''}
|
{result.damage}{a.damageType ? ` ${a.damageType}` : ''}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}>✕</Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}><X size={14} aria-hidden /></Button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
|
||||||
|
import { Minus, Plus, Star } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
import { SheetSection, type SectionProps } from './common';
|
import { SheetSection, type SectionProps } from './common';
|
||||||
@@ -44,7 +46,7 @@ export function DefensesSection({ c, update }: SectionProps) {
|
|||||||
</Field>
|
</Field>
|
||||||
<Field label="Inspiration">
|
<Field label="Inspiration">
|
||||||
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
|
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
|
||||||
{d.inspiration ? '★ Inspired' : 'Grant inspiration'}
|
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
|
||||||
</Button>
|
</Button>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Exhaustion (0–6)">
|
<Field label="Exhaustion (0–6)">
|
||||||
@@ -64,9 +66,9 @@ export function DefensesSection({ c, update }: SectionProps) {
|
|||||||
</Field>
|
</Field>
|
||||||
<Field label="Hero Points">
|
<Field label="Hero Points">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point">−</Button>
|
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
|
||||||
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
|
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
|
||||||
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point">+</Button>
|
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
</>
|
</>
|
||||||
@@ -79,7 +81,7 @@ export function DefensesSection({ c, update }: SectionProps) {
|
|||||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-line bg-panel px-3 py-2">
|
<div className="rounded-md border border-line bg-panel px-3 py-2">
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
|
<div className="mb-1 smallcaps">{label}</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
|
import type { Feat } from '@/lib/schemas';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input, Textarea } from '@/components/ui/Input';
|
||||||
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
|
||||||
|
/** Feats tracked as first-class records (name + effect) rather than buried in notes. */
|
||||||
|
export function FeatsSection({ c, update }: SectionProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
if (name.trim() === '') return;
|
||||||
|
const feat: Feat = { id: newId(), name: name.trim(), source: '', description: '' };
|
||||||
|
update({ feats: [...c.feats, feat] });
|
||||||
|
setName('');
|
||||||
|
};
|
||||||
|
const patch = (id: string, p: Partial<Feat>) =>
|
||||||
|
update({ feats: c.feats.map((f) => (f.id === id ? { ...f, ...p } : f)) });
|
||||||
|
const remove = (id: string) => update({ feats: c.feats.filter((f) => f.id !== id) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetSection title="Feats & Features">
|
||||||
|
<div className="mb-3 flex items-end gap-2">
|
||||||
|
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||||
|
Add feat / feature
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Great Weapon Master, Oath of Devotion…" />
|
||||||
|
</label>
|
||||||
|
<Button variant="primary" onClick={add}>Add</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.feats.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No feats or features tracked yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{c.feats.map((f) => (
|
||||||
|
<li key={f.id} className="rounded-md border border-line bg-panel p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="h-8 flex-1 font-medium" value={f.name} onChange={(e) => patch(f.id, { name: e.target.value })} aria-label="Feat name" />
|
||||||
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(f.id)} aria-label={`Remove ${f.name}`}><X size={14} aria-hidden /></Button>
|
||||||
|
</div>
|
||||||
|
<Textarea className="mt-1 text-xs" rows={2} value={f.description} onChange={(e) => patch(f.id, { description: e.target.value })} placeholder="What it does — kept handy at the table." aria-label={`${f.name} description`} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SheetSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
import type { InventoryItem } from '@/lib/schemas';
|
import type { InventoryItem } from '@/lib/schemas';
|
||||||
@@ -118,7 +120,7 @@ export function InventorySection({ c, update }: SectionProps) {
|
|||||||
Attuned
|
Attuned
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}>✕</Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}><X size={14} aria-hidden /></Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function LevelUpAdvisor({ campaign, character }: { campaign: Campaign; ch
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
|
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
|
<span className="smallcaps">
|
||||||
{source === 'llm' ? 'AI build routes' : 'Build routes'}
|
{source === 'llm' ? 'AI build routes' : 'Build routes'}
|
||||||
</span>
|
</span>
|
||||||
{state === 'loading' && <span className="text-xs text-muted">Thinking…</span>}
|
{state === 'loading' && <span className="text-xs text-muted">Thinking…</span>}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Hit points</h3>
|
<h3 className="mb-1 smallcaps">Hit points</h3>
|
||||||
{character.system === '5e' ? (
|
{character.system === '5e' ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
|
<Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
|
||||||
@@ -108,7 +108,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
{/* ASI (5e) */}
|
{/* ASI (5e) */}
|
||||||
{asi && (
|
{asi && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability Score Improvement</h3>
|
<h3 className="mb-1 smallcaps">Ability Score Improvement</h3>
|
||||||
<div className="mb-2 flex gap-1">
|
<div className="mb-2 flex gap-1">
|
||||||
{(['asi', 'feat'] as const).map((m) => (
|
{(['asi', 'feat'] as const).map((m) => (
|
||||||
<button key={m} onClick={() => setAsiMode(m)} className={`rounded-md px-3 py-1 text-sm ${asiMode === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink'}`}>
|
<button key={m} onClick={() => setAsiMode(m)} className={`rounded-md px-3 py-1 text-sm ${asiMode === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink'}`}>
|
||||||
@@ -134,7 +134,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
{/* Boosts (pf2e) */}
|
{/* Boosts (pf2e) */}
|
||||||
{boosts && (
|
{boosts && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability boosts (choose 4)</h3>
|
<h3 className="mb-1 smallcaps">Ability boosts (choose 4)</h3>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
{[0, 1, 2, 3].map((i) => (
|
{[0, 1, 2, 3].map((i) => (
|
||||||
<Select key={i} aria-label={`Boost ${i + 1}`} value={boostPicks[i]} onChange={(e) => setBoostPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
|
<Select key={i} aria-label={`Boost ${i + 1}`} value={boostPicks[i]} onChange={(e) => setBoostPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
|
||||||
@@ -148,7 +148,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
{/* Skill increase (pf2e) */}
|
{/* Skill increase (pf2e) */}
|
||||||
{skillInc && (
|
{skillInc && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Skill increase</h3>
|
<h3 className="mb-1 smallcaps">Skill increase</h3>
|
||||||
<Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
|
<Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
|
||||||
{sys.skills.map((s) => {
|
{sys.skills.map((s) => {
|
||||||
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
|
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { X, Minus, Plus } from 'lucide-react';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { getSystem, applyRest } from '@/lib/rules';
|
import { getSystem, applyRest } from '@/lib/rules';
|
||||||
|
import { spendResource, regainResource } from '@/lib/mechanics';
|
||||||
import type { CharacterResource } from '@/lib/schemas';
|
import type { CharacterResource } from '@/lib/schemas';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -61,19 +64,19 @@ export function ResourcesSection({ c, update }: SectionProps) {
|
|||||||
{c.resources.map((r) => (
|
{c.resources.map((r) => (
|
||||||
<li key={r.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-2">
|
<li key={r.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-2">
|
||||||
<Input className="h-8 flex-1" value={r.name} onChange={(e) => patch(r.id, { name: e.target.value })} aria-label="Resource name" />
|
<Input className="h-8 flex-1" value={r.name} onChange={(e) => patch(r.id, { name: e.target.value })} aria-label="Resource name" />
|
||||||
<Button size="icon" variant="ghost" onClick={() => patch(r.id, { current: Math.max(0, r.current - 1) })} aria-label="Spend">−</Button>
|
<Button size="icon" variant="ghost" disabled={r.current <= 0} onClick={() => { const res = spendResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Spend"><Minus size={14} aria-hidden /></Button>
|
||||||
<span className="w-12 text-center text-sm">
|
<span className="w-12 text-center text-sm">
|
||||||
<span className="font-medium text-ink">{r.current}</span>
|
<span className="font-medium text-ink">{r.current}</span>
|
||||||
<span className="text-muted">/{r.max}</span>
|
<span className="text-muted">/{r.max}</span>
|
||||||
</span>
|
</span>
|
||||||
<Button size="icon" variant="ghost" onClick={() => patch(r.id, { current: Math.min(r.max, r.current + 1) })} aria-label="Regain">+</Button>
|
<Button size="icon" variant="ghost" disabled={r.current >= r.max} onClick={() => { const res = regainResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Regain"><Plus size={14} aria-hidden /></Button>
|
||||||
<NumberField className="w-14" value={r.max} min={0} onChange={(v) => patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" />
|
<NumberField className="w-14" value={r.max} min={0} onChange={(v) => patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" />
|
||||||
<Select className="w-auto py-1 text-xs" value={r.recovery} onChange={(e) => patch(r.id, { recovery: e.target.value as CharacterResource['recovery'] })}>
|
<Select className="w-auto py-1 text-xs" value={r.recovery} onChange={(e) => patch(r.id, { recovery: e.target.value as CharacterResource['recovery'] })}>
|
||||||
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
|
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
|
||||||
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
|
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}>✕</Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}><X size={14} aria-hidden /></Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Sparkles, BrainCircuit, X, Minus, Plus } from 'lucide-react';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||||
import type { SpellEntry } from '@/lib/schemas';
|
import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
|
||||||
|
import { castSpell, dropConcentration } from '@/lib/mechanics';
|
||||||
|
import { multiclassSlots } from '@/lib/rules/dnd5e/progression';
|
||||||
import { formatModifier } from '@/lib/format';
|
import { formatModifier } from '@/lib/format';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -33,15 +36,17 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
const setSlots = (slots: typeof sc.slots) => update({ spellcasting: { ...sc, slots } });
|
const setSlots = (slots: typeof sc.slots) => update({ spellcasting: { ...sc, slots } });
|
||||||
const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) =>
|
const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) =>
|
||||||
setSlots(sc.slots.map((s) => (s.level === level ? { ...s, ...p } : s)));
|
setSlots(sc.slots.map((s) => (s.level === level ? { ...s, ...p } : s)));
|
||||||
|
// pf2e has 10 spell ranks; 5e tops out at 9.
|
||||||
|
const maxRank = c.system === 'pf2e' ? 10 : 9;
|
||||||
const addSlotLevel = () => {
|
const addSlotLevel = () => {
|
||||||
const next = (sc.slots.at(-1)?.level ?? 0) + 1;
|
const next = (sc.slots.at(-1)?.level ?? 0) + 1;
|
||||||
if (next > 9) return;
|
if (next > maxRank) return;
|
||||||
setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
|
setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSpell = () => {
|
const addSpell = () => {
|
||||||
if (spellName.trim() === '') return;
|
if (spellName.trim() === '') return;
|
||||||
const entry: SpellEntry = { id: newId(), name: spellName.trim(), level: spellLevel, prepared: false, notes: '' };
|
const entry: SpellEntry = newSpellEntry({ id: newId(), name: spellName.trim(), level: spellLevel });
|
||||||
update({ spellcasting: { ...sc, spells: [...sc.spells, entry] } });
|
update({ spellcasting: { ...sc, spells: [...sc.spells, entry] } });
|
||||||
setSpellName('');
|
setSpellName('');
|
||||||
};
|
};
|
||||||
@@ -50,6 +55,18 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
const removeSpell = (id: string) =>
|
const removeSpell = (id: string) =>
|
||||||
update({ spellcasting: { ...sc, spells: sc.spells.filter((s) => s.id !== id) } });
|
update({ spellcasting: { ...sc, spells: sc.spells.filter((s) => s.id !== id) } });
|
||||||
|
|
||||||
|
// Casting a spell enforces slot consumption + concentration via the kernel.
|
||||||
|
const [castMsg, setCastMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
|
const cast = (id: string) => {
|
||||||
|
const res = castSpell(c, id);
|
||||||
|
if (res.ok) {
|
||||||
|
update(res.patch);
|
||||||
|
setCastMsg({ text: res.log.join(' '), ok: true });
|
||||||
|
} else {
|
||||||
|
setCastMsg({ text: res.reason, ok: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const spellsByLevel = [...sc.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
|
const spellsByLevel = [...sc.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,6 +85,21 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
{/* pf2e: spell DC/attack scale with spellcasting proficiency (expert/master/legendary). */}
|
||||||
|
{c.system === 'pf2e' && ability && (
|
||||||
|
<label className="text-xs text-muted">
|
||||||
|
Proficiency
|
||||||
|
<Select
|
||||||
|
className="ml-2 w-auto"
|
||||||
|
value={c.spellcastingRank ?? 'trained'}
|
||||||
|
onChange={(e) => update({ spellcastingRank: e.target.value as ProficiencyRank })}
|
||||||
|
>
|
||||||
|
{(['trained', 'expert', 'master', 'legendary'] as const).map((r) => (
|
||||||
|
<option key={r} value={r}>{r[0]!.toUpperCase() + r.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
{ability && (
|
{ability && (
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="flex gap-4 text-sm">
|
||||||
<span className="text-muted">Spell DC <span className="font-display text-lg font-semibold text-accent">{dc}</span></span>
|
<span className="text-muted">Spell DC <span className="font-display text-lg font-semibold text-accent">{dc}</span></span>
|
||||||
@@ -76,10 +108,22 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Concentration banner — enforced: one spell at a time. */}
|
||||||
|
{c.concentration && (
|
||||||
|
<div className="mb-3 flex items-center gap-2 rounded-md border border-accent/40 bg-accent/5 px-3 py-1.5 text-sm">
|
||||||
|
<BrainCircuit size={15} className="shrink-0 text-accent" aria-hidden />
|
||||||
|
<span className="text-ink">Concentrating on <span className="font-medium">{c.concentration.spellName}</span></span>
|
||||||
|
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => update(dropConcentration(c))}>Drop</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{castMsg && (
|
||||||
|
<p className={`mb-3 text-xs ${castMsg.ok ? 'text-success' : 'text-danger'}`} aria-live="polite">{castMsg.text}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spell slots */}
|
{/* Spell slots */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Spell slots</span>
|
<span className="smallcaps">Spell slots</span>
|
||||||
<Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
|
<Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
|
||||||
</div>
|
</div>
|
||||||
{sc.slots.length === 0 ? (
|
{sc.slots.length === 0 ? (
|
||||||
@@ -90,14 +134,15 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
<div key={s.level} className="rounded-md border border-line bg-panel px-2 py-1 text-center">
|
<div key={s.level} className="rounded-md border border-line bg-panel px-2 py-1 text-center">
|
||||||
<div className="text-[10px] uppercase text-muted">Lv {s.level}</div>
|
<div className="text-[10px] uppercase text-muted">Lv {s.level}</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}>−</Button>
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}><Minus size={14} aria-hidden /></Button>
|
||||||
<span className="text-sm"><span className="font-medium text-ink">{s.current}</span>/<NumberField className="inline-block w-12" value={s.max} min={0} onChange={(v) => patchSlot(s.level, { max: v, current: Math.min(v, s.current) })} aria-label={`Level ${s.level} max slots`} /></span>
|
<span className="text-sm"><span className="font-medium text-ink">{s.current}</span>/<NumberField className="inline-block w-12" value={s.max} min={0} onChange={(v) => patchSlot(s.level, { max: v, current: Math.min(v, s.current) })} aria-label={`Level ${s.level} max slots`} /></span>
|
||||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}>+</Button>
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}><Plus size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{c.system === '5e' && <MulticlassSlots onApply={setSlots} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spell list */}
|
{/* Spell list */}
|
||||||
@@ -110,7 +155,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
Level
|
Level
|
||||||
<Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}>
|
<Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}>
|
||||||
<option value={0}>Cantrip</option>
|
<option value={0}>Cantrip</option>
|
||||||
{Array.from({ length: 9 }, (_, i) => i + 1).map((l) => (
|
{Array.from({ length: maxRank }, (_, i) => i + 1).map((l) => (
|
||||||
<option key={l} value={l}>{l}</option>
|
<option key={l} value={l}>{l}</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -124,14 +169,20 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
{spellsByLevel.map((s) => (
|
{spellsByLevel.map((s) => (
|
||||||
<li key={s.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
<li key={s.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
||||||
<span className="w-16 text-xs text-muted">{s.level === 0 ? 'Cantrip' : `Lv ${s.level}`}</span>
|
<span className="w-16 text-xs text-muted">{s.level === 0 ? 'Cantrip' : `Lv ${s.level}`}</span>
|
||||||
<span className="flex-1 text-ink">{s.name}</span>
|
<span className="flex-1 text-ink">
|
||||||
|
{s.name}
|
||||||
|
{s.concentration && <BrainCircuit size={12} className="ml-1 inline text-accent" aria-label="Concentration" />}
|
||||||
|
</span>
|
||||||
{s.level > 0 && (
|
{s.level > 0 && (
|
||||||
<label className="flex items-center gap-1 text-xs text-muted">
|
<label className="flex items-center gap-1 text-xs text-muted">
|
||||||
<input type="checkbox" checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
|
<input type="checkbox" checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
|
||||||
Prepared
|
Prepared
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}>✕</Button>
|
<Button size="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
|
||||||
|
<Sparkles size={13} aria-hidden /> Cast
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}><X size={14} aria-hidden /></Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -139,3 +190,23 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
</SheetSection>
|
</SheetSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PHB multiclass spell-slot calculator: enter your class levels by caster type
|
||||||
|
* and it fills the slot table correctly (full ×1, half ÷2, third ÷3). */
|
||||||
|
function MulticlassSlots({ onApply }: { onApply: (slots: { level: number; max: number; current: number }[]) => void }) {
|
||||||
|
const [full, setFull] = useState(0);
|
||||||
|
const [half, setHalf] = useState(0);
|
||||||
|
const [third, setThird] = useState(0);
|
||||||
|
return (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted hover:text-ink">Multiclass slot calculator</summary>
|
||||||
|
<div className="mt-2 flex flex-wrap items-end gap-2 rounded-md border border-line bg-panel-2 p-2 text-xs text-muted">
|
||||||
|
<label>Full-caster lvls<NumberField className="ml-1 w-14" value={full} min={0} max={20} onChange={setFull} aria-label="Full caster levels" /></label>
|
||||||
|
<label>Half-caster lvls<NumberField className="ml-1 w-14" value={half} min={0} max={20} onChange={setHalf} aria-label="Half caster levels" /></label>
|
||||||
|
<label>Third-caster lvls<NumberField className="ml-1 w-14" value={third} min={0} max={20} onChange={setThird} aria-label="Third caster levels" /></label>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => onApply(multiclassSlots({ full, half, third }))}>Apply slots</Button>
|
||||||
|
<span className="w-full text-[11px] text-faint">Bard/Cleric/Druid/Sorcerer/Wizard = full; Paladin/Ranger = half; Eldritch Knight/Arcane Trickster = third. Warlock pact slots are separate.</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Cloud, CloudOff, RefreshCw, WifiOff, AlertTriangle } from 'lucide-react';
|
||||||
|
import { cloudUsername, pushBackup, pullBackup } from '@/lib/cloud/client';
|
||||||
|
import { useConnectivityStore } from '@/stores/connectivityStore';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact shell indicator for connectivity + cloud sync. Shows "Offline" when
|
||||||
|
* the browser is offline, otherwise the autosave state (saving / saved / failed)
|
||||||
|
* while signed into the cloud. Renders nothing when online and not using cloud,
|
||||||
|
* to keep the bar quiet. Also owns the online/offline event wiring.
|
||||||
|
*/
|
||||||
|
export function SyncStatusIndicator() {
|
||||||
|
const online = useConnectivityStore((s) => s.online);
|
||||||
|
const cloudSync = useConnectivityStore((s) => s.cloudSync);
|
||||||
|
const setOnline = useConnectivityStore((s) => s.setOnline);
|
||||||
|
const signedIn = !!cloudUsername();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const on = () => setOnline(true);
|
||||||
|
const off = () => setOnline(false);
|
||||||
|
window.addEventListener('online', on);
|
||||||
|
window.addEventListener('offline', off);
|
||||||
|
setOnline(navigator.onLine);
|
||||||
|
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); };
|
||||||
|
}, [setOnline]);
|
||||||
|
|
||||||
|
if (!online) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning" title="You're offline — changes are saved locally and will sync when you reconnect." role="status">
|
||||||
|
<WifiOff size={14} aria-hidden /> <span className="hidden sm:inline">Offline</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signedIn) return null;
|
||||||
|
|
||||||
|
if (cloudSync === 'conflict') return <ConflictResolver />;
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
idle: null,
|
||||||
|
conflict: null, // handled above
|
||||||
|
saving: { Icon: RefreshCw, text: 'Saving…', cls: 'text-muted', spin: true, title: 'Saving your campaign to the cloud…' },
|
||||||
|
saved: { Icon: Cloud, text: 'Saved', cls: 'text-success', spin: false, title: 'Your campaign is backed up to the cloud.' },
|
||||||
|
error: { Icon: CloudOff, text: 'Sync failed', cls: 'text-danger', spin: false, title: 'Cloud sync failed — your data is safe locally and will retry on the next change.' },
|
||||||
|
} as const;
|
||||||
|
const s = map[cloudSync];
|
||||||
|
if (!s) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('flex items-center gap-1 rounded-md px-2 py-1 text-xs', s.cls)} title={s.title} role="status" aria-live="polite">
|
||||||
|
<s.Icon size={14} aria-hidden className={s.spin ? 'animate-spin' : undefined} />
|
||||||
|
<span className="hidden sm:inline">{s.text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloud changed on another device since this one last synced — resolve in one
|
||||||
|
* click rather than silently clobbering. "Use cloud" pulls (overwrites local),
|
||||||
|
* "Keep this device" force-pushes (overwrites cloud).
|
||||||
|
*/
|
||||||
|
function ConflictResolver() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const setCloudSync = useConnectivityStore((s) => s.setCloudSync);
|
||||||
|
|
||||||
|
const useCloud = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { if (await pullBackup()) { setCloudSync('saved'); setTimeout(() => location.reload(), 400); } }
|
||||||
|
finally { setBusy(false); setOpen(false); }
|
||||||
|
};
|
||||||
|
const keepMine = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await pushBackup({ force: true }); setCloudSync('saved'); }
|
||||||
|
finally { setBusy(false); setOpen(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={() => setOpen((o) => !o)} className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning hover:bg-elevated" title="The cloud copy changed on another device — click to resolve.">
|
||||||
|
<AlertTriangle size={14} aria-hidden /> <span className="hidden sm:inline">Sync conflict</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-9 z-50 w-64 rounded-xl border border-line bg-panel p-3 text-sm shadow-xl">
|
||||||
|
<p className="mb-2 text-muted">The cloud copy was changed on another device since this one last synced.</p>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Button size="sm" variant="secondary" disabled={busy} onClick={() => void useCloud()}>Use cloud version (reload)</Button>
|
||||||
|
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void keepMine()}>Keep this device (overwrite cloud)</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db } from '@/lib/db/db';
|
||||||
|
import { cloudUsername, pushBackup } from '@/lib/cloud/client';
|
||||||
|
import { useConnectivityStore } from '@/stores/connectivityStore';
|
||||||
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autosave to the cloud while signed in: whenever durable data changes, debounce
|
||||||
|
* a full backup push. A fingerprint of (id:updatedAt) over the durable tables is
|
||||||
|
* watched via liveQuery, so it reacts to edits, adds, and deletes alike. High-churn
|
||||||
|
* tables (dice rolls, the session recap) are excluded so a roll doesn't trigger a
|
||||||
|
* save. No-op when signed out. Mounted once in the app shell.
|
||||||
|
*/
|
||||||
|
export function useCloudAutosave(): void {
|
||||||
|
const fingerprint = useLiveQuery(async () => {
|
||||||
|
const stamp = async (rows: Promise<Array<{ id?: string; campaignId?: string; updatedAt?: string }>>) =>
|
||||||
|
(await rows).map((r) => `${r.id ?? r.campaignId ?? ''}:${r.updatedAt ?? ''}`).join(',');
|
||||||
|
const [c, ch, e, n, np, q, m, h, cal] = await Promise.all([
|
||||||
|
stamp(db.campaigns.toArray()),
|
||||||
|
stamp(db.characters.toArray()),
|
||||||
|
stamp(db.encounters.toArray()),
|
||||||
|
stamp(db.notes.toArray()),
|
||||||
|
stamp(db.npcs.toArray()),
|
||||||
|
stamp(db.quests.toArray()),
|
||||||
|
stamp(db.maps.toArray()),
|
||||||
|
stamp(db.homebrew.toArray()),
|
||||||
|
stamp(db.calendars.toArray()),
|
||||||
|
]);
|
||||||
|
return [c, ch, e, n, np, q, m, h, cal].join('|');
|
||||||
|
}, [], undefined);
|
||||||
|
|
||||||
|
const save = useDebouncedCallback(() => {
|
||||||
|
if (!cloudUsername()) return;
|
||||||
|
const conn = useConnectivityStore.getState();
|
||||||
|
conn.setCloudSync('saving');
|
||||||
|
void pushBackup()
|
||||||
|
.then((r) => useConnectivityStore.getState().setCloudSync(r.ok ? 'saved' : 'conflict'))
|
||||||
|
.catch(() => useConnectivityStore.getState().setCloudSync('error')); // offline / transient — next change retries
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
// Skip the initial load; only push once data actually changes after mount.
|
||||||
|
const prev = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (fingerprint === undefined) return;
|
||||||
|
if (prev.current === undefined) { prev.current = fingerprint; return; }
|
||||||
|
if (prev.current !== fingerprint) { prev.current = fingerprint; save(); }
|
||||||
|
}, [fingerprint, save]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
@@ -22,7 +22,8 @@ import { createRng } from '@/lib/rng';
|
|||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
||||||
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
|
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { deriveState, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
|
||||||
|
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||||
import { useConditionGlossary } from './useConditionGlossary';
|
import { useConditionGlossary } from './useConditionGlossary';
|
||||||
import {
|
import {
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -48,6 +49,9 @@ import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
|
|||||||
|
|
||||||
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
||||||
const characters = useCharacters(campaign.id);
|
const characters = useCharacters(campaign.id);
|
||||||
|
// PCs are campaign-agnostic, so any PC can join a fight; NPCs stay campaign-scoped.
|
||||||
|
const allPcs = useAllPcs();
|
||||||
|
const addableChars = [...allPcs, ...characters.filter((c) => c.kind === 'npc')];
|
||||||
const glossary = useConditionGlossary(campaign.system);
|
const glossary = useConditionGlossary(campaign.system);
|
||||||
|
|
||||||
const undoStack = useRef<Encounter[]>([]);
|
const undoStack = useRef<Encounter[]>([]);
|
||||||
@@ -62,6 +66,36 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
if (prev) void encountersRepo.save(prev);
|
if (prev) void encountersRepo.save(prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The Character backing a PC/NPC combatant, if any (for cross-doc rules state). */
|
||||||
|
const pcCharacter = (combatant: Combatant): Character | undefined =>
|
||||||
|
combatant.kind !== 'monster' && combatant.characterId
|
||||||
|
? [...allPcs, ...characters].find((ch) => ch.id === combatant.characterId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a concentrating PC takes damage, the app computes the Constitution
|
||||||
|
* save DC and surfaces it — but never rolls. The player rolls their own save
|
||||||
|
* and, on a failure, uses the Drop button on their sheet/panel. (Auto-rolling
|
||||||
|
* would take the moment away from the player.)
|
||||||
|
*/
|
||||||
|
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
|
||||||
|
if (damage <= 0) return;
|
||||||
|
const ch = pcCharacter(combatant);
|
||||||
|
if (!ch?.concentration) return;
|
||||||
|
const dc = concentrationDC(damage);
|
||||||
|
void encountersRepo.mutate(encounter.id, (e) =>
|
||||||
|
logEvent(e, `${ch.name}: roll a DC ${dc} Constitution save or lose concentration on ${ch.concentration!.spellName}.`));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
|
||||||
|
const advanceTurn = () => {
|
||||||
|
mutate(nextTurn);
|
||||||
|
const upcoming = currentCombatant(nextTurn(encounter));
|
||||||
|
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
|
||||||
|
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const rollAllInitiative = () => {
|
const rollAllInitiative = () => {
|
||||||
const rolls: Record<string, number> = {};
|
const rolls: Record<string, number> = {};
|
||||||
for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus;
|
for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus;
|
||||||
@@ -71,7 +105,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat
|
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat
|
||||||
// has started, rate against the levels captured at that time (snapshot) so the
|
// has started, rate against the levels captured at that time (snapshot) so the
|
||||||
// reading — and the assistant's history — reflects the party as it was.
|
// reading — and the assistant's history — reflects the party as it was.
|
||||||
const currentLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
|
// Rate against the PCs actually IN the fight (so adding a player updates the
|
||||||
|
// difficulty); fall back to the campaign roster before any PC is added.
|
||||||
|
const encounterPcLevels = encounter.combatants.filter((c) => c.kind === 'pc' && c.level !== undefined).map((c) => c.level as number);
|
||||||
|
const currentLevels = encounterPcLevels.length > 0 ? encounterPcLevels : characters.filter((c) => c.kind === 'pc').map((c) => c.level);
|
||||||
const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
|
const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
|
||||||
const monsters = encounter.combatants
|
const monsters = encounter.combatants
|
||||||
.filter((c) => c.kind === 'monster')
|
.filter((c) => c.kind === 'monster')
|
||||||
@@ -84,6 +121,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
const current = currentCombatant(encounter);
|
const current = currentCombatant(encounter);
|
||||||
const isActive = encounter.status === 'active';
|
const isActive = encounter.status === 'active';
|
||||||
|
|
||||||
|
// Keyboard turn control for a GM running the table hands-free. Guarded so it
|
||||||
|
// never fires while typing in a field, and only during an active fight.
|
||||||
|
const advanceRef = useRef(advanceTurn);
|
||||||
|
advanceRef.current = advanceTurn;
|
||||||
|
const prevRef = useRef(() => mutate(previousTurn));
|
||||||
|
prevRef.current = () => mutate(previousTurn);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return;
|
||||||
|
if (e.key === 'n' || e.key === 'ArrowRight') { e.preventDefault(); advanceRef.current(); }
|
||||||
|
else if (e.key === 'p' || e.key === 'ArrowLeft') { e.preventDefault(); prevRef.current(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Control bar */}
|
{/* Control bar */}
|
||||||
@@ -138,10 +194,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
)}
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
|
<Button variant="secondary" onClick={() => mutate(previousTurn)} title="Previous turn (p or ←)">
|
||||||
<ChevronLeft size={15} aria-hidden /> Prev
|
<ChevronLeft size={15} aria-hidden /> Prev
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => mutate(nextTurn)}>
|
<Button variant="primary" onClick={advanceTurn} title="Next turn (n or →)">
|
||||||
Next turn <ChevronRight size={15} aria-hidden />
|
Next turn <ChevronRight size={15} aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
|
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
|
||||||
@@ -159,7 +215,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
|
|
||||||
<EncounterTipCard campaign={campaign} encounter={encounter} />
|
<EncounterTipCard campaign={campaign} encounter={encounter} />
|
||||||
|
|
||||||
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
|
<AddCombatantBar encounter={encounter} characters={addableChars} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
|
||||||
|
|
||||||
{/* Combatant list */}
|
{/* Combatant list */}
|
||||||
{encounter.combatants.length === 0 ? (
|
{encounter.combatants.length === 0 ? (
|
||||||
@@ -178,7 +234,15 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
glossary={glossary}
|
glossary={glossary}
|
||||||
isCurrent={isActive && idx === encounter.turnIndex}
|
isCurrent={isActive && idx === encounter.turnIndex}
|
||||||
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
|
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
|
||||||
onDamage={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }), `${c.name} takes ${amt} damage`))}
|
onDamage={(amt, type) => {
|
||||||
|
const after = applyDamage(c, amt, type);
|
||||||
|
const hpLoss = (c.hp.current - after.hp.current) + (c.hp.temp - after.hp.temp);
|
||||||
|
const note = hpLoss === amt
|
||||||
|
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
||||||
|
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
|
||||||
|
mutate((e) => logEvent(updateCombatant(e, c.id, { hp: after.hp }), note));
|
||||||
|
noteConcentrationCheck(c, hpLoss);
|
||||||
|
}}
|
||||||
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
|
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
|
||||||
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
|
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
|
||||||
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
||||||
@@ -276,7 +340,7 @@ function AddCombatantBar({
|
|||||||
characterId: ch.id,
|
characterId: ch.id,
|
||||||
initiative: rollInitiativeFor(ch),
|
initiative: rollInitiativeFor(ch),
|
||||||
initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }),
|
initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }),
|
||||||
ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus }),
|
ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus, ...(ch.equippedArmor ? { equippedArmor: ch.equippedArmor } : {}) }),
|
||||||
hp: { ...ch.hp },
|
hp: { ...ch.hp },
|
||||||
conditions: [],
|
conditions: [],
|
||||||
notes: '',
|
notes: '',
|
||||||
@@ -350,12 +414,16 @@ function CombatantRow({
|
|||||||
glossary: Map<string, string>;
|
glossary: Map<string, string>;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
onChange: (patch: Partial<Combatant>) => void;
|
onChange: (patch: Partial<Combatant>) => void;
|
||||||
onDamage: (amt: number) => void;
|
onDamage: (amt: number, type?: string) => void;
|
||||||
onHeal: (amt: number) => void;
|
onHeal: (amt: number) => void;
|
||||||
onMove: (dir: -1 | 1) => void;
|
onMove: (dir: -1 | 1) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [delta, setDelta] = useState(0);
|
const [delta, setDelta] = useState(0);
|
||||||
|
const [dmgType, setDmgType] = useState('');
|
||||||
|
// Only show the damage-type picker when this creature actually has typed defenses.
|
||||||
|
const def = c.damageDefenses;
|
||||||
|
const hasDefenses = !!def && (def.resist.length > 0 || def.immune.length > 0 || def.vulnerable.length > 0 || (def.resistFlat?.length ?? 0) > 0 || (def.weakness?.length ?? 0) > 0);
|
||||||
const dead = c.hp.current <= 0;
|
const dead = c.hp.current <= 0;
|
||||||
const foe = c.kind === 'monster';
|
const foe = c.kind === 'monster';
|
||||||
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
||||||
@@ -433,12 +501,40 @@ function CombatantRow({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Mechanical consequences the conditions impose (enforced read-model). */}
|
||||||
|
{(() => {
|
||||||
|
const st = deriveState(system, 30, c.conditions);
|
||||||
|
return st.badges.length > 0 ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1" aria-label="Condition effects">
|
||||||
|
{st.badges.map((b) => (
|
||||||
|
<span key={b} className="rounded border border-line bg-surface px-1.5 py-0.5 text-[10px] font-medium text-muted">{b}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action controls — kept grouped so they wrap together, not scattered, on narrow widths */}
|
||||||
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
{/* HP controls */}
|
{/* HP controls */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
|
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
|
||||||
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
|
{hasDefenses && (
|
||||||
|
<Select className="w-auto py-1 text-xs" value={dmgType} onChange={(e) => setDmgType(e.target.value)} aria-label={`${c.name} damage type`}>
|
||||||
|
<option value="">untyped</option>
|
||||||
|
{DAMAGE_TYPES.filter((t) => t !== 'untyped').map((t) => {
|
||||||
|
const rf = def!.resistFlat?.find((r) => r.type === t);
|
||||||
|
const wk = def!.weakness?.find((w) => w.type === t);
|
||||||
|
const tag = def!.immune.includes(t) ? ' (immune)'
|
||||||
|
: def!.vulnerable.includes(t) ? ' (vuln)'
|
||||||
|
: def!.resist.includes(t) ? ' (resist)'
|
||||||
|
: rf ? ` (resist ${rf.amount})`
|
||||||
|
: wk ? ` (weak ${wk.amount})` : '';
|
||||||
|
return <option key={t} value={t}>{t}{tag}</option>;
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta, dmgType || undefined); setDelta(0); }} title="Apply damage">
|
||||||
<Sword size={14} aria-hidden /> Dmg
|
<Sword size={14} aria-hidden /> Dmg
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
|
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
|
||||||
@@ -466,6 +562,7 @@ function CombatantRow({
|
|||||||
<X size={15} aria-hidden />
|
<X size={15} aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { createRng } from '@/lib/rng';
|
|||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { addCombatant } from '@/lib/combat/engine';
|
import { addCombatant } from '@/lib/combat/engine';
|
||||||
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
|
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
|
||||||
import type { Character, InventoryItem, SpellEntry } from '@/lib/schemas';
|
import { type Character, type InventoryItem, type SpellEntry, newSpellEntry } from '@/lib/schemas';
|
||||||
|
import type { Spell, CompendiumEntry } from '@/lib/compendium/types';
|
||||||
|
import type { CombatantDamageDefenses } from '@/lib/schemas/encounter';
|
||||||
|
import { normalizeSpell5e, normalizeSpellPf2e } from '@/lib/mechanics';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
@@ -293,7 +296,7 @@ function ActionBar({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) {
|
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; damageDefenses?: CombatantDamageDefenses } }) {
|
||||||
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -310,6 +313,7 @@ function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number;
|
|||||||
conditions: [], notes: '',
|
conditions: [], notes: '',
|
||||||
...(stats.cr !== undefined ? { cr: stats.cr } : {}),
|
...(stats.cr !== undefined ? { cr: stats.cr } : {}),
|
||||||
...(stats.level !== undefined ? { level: stats.level } : {}),
|
...(stats.level !== undefined ? { level: stats.level } : {}),
|
||||||
|
...(stats.damageDefenses ? { damageDefenses: stats.damageDefenses } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setMsg(`Added to "${enc.name}" (init ${initiative}).`);
|
setMsg(`Added to "${enc.name}" (init ${initiative}).`);
|
||||||
@@ -334,11 +338,20 @@ function AddToCharacter({ kind, entry, system }: { kind: 'spell' | 'item'; entry
|
|||||||
const c = await charactersRepo.get(characterId);
|
const c = await charactersRepo.get(characterId);
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (kind === 'spell') {
|
if (kind === 'spell') {
|
||||||
|
// Snapshot the spell's mechanics (concentration / save / damage) so combat
|
||||||
|
// enforcement never needs to re-load the compendium.
|
||||||
|
const mech = system === '5e'
|
||||||
|
? normalizeSpell5e(entry as unknown as Spell)
|
||||||
|
: normalizeSpellPf2e(entry as unknown as CompendiumEntry);
|
||||||
const level = system === '5e' ? Number(entry.level_int ?? 0) : Number(entry.level ?? 0);
|
const level = system === '5e' ? Number(entry.level_int ?? 0) : Number(entry.level ?? 0);
|
||||||
const spell: SpellEntry = {
|
const spell: SpellEntry = newSpellEntry({
|
||||||
id: newId(), name: entry.name, level: Number.isFinite(level) ? level : 0,
|
id: newId(), name: entry.name,
|
||||||
prepared: false, notes: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
|
level: mech?.level ?? (Number.isFinite(level) ? level : 0),
|
||||||
};
|
concentration: mech?.concentration ?? false,
|
||||||
|
save: mech?.save ?? null,
|
||||||
|
damage: mech?.damage ?? [],
|
||||||
|
...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
|
||||||
|
});
|
||||||
await charactersRepo.update(c.id, { spellcasting: { ...c.spellcasting, spells: [...c.spellcasting.spells, spell] } });
|
await charactersRepo.update(c.id, { spellcasting: { ...c.spellcasting, spells: [...c.spellcasting.spells, spell] } });
|
||||||
} else {
|
} else {
|
||||||
const item: InventoryItem = {
|
const item: InventoryItem = {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { SystemId } from '@/lib/rules';
|
import type { SystemId } from '@/lib/rules';
|
||||||
import { abilityModifier } from '@/lib/rules';
|
import { abilityModifier } from '@/lib/rules';
|
||||||
|
import { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e } from '@/lib/mechanics';
|
||||||
|
import type { CombatantDamageDefenses } from '@/lib/schemas/encounter';
|
||||||
import type { CompendiumEntry, Monster, Spell, MagicItem } from '@/lib/compendium/types';
|
import type { CompendiumEntry, Monster, Spell, MagicItem } from '@/lib/compendium/types';
|
||||||
import {
|
import {
|
||||||
loadMonsters,
|
loadMonsters,
|
||||||
@@ -49,7 +51,7 @@ export interface CategoryDef {
|
|||||||
/** enables "add to character" for spells/items */
|
/** enables "add to character" for spells/items */
|
||||||
linkAs?: 'spell' | 'item';
|
linkAs?: 'spell' | 'item';
|
||||||
/** enables "add to combat" for monsters/creatures */
|
/** enables "add to combat" for monsters/creatures */
|
||||||
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number };
|
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; damageDefenses?: CombatantDamageDefenses };
|
||||||
/** optional numeric sort axis (e.g. CR, spell level) in addition to name */
|
/** optional numeric sort axis (e.g. CR, spell level) in addition to name */
|
||||||
numericSort?: { label: string; get: (e: Entry) => number };
|
numericSort?: { label: string; get: (e: Entry) => number };
|
||||||
}
|
}
|
||||||
@@ -169,6 +171,7 @@ export const CATEGORIES: CategoryDef[] = [
|
|||||||
name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
|
name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
|
||||||
initBonus: abilityModifier(m.dexterity ?? 10),
|
initBonus: abilityModifier(m.dexterity ?? 10),
|
||||||
...(m.cr !== undefined ? { cr: m.cr } : {}),
|
...(m.cr !== undefined ? { cr: m.cr } : {}),
|
||||||
|
damageDefenses: { ...normalizeMonsterDefenses(m), resistFlat: [], weakness: [] },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
numericSort: { label: 'CR', get: (e) => Number(e.cr) },
|
numericSort: { label: 'CR', get: (e) => Number(e.cr) },
|
||||||
@@ -242,13 +245,18 @@ export const CATEGORIES: CategoryDef[] = [
|
|||||||
// ---------- Pathfinder 2e ----------
|
// ---------- Pathfinder 2e ----------
|
||||||
{
|
{
|
||||||
...pf2eCategory('creatures', 'Bestiary', 'creatures'),
|
...pf2eCategory('creatures', 'Bestiary', 'creatures'),
|
||||||
toCombatant: (e) => ({
|
toCombatant: (e) => {
|
||||||
|
const d = normalizeMonsterDefensesPf2e(e as { immunity?: unknown; resistance?: unknown; weakness?: unknown });
|
||||||
|
const hasDef = d.immune.length || d.conditionImmune.length || d.resistFlat.length || d.weakness.length;
|
||||||
|
return {
|
||||||
name: e.name,
|
name: e.name,
|
||||||
ac: Number(e.ac) || 10,
|
ac: Number(e.ac) || 10,
|
||||||
hp: Number(e.hp) || 1,
|
hp: Number(e.hp) || 1,
|
||||||
initBonus: Number(e.perception) || 0,
|
initBonus: Number(e.perception) || 0,
|
||||||
...(e.level !== undefined ? { level: Number(e.level) } : {}),
|
...(e.level !== undefined ? { level: Number(e.level) } : {}),
|
||||||
}),
|
...(hasDef ? { damageDefenses: { resist: [], vulnerable: [], ...d } } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
classCategory('pf2e'),
|
classCategory('pf2e'),
|
||||||
pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }),
|
pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { Dices, Sparkles, Skull, History as HistoryIcon } from 'lucide-react';
|
import { Dices, Sparkles, Skull, EyeOff, X, History as HistoryIcon } from 'lucide-react';
|
||||||
import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
|
import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { diceRepo } from '@/lib/db/repositories';
|
import { diceRepo } from '@/lib/db/repositories';
|
||||||
@@ -199,7 +199,7 @@ export function DicePage() {
|
|||||||
onClick={toggleSecret}
|
onClick={toggleSecret}
|
||||||
title="When on, your rolls are NOT shared with the players"
|
title="When on, your rolls are NOT shared with the players"
|
||||||
>
|
>
|
||||||
🤫 Secret
|
<EyeOff size={14} aria-hidden /> Secret
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +209,7 @@ export function DicePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isGm && secret && (
|
{isGm && secret && (
|
||||||
<p className="mt-2 text-xs text-warning">🤫 Secret is on — your rolls are not shared with the players.</p>
|
<p className="mt-2 flex items-center gap-1 text-xs text-warning"><EyeOff size={12} aria-hidden /> Secret is on — your rolls are not shared with the players.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Saved rolls (macros) */}
|
{/* Saved rolls (macros) */}
|
||||||
@@ -240,7 +240,7 @@ export function DicePage() {
|
|||||||
onClick={() => removeMacro(macroKey, m.id)}
|
onClick={() => removeMacro(macroKey, m.id)}
|
||||||
aria-label={`Remove ${m.label}`}
|
aria-label={`Remove ${m.label}`}
|
||||||
>
|
>
|
||||||
✕
|
<X size={13} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Check, Megaphone, X } from 'lucide-react';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -21,9 +22,9 @@ export function HandoutControl() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
|
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
|
||||||
{active ? '📣 Handout ✓' : '📣 Handout'}
|
<Megaphone size={14} aria-hidden /> Handout {active && <Check size={12} aria-hidden />}
|
||||||
</Button>
|
</Button>
|
||||||
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout">✕</Button>}
|
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"><X size={14} aria-hidden /></Button>}
|
||||||
{open && (
|
{open && (
|
||||||
<Modal open onClose={() => setOpen(false)} title="Handout for players"
|
<Modal open onClose={() => setOpen(false)} title="Handout for players"
|
||||||
footer={<>
|
footer={<>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
import { leaveRoom } from '@/lib/sync/wsSync';
|
import { leaveRoom } from '@/lib/sync/wsSync';
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ export function PlayerSessionBadge() {
|
|||||||
const label = status === 'connected' ? 'Live' : status === 'connecting' ? 'Connecting…' : status;
|
const label = status === 'connected' ? 'Live' : status === 'connecting' ? 'Connecting…' : status;
|
||||||
return (
|
return (
|
||||||
<div data-testid="player-live" className="flex items-center gap-1 rounded-md border border-success/40 bg-success/5 px-2 py-1 text-xs">
|
<div data-testid="player-live" className="flex items-center gap-1 rounded-md border border-success/40 bg-success/5 px-2 py-1 text-xs">
|
||||||
<span className={status === 'connected' ? 'text-success' : 'text-muted'}>● {label}</span>
|
<span className={`inline-flex items-center gap-1 ${status === 'connected' ? 'text-success' : 'text-muted'}`}>
|
||||||
|
<span className={`inline-block h-1.5 w-1.5 rounded-full ${status === 'connected' ? 'bg-success' : 'bg-muted'}`} aria-hidden />{label}
|
||||||
|
</span>
|
||||||
<Link to="/play" className="font-mono font-semibold text-accent" title="Open the table">{intent.joinCode}</Link>
|
<Link to="/play" className="font-mono font-semibold text-accent" title="Open the table">{intent.joinCode}</Link>
|
||||||
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session">✕</button>
|
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session" aria-label="Leave session"><X size={13} aria-hidden /></button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { RadioTower, X } from 'lucide-react';
|
import { Check, Lock, RadioTower, X } from 'lucide-react';
|
||||||
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
|
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
|
||||||
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
|
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
|
||||||
import { charactersRepo } from '@/lib/db/repositories';
|
import { charactersRepo } from '@/lib/db/repositories';
|
||||||
import { cloudUsername } from '@/lib/cloud/client';
|
import { cloudUsername } from '@/lib/cloud/client';
|
||||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
||||||
/** Header control: GM hosts a live session, shares the code, and approves seats. */
|
/** Header control: GM hosts a live session, shares the code, and approves seats. */
|
||||||
export function SessionControl() {
|
export function SessionControl() {
|
||||||
@@ -19,6 +21,8 @@ export function SessionControl() {
|
|||||||
const seatRequests = useSessionStore((s) => s.seatRequests);
|
const seatRequests = useSessionStore((s) => s.seatRequests);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [showSeats, setShowSeats] = useState(false);
|
const [showSeats, setShowSeats] = useState(false);
|
||||||
|
const [hostOpen, setHostOpen] = useState(false);
|
||||||
|
const [hostPw, setHostPw] = useState('');
|
||||||
|
|
||||||
if (role === 'player') return null; // players don't host
|
if (role === 'player') return null; // players don't host
|
||||||
|
|
||||||
@@ -28,17 +32,24 @@ export function SessionControl() {
|
|||||||
if (!cloudUsername()) {
|
if (!cloudUsername()) {
|
||||||
return (
|
return (
|
||||||
<Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}>
|
<Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}>
|
||||||
🔒 Sign in to host
|
<Lock size={14} aria-hidden /> Sign in to host
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button size="sm" variant="ghost" title="Host a live session for players"
|
<>
|
||||||
onClick={() => {
|
<Button size="sm" variant="ghost" title="Host a live session for players" onClick={() => setHostOpen(true)}>
|
||||||
const pw = window.prompt('Optional player password (leave blank for none):')?.trim();
|
<RadioTower size={14} aria-hidden /> Host
|
||||||
hostSession(campaign.id, pw || undefined);
|
</Button>
|
||||||
}}
|
<Modal open={hostOpen} onClose={() => setHostOpen(false)} title="Host a live session" className="max-w-sm"
|
||||||
>📡 Host</Button>
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setHostOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => { hostSession(campaign.id, hostPw.trim() || undefined); setHostOpen(false); setHostPw(''); }}>Start hosting</Button>
|
||||||
|
</>}>
|
||||||
|
<p className="mb-2 text-sm text-muted">Optionally require a password for players to join. Leave it blank for an open session.</p>
|
||||||
|
<Input type="password" autoFocus value={hostPw} onChange={(e) => setHostPw(e.target.value)} aria-label="Session password (optional)" placeholder="Password (optional)" />
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +63,7 @@ export function SessionControl() {
|
|||||||
className="font-mono font-semibold tracking-wider text-accent-deep"
|
className="font-mono font-semibold tracking-wider text-accent-deep"
|
||||||
title="Copy player link"
|
title="Copy player link"
|
||||||
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
|
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
|
||||||
>{joinCode}{copied ? ' ✓' : ''}</button>
|
>{joinCode}{copied ? <><Check size={11} aria-hidden /></> : ''}</button>
|
||||||
|
|
||||||
{seatRequests.length > 0 && (
|
{seatRequests.length > 0 && (
|
||||||
<button data-testid="seat-requests" className="ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 font-mono text-[10px] font-bold text-white"
|
<button data-testid="seat-requests" className="ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 font-mono text-[10px] font-bold text-white"
|
||||||
@@ -74,16 +85,22 @@ export function SessionControl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SeatRequestRow({ req }: { req: SeatRequest }) {
|
function SeatRequestRow({ req }: { req: SeatRequest }) {
|
||||||
|
const campaign = useActiveCampaign();
|
||||||
const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]);
|
const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]);
|
||||||
const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest);
|
const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest);
|
||||||
const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character';
|
const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character';
|
||||||
|
|
||||||
const grant = async (applyOffline: boolean) => {
|
const grant = async (applyOffline: boolean) => {
|
||||||
if (applyOffline && req.offlineSnapshot) {
|
let current = await charactersRepo.get(req.characterId);
|
||||||
|
if (!current && req.offlineSnapshot && campaign) {
|
||||||
|
// A character the player made and pushed — add it to this campaign, then seat them.
|
||||||
|
await charactersRepo.insert({ ...req.offlineSnapshot, campaignId: campaign.id });
|
||||||
|
current = await charactersRepo.get(req.characterId);
|
||||||
|
} else if (applyOffline && req.offlineSnapshot && current) {
|
||||||
const { id: _id, campaignId: _c, createdAt: _cr, updatedAt: _u, ...rest } = req.offlineSnapshot;
|
const { id: _id, campaignId: _c, createdAt: _cr, updatedAt: _u, ...rest } = req.offlineSnapshot;
|
||||||
await charactersRepo.update(req.characterId, rest);
|
await charactersRepo.update(req.characterId, rest);
|
||||||
|
current = await charactersRepo.get(req.characterId);
|
||||||
}
|
}
|
||||||
const current = await charactersRepo.get(req.characterId);
|
|
||||||
if (current) grantSeat(req.playerId, current);
|
if (current) grantSeat(req.playerId, current);
|
||||||
else removeSeatRequest(req.playerId);
|
else removeSeatRequest(req.playerId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { sessionLogRepo } from '@/lib/db/repositories';
|
|||||||
import type { SessionLogEntry } from '@/lib/schemas';
|
import type { SessionLogEntry } from '@/lib/schemas';
|
||||||
import type { RosterEntry } from '@/lib/sync/messages';
|
import type { RosterEntry } from '@/lib/sync/messages';
|
||||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||||
|
import { Dice5, Mail, MessageSquare, X } from 'lucide-react';
|
||||||
import { Input, Textarea } from '@/components/ui/Input';
|
import { Input, Textarea } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
@@ -86,7 +87,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink">✕</button>
|
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink"><X size={15} aria-hidden /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!inSession && (
|
{!inSession && (
|
||||||
@@ -95,21 +96,21 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
|
|
||||||
{inSession && !isGm && (
|
{inSession && !isGm && (
|
||||||
<div className="border-b border-line p-3">
|
<div className="border-b border-line p-3">
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-muted">Your name</label>
|
<label className="smallcaps">Your name</label>
|
||||||
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); }} placeholder="What should we call you?" aria-label="Your display name" className="mt-1" />
|
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); }} placeholder="What should we call you?" aria-label="Your display name" className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inSession && (
|
{inSession && (
|
||||||
<div className="border-b border-line p-3">
|
<div className="border-b border-line p-3">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Who's here ({roster.length})</h3>
|
<h3 className="mb-1 smallcaps">Who's here ({roster.length})</h3>
|
||||||
<ul className="flex flex-wrap gap-1">
|
<ul className="flex flex-wrap gap-1">
|
||||||
{roster.map((p) => (
|
{roster.map((p) => (
|
||||||
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
|
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
|
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
|
||||||
{p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
|
{p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
|
||||||
{isGm && p.playerId !== 'gm' && (
|
{isGm && p.playerId !== 'gm' && (
|
||||||
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info">📨</button>
|
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info"><Mail size={12} aria-hidden /></button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -158,7 +159,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
<ul className="space-y-0.5 text-xs">
|
<ul className="space-y-0.5 text-xs">
|
||||||
{(recap ?? []).map((e) => (
|
{(recap ?? []).map((e) => (
|
||||||
<li key={e.id} className="flex gap-2">
|
<li key={e.id} className="flex gap-2">
|
||||||
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'}>{e.kind === 'roll' ? '🎲' : '💬'}</span>
|
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'} aria-hidden>{e.kind === 'roll' ? <Dice5 size={12} /> : <MessageSquare size={12} />}</span>
|
||||||
<span className="text-muted">{e.from}:</span>
|
<span className="text-muted">{e.from}:</span>
|
||||||
<span className="min-w-0 flex-1 text-ink">{e.text}</span>
|
<span className="min-w-0 flex-1 text-ink">{e.text}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Snapshot } from '@/lib/sync/messages';
|
|||||||
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
|
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
import { useEncounters } from '@/features/combat/hooks';
|
import { useEncounters } from '@/features/combat/hooks';
|
||||||
import { useMaps, useCalendar } from '@/features/world/hooks';
|
import { useMaps, useCalendar, useQuests } from '@/features/world/hooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* While the GM is hosting, mirror local state to the room: a debounced
|
* While the GM is hosting, mirror local state to the room: a debounced
|
||||||
@@ -23,6 +23,7 @@ export function useSessionBroadcaster(campaign: Campaign | null): void {
|
|||||||
const encounters = useEncounters(cid);
|
const encounters = useEncounters(cid);
|
||||||
const maps = useMaps(cid);
|
const maps = useMaps(cid);
|
||||||
const calendar = useCalendar(cid);
|
const calendar = useCalendar(cid);
|
||||||
|
const quests = useQuests(cid);
|
||||||
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||||
const activeMapId = useUiStore((s) => s.activeMapId);
|
const activeMapId = useUiStore((s) => s.activeMapId);
|
||||||
const activeHandout = useUiStore((s) => s.activeHandout);
|
const activeHandout = useUiStore((s) => s.activeHandout);
|
||||||
@@ -31,8 +32,8 @@ export function useSessionBroadcaster(campaign: Campaign | null): void {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (role !== 'gm' || status !== 'connected' || !campaign) return;
|
if (role !== 'gm' || status !== 'connected' || !campaign) return;
|
||||||
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout });
|
const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout, quests });
|
||||||
debouncedPush(snapshot);
|
debouncedPush(snapshot);
|
||||||
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl);
|
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl);
|
||||||
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, activeHandout, debouncedPush]);
|
}, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, activeHandout, quests, debouncedPush]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Check, ChevronRight, ArrowUp } from 'lucide-react';
|
||||||
|
import type { Character } from '@/lib/schemas';
|
||||||
|
import { getTurnGuide, getGenericGuide } from '@/lib/assistant/actions';
|
||||||
|
|
||||||
|
/** Collapsible "What can I do?" guide for the player in-session view. */
|
||||||
|
export function ActionGuide({ character }: { character: Character }) {
|
||||||
|
const guide = getTurnGuide(character.system, character.className?.toLowerCase() ?? '') ?? getGenericGuide(character.system);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="mt-3 rounded-lg border border-line bg-surface-2">
|
||||||
|
<summary className="flex cursor-pointer select-none items-center gap-1 px-3 py-2 text-xs text-muted hover:text-ink">
|
||||||
|
<ChevronRight size={13} aria-hidden /> What can I do on my turn?
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-line px-3 pb-3 pt-2">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{guide.actions.map((a) => (
|
||||||
|
<li key={a.label}>
|
||||||
|
<span className="text-xs font-semibold text-ink">{a.label}: </span>
|
||||||
|
<span className="text-xs text-muted">{a.desc}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{guide.upgradeAt && character.level < guide.upgradeAt.level && (
|
||||||
|
<p className="mt-2 flex items-center gap-1 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
|
||||||
|
<ArrowUp size={12} aria-hidden /> {guide.upgradeAt.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{guide.upgradeAt && character.level >= guide.upgradeAt.level && (
|
||||||
|
<p className="mt-2 rounded-md border border-success/30 bg-success/5 px-2 py-1 text-xs text-success">
|
||||||
|
<Check size={12} className="mr-1 inline" aria-hidden /> {guide.upgradeAt.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{guide.tip && (
|
||||||
|
<p className="mt-2 border-t border-line pt-2 text-xs italic text-muted">{guide.tip}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Sparkles, BrainCircuit, Minus, Plus, X } from 'lucide-react';
|
||||||
import type { Character, Defenses } from '@/lib/schemas';
|
import type { Character, Defenses } from '@/lib/schemas';
|
||||||
|
import { castSpell, dropConcentration } from '@/lib/mechanics';
|
||||||
|
import { ActionGuide } from './ActionGuide';
|
||||||
import { rollDice, applyRollMode } from '@/lib/dice/notation';
|
import { rollDice, applyRollMode } from '@/lib/dice/notation';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { useRollStore } from '@/stores/rollStore';
|
import { useRollStore } from '@/stores/rollStore';
|
||||||
import { sendPlayerRoll } from '@/lib/sync/wsSync';
|
import { sendPlayerRoll } from '@/lib/sync/wsSync';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Meter } from '@/components/ui/Codex';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
||||||
@@ -19,6 +23,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
|
|
||||||
const hpPct = c.hp.max > 0 ? clamp((c.hp.current / c.hp.max) * 100, 0, 100) : 0;
|
const hpPct = c.hp.max > 0 ? clamp((c.hp.current / c.hp.max) * 100, 0, 100) : 0;
|
||||||
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
|
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
|
||||||
|
const [castMsg, setCastMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
|
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
|
||||||
@@ -27,6 +32,14 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
<span className="text-sm text-muted">Lv {c.level} {c.className} · {c.ancestry}</span>
|
<span className="text-sm text-muted">Lv {c.level} {c.className} · {c.ancestry}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{c.concentration && (
|
||||||
|
<div className="mb-3 flex items-center gap-2 rounded-md border border-accent/50 bg-accent/10 px-3 py-1.5 text-sm">
|
||||||
|
<BrainCircuit size={15} className="shrink-0 text-accent" aria-hidden />
|
||||||
|
<span className="text-ink">Concentrating on <span className="font-medium">{c.concentration.spellName}</span></span>
|
||||||
|
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => onPatch(dropConcentration(c))}>Drop</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<div className="rounded-lg border border-line bg-panel p-3">
|
<div className="rounded-lg border border-line bg-panel p-3">
|
||||||
@@ -34,8 +47,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
<span>Hit points</span>
|
<span>Hit points</span>
|
||||||
<span className="tabular-nums" data-testid="my-hp">{c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</span>
|
<span className="tabular-nums" data-testid="my-hp">{c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 h-3 overflow-hidden rounded-full bg-surface">
|
<div className="mb-2">
|
||||||
<div className={cn('h-full', hpPct > 50 ? 'bg-success' : hpPct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${hpPct}%` }} />
|
<Meter value={c.hp.current} max={c.hp.max} tone={hpPct > 50 ? 'var(--app-verdigris)' : hpPct > 0 ? 'var(--app-accent)' : 'var(--app-danger)'} height={10} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<Button size="sm" variant="danger" onClick={() => setHp(c.hp.current - 5)}>−5</Button>
|
<Button size="sm" variant="danger" onClick={() => setHp(c.hp.current - 5)}>−5</Button>
|
||||||
@@ -44,8 +57,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
<Button size="sm" variant="primary" onClick={() => setHp(c.hp.current + 5)}>+5</Button>
|
<Button size="sm" variant="primary" onClick={() => setHp(c.hp.current + 5)}>+5</Button>
|
||||||
<span className="mx-1 h-4 w-px bg-line" />
|
<span className="mx-1 h-4 w-px bg-line" />
|
||||||
<span className="text-xs text-muted">Temp</span>
|
<span className="text-xs text-muted">Temp</span>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}>−</Button>
|
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}><Minus size={14} aria-hidden /></Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}>+</Button>
|
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}><Plus size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,8 +74,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<Pips label="Death saves ✓" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
|
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
|
||||||
<Pips label="Death saves ✗" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
|
<Pips label="Death saves (fails)" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
|
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
|
||||||
<label className="flex items-center gap-1"><input type="checkbox" checked={c.defenses.inspiration} onChange={(e) => setDef({ inspiration: e.target.checked })} /> Inspiration</label>
|
<label className="flex items-center gap-1"><input type="checkbox" checked={c.defenses.inspiration} onChange={(e) => setDef({ inspiration: e.target.checked })} /> Inspiration</label>
|
||||||
@@ -93,6 +106,28 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Spells — cast enforces slot consumption + concentration */}
|
||||||
|
{c.spellcasting.spells.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-line bg-panel p-3">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-ink">Spells</div>
|
||||||
|
<div className="max-h-44 space-y-1 overflow-y-auto">
|
||||||
|
{[...c.spellcasting.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name)).map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-12 shrink-0 text-xs text-muted">{s.level === 0 ? 'Cant' : `Lv ${s.level}`}</span>
|
||||||
|
<span className="flex-1 truncate text-ink">
|
||||||
|
{s.name}
|
||||||
|
{s.concentration && <BrainCircuit size={11} className="ml-1 inline text-accent" aria-label="Concentration" />}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { const r = castSpell(c, s.id); if (r.ok) { onPatch(r.patch); setCastMsg(r.log.join(' ')); } else setCastMsg(r.reason); }} aria-label={`Cast ${s.name}`}>
|
||||||
|
<Sparkles size={12} aria-hidden /> Cast
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{castMsg && <p className="mt-2 text-xs text-muted" aria-live="polite">{castMsg}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Resources */}
|
{/* Resources */}
|
||||||
{c.resources.length > 0 && (
|
{c.resources.length > 0 && (
|
||||||
<div className="rounded-lg border border-line bg-panel p-3">
|
<div className="rounded-lg border border-line bg-panel p-3">
|
||||||
@@ -114,6 +149,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
{/* Dice */}
|
{/* Dice */}
|
||||||
<DiceBox characterId={c.id} />
|
<DiceBox characterId={c.id} />
|
||||||
</div>
|
</div>
|
||||||
|
<ActionGuide character={c} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,9 +158,9 @@ function Counter({ label, value, min, max, onChange, suffix }: { label: string;
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{label && <span className="flex-1 text-xs text-muted">{label}</span>}
|
{label && <span className="flex-1 text-xs text-muted">{label}</span>}
|
||||||
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}>−</Button>
|
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}><Minus size={14} aria-hidden /></Button>
|
||||||
<span className="w-8 text-center tabular-nums text-ink">{value}{suffix ?? ''}</span>
|
<span className="w-8 text-center tabular-nums text-ink">{value}{suffix ?? ''}</span>
|
||||||
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}>+</Button>
|
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}><Plus size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,8 +194,8 @@ function ConditionsBox({ character: c, onPatch }: { character: Character; onPatc
|
|||||||
<div className="mb-2 flex flex-wrap gap-1">
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
{c.conditions.length === 0 && <span className="text-xs text-muted">None.</span>}
|
{c.conditions.length === 0 && <span className="text-xs text-muted">None.</span>}
|
||||||
{c.conditions.map((cond, i) => (
|
{c.conditions.map((cond, i) => (
|
||||||
<button key={i} onClick={() => remove(i)} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
|
<button key={i} onClick={() => remove(i)} className="inline-flex items-center gap-1 rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
|
||||||
{cond.name}{cond.value ? ` ${cond.value}` : ''} ✕
|
{cond.name}{cond.value ? ` ${cond.value}` : ''} <X size={11} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Mail, Eye, Map as MapIcon, Users, Swords } from 'lucide-react';
|
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle, Play } from 'lucide-react';
|
||||||
|
import type { SystemId } from '@/lib/rules';
|
||||||
import type { Snapshot } from '@/lib/sync/messages';
|
import type { Snapshot } from '@/lib/sync/messages';
|
||||||
import type { PlayerMap } from '@/lib/map';
|
import type { PlayerMap } from '@/lib/map';
|
||||||
|
import { useConditionGlossary } from '@/features/combat/useConditionGlossary';
|
||||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||||
import { EmptyState } from '@/components/ui/Page';
|
import { EmptyState } from '@/components/ui/Page';
|
||||||
import { Meter, Badge } from '@/components/ui/Codex';
|
import { Meter, Badge } from '@/components/ui/Codex';
|
||||||
@@ -10,6 +12,8 @@ import { PlayerMapView } from './PlayerMapView';
|
|||||||
/** Presentational player view: battle map + party + initiative, from a snapshot. */
|
/** Presentational player view: battle map + party + initiative, from a snapshot. */
|
||||||
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
|
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
|
||||||
const { party, encounter, map, handout } = snapshot;
|
const { party, encounter, map, handout } = snapshot;
|
||||||
|
const quests = snapshot.quests ?? [];
|
||||||
|
const condGlossary = useConditionGlossary((snapshot.system ?? '5e') as SystemId);
|
||||||
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
||||||
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
|
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
|
||||||
return (
|
return (
|
||||||
@@ -17,7 +21,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
{privateHandout && (
|
{privateHandout && (
|
||||||
<section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
|
<section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
|
||||||
<div className="smallcaps mb-1 flex items-center gap-1.5 text-info">
|
<div className="smallcaps mb-1 flex items-center gap-1.5 text-info">
|
||||||
<Mail size={13} aria-hidden /> 📨 Just for you
|
<Mail size={13} aria-hidden /> Just for you
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
|
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
|
||||||
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
|
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
|
||||||
@@ -73,9 +77,12 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
<div className="mt-1 text-right font-mono text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
|
<div className="mt-1 text-right font-mono text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
|
||||||
{c.conditions.length > 0 && (
|
{c.conditions.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{c.conditions.map((cond, i) => (
|
{c.conditions.map((cond, i) => {
|
||||||
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
const tooltip = condGlossary.get(cond.name.toLowerCase());
|
||||||
))}
|
return (
|
||||||
|
<span key={i} title={tooltip || undefined} className="cursor-help rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +104,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
<li key={c.id} className={cn('flex items-center gap-3 rounded-xl border px-3 py-2', c.isCurrent ? 'border-accent bg-accent-glow ring-1 ring-accent/40' : 'border-line bg-panel')}>
|
<li key={c.id} className={cn('flex items-center gap-3 rounded-xl border px-3 py-2', c.isCurrent ? 'border-accent bg-accent-glow ring-1 ring-accent/40' : 'border-line bg-panel')}>
|
||||||
<span className="w-8 text-center font-mono text-lg font-semibold text-accent-deep">{c.initiative}</span>
|
<span className="w-8 text-center font-mono text-lg font-semibold text-accent-deep">{c.initiative}</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
|
||||||
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent">◀ turn</span>}</span>
|
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 inline-flex items-center gap-0.5 text-xs text-accent"><Play size={11} aria-hidden /> turn</span>}</span>
|
||||||
{c.conditions.map((cond, i) => (
|
{c.conditions.map((cond, i) => (
|
||||||
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||||
))}
|
))}
|
||||||
@@ -113,6 +120,34 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{quests.length > 0 && (
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
|
||||||
|
<ScrollText size={13} aria-hidden /> Quest log
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{quests.map((q, qi) => (
|
||||||
|
<div key={qi} className={cn('rounded-xl border border-line bg-panel p-3', q.completed && 'opacity-70')}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{q.completed ? <CheckCircle2 size={15} className="text-success" aria-hidden /> : <Circle size={15} className="text-accent" aria-hidden />}
|
||||||
|
<span className={cn('font-display font-semibold text-ink', q.completed && 'line-through')}>{q.title}</span>
|
||||||
|
</div>
|
||||||
|
{q.objectives.length > 0 && (
|
||||||
|
<ul className="mt-1.5 space-y-0.5 pl-1 text-sm">
|
||||||
|
{q.objectives.map((o, oi) => (
|
||||||
|
<li key={oi} className={cn('flex items-start gap-1.5', o.done ? 'text-muted line-through' : 'text-ink')}>
|
||||||
|
{o.done ? <CheckCircle2 size={13} className="mt-0.5 shrink-0 text-success" aria-hidden /> : <Circle size={13} className="mt-0.5 shrink-0 text-faint" aria-hidden />}
|
||||||
|
<span>{o.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import { charactersRepo } from '@/lib/db/repositories';
|
|||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||||
import { useEncounters } from '@/features/combat/hooks';
|
import { useEncounters } from '@/features/combat/hooks';
|
||||||
import { useCalendar, useMaps } from '@/features/world/hooks';
|
import { useCalendar, useMaps } from '@/features/world/hooks';
|
||||||
|
import { Maximize2 } from 'lucide-react';
|
||||||
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
import { PlayerBoards } from './PlayerBoards';
|
import { PlayerBoards } from './PlayerBoards';
|
||||||
import { SeatClaimScreen } from './SeatClaimScreen';
|
import { SeatClaimScreen } from './SeatClaimScreen';
|
||||||
import { MyCharacterPanel } from './MyCharacterPanel';
|
import { MyCharacterPanel } from './MyCharacterPanel';
|
||||||
@@ -47,7 +50,7 @@ function Shell({ title, subtitle, children }: { title: string; subtitle: string;
|
|||||||
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
|
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
|
||||||
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden">⛶ Fullscreen</Button>
|
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"><Maximize2 size={14} aria-hidden /> Fullscreen</Button>
|
||||||
</div>
|
</div>
|
||||||
<hr className="gilt-rule mt-3" />
|
<hr className="gilt-rule mt-3" />
|
||||||
</div>
|
</div>
|
||||||
@@ -87,19 +90,40 @@ function NetworkedPlayerView() {
|
|||||||
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
|
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
|
||||||
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
|
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
|
||||||
const [needPw, setNeedPw] = useState(false);
|
const [needPw, setNeedPw] = useState(false);
|
||||||
|
const [pwInput, setPwInput] = useState('');
|
||||||
|
|
||||||
// Locally-developed copies of party characters (for offline-edit handoff).
|
// Locally-developed copies of party characters (for offline-edit handoff) PLUS
|
||||||
|
// the player's own PCs, so they can push one the GM hasn't added yet.
|
||||||
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
|
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
|
||||||
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
|
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
|
||||||
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
|
const myPcs = useAllPcs();
|
||||||
|
const localChars = new Map([...(localList ?? []), ...myPcs].map((c) => [c.id, c]));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error?.toLowerCase().includes('password') && !needPw) {
|
if (error?.toLowerCase().includes('password')) setNeedPw(true);
|
||||||
setNeedPw(true);
|
}, [error]);
|
||||||
const pw = window.prompt('This session needs a password:')?.trim();
|
const submitPassword = () => {
|
||||||
if (pw) { useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); setNeedPw(false); }
|
const pw = pwInput.trim();
|
||||||
}
|
if (!pw) return;
|
||||||
}, [error, needPw, room]);
|
useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw });
|
||||||
|
setPwInput('');
|
||||||
|
setNeedPw(false);
|
||||||
|
};
|
||||||
|
const passwordModal = needPw ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => setNeedPw(false)}
|
||||||
|
title="Session password"
|
||||||
|
className="max-w-sm"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setNeedPw(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" disabled={!pwInput.trim()} onClick={submitPassword}>Join</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-sm text-muted">This session is password-protected. Enter the password your GM shared.</p>
|
||||||
|
<Input type="password" autoFocus value={pwInput} onChange={(e) => setPwInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submitPassword()} aria-label="Session password" placeholder="Password" />
|
||||||
|
</Modal>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
|
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
|
||||||
const handlePatch = (diff: Partial<Character>) => {
|
const handlePatch = (diff: Partial<Character>) => {
|
||||||
@@ -113,6 +137,7 @@ function NetworkedPlayerView() {
|
|||||||
return (
|
return (
|
||||||
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
|
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
|
||||||
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting…</p>}
|
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting…</p>}
|
||||||
|
{passwordModal}
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,7 +147,7 @@ function NetworkedPlayerView() {
|
|||||||
{myCharacter && seatStatus === 'granted' ? (
|
{myCharacter && seatStatus === 'granted' ? (
|
||||||
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
|
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
|
||||||
) : (
|
) : (
|
||||||
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
<SeatClaimScreen snapshot={snapshot} localChars={localChars} ownCharacters={myPcs} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
||||||
)}
|
)}
|
||||||
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
|
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
|
||||||
<RollFeed />
|
<RollFeed />
|
||||||
|
|||||||
@@ -10,14 +10,19 @@ import { Avatar, Badge } from '@/components/ui/Codex';
|
|||||||
* party. If a locally-developed copy exists, its offline edits ride along for
|
* party. If a locally-developed copy exists, its offline edits ride along for
|
||||||
* the GM to review.
|
* the GM to review.
|
||||||
*/
|
*/
|
||||||
export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
|
export function SeatClaimScreen({ snapshot, localChars, ownCharacters = [], pending, onClaim }: {
|
||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
localChars: Map<string, Character>;
|
localChars: Map<string, Character>;
|
||||||
|
/** the player's own local characters — they can push one the GM hasn't added yet */
|
||||||
|
ownCharacters?: Character[];
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
onClaim: (characterId: string) => void;
|
onClaim: (characterId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (snapshot.party.length === 0) {
|
const partyIds = new Set(snapshot.party.map((p) => p.id));
|
||||||
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />;
|
const pushable = ownCharacters.filter((c) => c.kind === 'pc' && !partyIds.has(c.id));
|
||||||
|
|
||||||
|
if (snapshot.party.length === 0 && pushable.length === 0) {
|
||||||
|
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters — make one of your own and it'll appear here to bring to the table." />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
|
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
|
||||||
@@ -25,6 +30,8 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
|
|||||||
<h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2>
|
<h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2>
|
||||||
<p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
|
<p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
|
||||||
<hr className="gilt-rule my-3" />
|
<hr className="gilt-rule my-3" />
|
||||||
|
|
||||||
|
{snapshot.party.length > 0 && (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{snapshot.party.map((c) => {
|
{snapshot.party.map((c) => {
|
||||||
const hasOffline = localChars.has(c.id);
|
const hasOffline = localChars.has(c.id);
|
||||||
@@ -41,6 +48,27 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pushable.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="smallcaps mb-1.5 text-muted">Bring your own character</h3>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{pushable.map((c) => (
|
||||||
|
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
|
||||||
|
<Avatar name={c.name} size={40} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
|
||||||
|
<div className="font-mono text-xs text-muted">Lv {c.level}{c.className ? ` · ${c.className}` : ''}</div>
|
||||||
|
<Badge tone="gold" className="mt-1"><UserPlus size={11} aria-hidden /> send to the GM</Badge>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" disabled={pending} onClick={() => onClaim(c.id)}>Use this one</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve…</p>}
|
{pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve…</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function AssistantSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Assistant (AI)</h2>
|
<h2 className="mb-2 smallcaps">Assistant (AI)</h2>
|
||||||
<p className="mb-3 max-w-2xl text-sm text-muted">
|
<p className="mb-3 max-w-2xl text-sm text-muted">
|
||||||
Optional. Bring your own provider and key to enable AI-grounded tips (encounter balancing,
|
Optional. Bring your own provider and key to enable AI-grounded tips (encounter balancing,
|
||||||
level-up routes). The assistant always works without this — it falls back to deterministic
|
level-up routes). The assistant always works without this — it falls back to deterministic
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/lib/db/db';
|
import { db } from '@/lib/db/db';
|
||||||
import { cloudUsername, CloudError } from '@/lib/cloud/client';
|
import { cloudUsername, CloudError } from '@/lib/cloud/client';
|
||||||
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns';
|
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns';
|
||||||
import { useCampaigns } from '@/features/campaigns/hooks';
|
import { useCampaigns } from '@/features/campaigns/hooks';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -38,7 +38,7 @@ export function CloudCampaigns() {
|
|||||||
if (!signedIn) {
|
if (!signedIn) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-line bg-panel p-4">
|
<section className="rounded-lg border border-line bg-panel p-4">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Shared campaigns (cloud)</h2>
|
<h2 className="mb-2 smallcaps">Shared campaigns (cloud)</h2>
|
||||||
<p className="text-sm text-muted">Sign in above to publish a campaign for your players, or join one and publish your character — it stays yours and syncs when you edit it.</p>
|
<p className="text-sm text-muted">Sign in above to publish a campaign for your players, or join one and publish your character — it stays yours and syncs when you edit it.</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -48,7 +48,7 @@ export function CloudCampaigns() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 rounded-lg border border-line bg-panel p-4">
|
<section className="space-y-4 rounded-lg border border-line bg-panel p-4">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">Shared campaigns (cloud)</h2>
|
<h2 className="smallcaps">Shared campaigns (cloud)</h2>
|
||||||
|
|
||||||
{list.length > 0 && (
|
{list.length > 0 && (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
@@ -60,12 +60,24 @@ export function CloudCampaigns() {
|
|||||||
{c.inviteCode && (
|
{c.inviteCode && (
|
||||||
<button className="font-mono text-xs text-accent" title="Copy invite code" onClick={() => void navigator.clipboard?.writeText(c.inviteCode!).then(() => setMsg(`Invite code ${c.inviteCode} copied.`))}>invite: {c.inviteCode}</button>
|
<button className="font-mono text-xs text-accent" title="Copy invite code" onClick={() => void navigator.clipboard?.writeText(c.inviteCode!).then(() => setMsg(`Invite code ${c.inviteCode} copied.`))}>invite: {c.inviteCode}</button>
|
||||||
)}
|
)}
|
||||||
|
{c.role === 'owner' && c.inviteCode && (
|
||||||
|
<Button size="sm" variant="ghost" title="Invalidate the old code and mint a new one" onClick={run(async () => { const r = await rotateInvite(c.id); setMsg(`New invite code: ${r.inviteCode}`); refresh(); })}>Rotate invite</Button>
|
||||||
|
)}
|
||||||
{c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>}
|
{c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>}
|
||||||
</div>
|
</div>
|
||||||
{viewing === c.id && (
|
{viewing === c.id && (
|
||||||
<ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs">
|
<ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs">
|
||||||
{party.length === 0 ? <li className="text-muted">No characters published yet.</li> : party.map((p) => (
|
{party.length === 0 ? <li className="text-muted">No characters published yet.</li> : party.map((p) => (
|
||||||
<li key={p.id} className="flex justify-between"><span className="text-ink">{p.name}</span><span className="text-muted">{p.mine ? 'yours' : 'a player’s'}</span></li>
|
<li key={p.id} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-ink">{p.name}</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-muted">{p.mine ? 'yours' : 'a player’s'}</span>
|
||||||
|
{!p.mine && (
|
||||||
|
<button className="text-danger hover:underline" title="Remove this player and their characters from the campaign"
|
||||||
|
onClick={run(async () => { await removeCloudMember(c.id, p.ownerUserId); setMsg('Player removed.'); setParty(await listCloudCharacters(c.id)); })}>remove</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -114,7 +126,7 @@ export function CloudCampaigns() {
|
|||||||
|
|
||||||
{usage?.admin && (
|
{usage?.admin && (
|
||||||
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
|
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Admin · users & storage</h3>
|
<h3 className="mb-1 smallcaps">Admin · users & storage</h3>
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
|
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ function CloudSync() {
|
|||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [confirmPull, setConfirmPull] = useState(false);
|
const [confirmPull, setConfirmPull] = useState(false);
|
||||||
|
const [syncConflict, setSyncConflict] = useState(false);
|
||||||
|
|
||||||
const run = async (fn: () => Promise<void>) => {
|
const run = async (fn: () => Promise<void>) => {
|
||||||
setBusy(true); setMsg(null);
|
setBusy(true); setMsg(null);
|
||||||
@@ -196,7 +197,18 @@ function CloudSync() {
|
|||||||
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
|
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
|
||||||
setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.');
|
setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.');
|
||||||
});
|
});
|
||||||
const push = () => run(async () => { const n = await pushBackup(); setMsg(`Backed up to cloud (${Math.round(n / 1024)} KB).`); });
|
const push = () => run(async () => {
|
||||||
|
const r = await pushBackup();
|
||||||
|
if (r.ok) { setMsg(`Backed up to cloud (${Math.round(r.bytes / 1024)} KB).`); return; }
|
||||||
|
// Conflict: the cloud changed on another device since this one last synced —
|
||||||
|
// surface a themed choice instead of a native confirm.
|
||||||
|
setSyncConflict(true);
|
||||||
|
});
|
||||||
|
const resolveConflict = (which: 'cloud' | 'mine') => run(async () => {
|
||||||
|
setSyncConflict(false);
|
||||||
|
if (which === 'cloud') { const ok = await pullBackup(); setMsg(ok ? 'Pulled the cloud copy — reloading…' : 'No cloud backup.'); if (ok) setTimeout(() => location.reload(), 600); }
|
||||||
|
else { await pushBackup({ force: true }); setMsg('Overwrote the cloud with this device.'); }
|
||||||
|
});
|
||||||
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
|
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
|
||||||
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
|
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
|
||||||
|
|
||||||
@@ -212,8 +224,8 @@ function CloudSync() {
|
|||||||
{user ? (
|
{user ? (
|
||||||
<div className="space-y-3 pt-4">
|
<div className="space-y-3 pt-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="primary" disabled={busy} onClick={push}>⬆ Back up to cloud</Button>
|
<Button variant="primary" disabled={busy} onClick={push}><Upload size={14} aria-hidden /> Back up to cloud</Button>
|
||||||
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}>⬇ Restore from cloud</Button>
|
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}><Download size={14} aria-hidden /> Restore from cloud</Button>
|
||||||
<Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
|
<Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,6 +243,15 @@ function CloudSync() {
|
|||||||
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
|
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
|
||||||
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
|
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={syncConflict} onClose={() => setSyncConflict(false)} title="Cloud changed elsewhere"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setSyncConflict(false)}>Cancel</Button>
|
||||||
|
<Button variant="secondary" onClick={() => resolveConflict('cloud')}>Use cloud version</Button>
|
||||||
|
<Button variant="primary" onClick={() => resolveConflict('mine')}>Keep this device</Button>
|
||||||
|
</>}>
|
||||||
|
<p className="text-sm text-muted">The cloud copy was changed on another device since this one last synced. <strong className="text-ink">Use cloud version</strong> replaces this device with the cloud copy; <strong className="text-ink">Keep this device</strong> overwrites the cloud with this device.</p>
|
||||||
|
</Modal>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import type { Calendar, Campaign } from '@/lib/schemas';
|
import type { Calendar, Campaign } from '@/lib/schemas';
|
||||||
import { calendarRepo } from '@/lib/db/repositories';
|
import { calendarRepo } from '@/lib/db/repositories';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
@@ -55,7 +56,7 @@ function CalendarView({ campaign }: { campaign: Campaign }) {
|
|||||||
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
|
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
|
||||||
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
|
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
|
||||||
<span className="flex-1 text-ink">{ev.title}</span>
|
<span className="flex-1 text-ink">{ev.title}</span>
|
||||||
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}>✕</button>
|
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}><X size={13} aria-hidden /></button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { ChevronRight, Shield, ScrollText, CheckCheck, Skull, Handshake, VenetianMask } from 'lucide-react';
|
import {
|
||||||
|
Brain, Users, Swords, ScrollText, VenetianMask, Target, Map, CalendarDays, Library, Hammer, Dice5, Monitor,
|
||||||
|
ChevronRight, Shield, CheckCheck, Skull, Handshake,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
|
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
|
||||||
import { diceRepo } from '@/lib/db/repositories';
|
import { diceRepo } from '@/lib/db/repositories';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
@@ -15,20 +18,21 @@ export function DashboardPage() {
|
|||||||
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
|
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINKS = [
|
type NavIcon = React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;
|
||||||
{ to: '/assistant', label: 'Assistant', icon: '🧠' },
|
const LINKS: { to: string; label: string; icon: NavIcon }[] = [
|
||||||
{ to: '/characters', label: 'Characters', icon: '🧝' },
|
{ to: '/assistant', label: 'Assistant', icon: Brain },
|
||||||
{ to: '/combat', label: 'Combat', icon: '⚔' },
|
{ to: '/characters', label: 'Characters', icon: Users },
|
||||||
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' },
|
{ to: '/combat', label: 'Combat', icon: Swords },
|
||||||
{ to: '/npcs', label: 'NPCs', icon: '🎭' },
|
{ to: '/notes', label: 'Notes & Wiki', icon: ScrollText },
|
||||||
{ to: '/quests', label: 'Quests', icon: '🗺' },
|
{ to: '/npcs', label: 'NPCs', icon: VenetianMask },
|
||||||
{ to: '/maps', label: 'Maps', icon: '🗺️' },
|
{ to: '/quests', label: 'Quests', icon: Target },
|
||||||
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
{ to: '/maps', label: 'Maps', icon: Map },
|
||||||
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
{ to: '/calendar', label: 'Calendar', icon: CalendarDays },
|
||||||
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' },
|
{ to: '/compendium', label: 'Compendium', icon: Library },
|
||||||
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
{ to: '/homebrew', label: 'Homebrew', icon: Hammer },
|
||||||
{ to: '/play', label: 'Player View', icon: '📺' },
|
{ to: '/dice', label: 'Dice', icon: Dice5 },
|
||||||
] as const;
|
{ to: '/play', label: 'Player View', icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
function Dashboard({ campaign }: { campaign: Campaign }) {
|
function Dashboard({ campaign }: { campaign: Campaign }) {
|
||||||
const characters = useCharacters(campaign.id);
|
const characters = useCharacters(campaign.id);
|
||||||
@@ -84,7 +88,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
|
|||||||
to={l.to}
|
to={l.to}
|
||||||
className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow"
|
className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow"
|
||||||
>
|
>
|
||||||
<span className="text-2xl" aria-hidden>{l.icon}</span>
|
<l.icon size={24} className="text-muted" aria-hidden />
|
||||||
<span className="text-sm text-ink">{l.label}</span>
|
<span className="text-sm text-ink">{l.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -159,7 +163,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
|
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
|
||||||
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
|
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) });
|
||||||
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
||||||
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
|
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import type { Campaign, Homebrew, HomebrewKind } from '@/lib/schemas';
|
import type { Campaign, Homebrew, HomebrewKind } from '@/lib/schemas';
|
||||||
import { homebrewSchema } from '@/lib/schemas';
|
import { homebrewSchema } from '@/lib/schemas';
|
||||||
import { homebrewRepo } from '@/lib/db/repositories';
|
import { homebrewRepo } from '@/lib/db/repositories';
|
||||||
@@ -77,7 +79,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) {
|
|||||||
if (list.length === 0) return null;
|
if (list.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section key={k}>
|
<section key={k}>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{HOMEBREW_KIND_LABEL[k]}s</h2>
|
<h2 className="mb-2 smallcaps">{HOMEBREW_KIND_LABEL[k]}s</h2>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)}
|
{list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)}
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +104,7 @@ function HomebrewCard({ hb }: { hb: Homebrew }) {
|
|||||||
<div className="rounded-lg border border-line bg-panel p-4">
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
|
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}>✕</Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}><X size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
|
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ function NoteEditor({
|
|||||||
|
|
||||||
{/* Rendered preview with clickable links */}
|
{/* Rendered preview with clickable links */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Preview</h3>
|
<h3 className="mb-1 smallcaps">Preview</h3>
|
||||||
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
|
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
|
||||||
{splitWikiLinks(body).map((part, i) =>
|
{splitWikiLinks(body).map((part, i) =>
|
||||||
part.link ? (
|
part.link ? (
|
||||||
@@ -172,7 +172,7 @@ function NoteEditor({
|
|||||||
|
|
||||||
{backlinks.length > 0 && (
|
{backlinks.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Linked from</h3>
|
<h3 className="mb-1 smallcaps">Linked from</h3>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{backlinks.map((n) => (
|
{backlinks.map((n) => (
|
||||||
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
|
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Wand2, X } from 'lucide-react';
|
||||||
import type { Campaign, Npc } from '@/lib/schemas';
|
import type { Campaign, Npc } from '@/lib/schemas';
|
||||||
import { npcsRepo } from '@/lib/db/repositories';
|
import { npcsRepo } from '@/lib/db/repositories';
|
||||||
|
import { getSystem } from '@/lib/rules';
|
||||||
|
import { complete } from '@/lib/llm/client';
|
||||||
|
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||||
|
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
|
||||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
import { useNpcs } from './hooks';
|
import { useNpcs } from './hooks';
|
||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
@@ -33,7 +38,7 @@ function Npcs({ campaign }: { campaign: Campaign }) {
|
|||||||
<>
|
<>
|
||||||
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
|
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{filtered.map((n) => <NpcCard key={n.id} npc={n} />)}
|
{filtered.map((n) => <NpcCard key={n.id} npc={n} campaign={campaign} />)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -41,13 +46,39 @@ function Npcs({ campaign }: { campaign: Campaign }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NpcCard({ npc }: { npc: Npc }) {
|
function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
|
||||||
const [n, setN] = useState(npc);
|
const [n, setN] = useState(npc);
|
||||||
|
const [genState, setGenState] = useState<'idle' | 'loading'>('idle');
|
||||||
|
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||||
|
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||||
|
const canUseLlm = llmEnabled && hasKey;
|
||||||
|
|
||||||
const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, {
|
const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, {
|
||||||
name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
|
name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
|
||||||
}), 350);
|
}), 350);
|
||||||
const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; });
|
const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; });
|
||||||
|
|
||||||
|
const generateDetails = async () => {
|
||||||
|
setGenState('loading');
|
||||||
|
const ctx = {
|
||||||
|
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
|
||||||
|
systemLabel: getSystem(campaign.system).label,
|
||||||
|
questsSummary: 'No active quests.',
|
||||||
|
notesSummary: 'No notes yet.',
|
||||||
|
};
|
||||||
|
const hint = [n.name, n.role].filter(Boolean).join(', ');
|
||||||
|
let result: NpcQuick | null = null;
|
||||||
|
if (canUseLlm) {
|
||||||
|
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
|
||||||
|
const res = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
|
||||||
|
if (res.ok && 'data' in res) result = res.data;
|
||||||
|
}
|
||||||
|
if (!result) result = fallbackNpc();
|
||||||
|
const desc = `Motivation: ${result.motivation}\n\nSecret: ${result.secret}\n\nIntro hook: ${result.hook}`;
|
||||||
|
update({ description: desc, role: result.role || n.role });
|
||||||
|
setGenState('idle');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-line bg-panel p-4">
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -55,7 +86,10 @@ function NpcCard({ npc }: { npc: Npc }) {
|
|||||||
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
|
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
|
||||||
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
|
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}>✕</Button>
|
<Button size="sm" variant="ghost" disabled={genState === 'loading'} onClick={() => void generateDetails()} title="Fill description with generated details" aria-label="Generate NPC details">
|
||||||
|
<Wand2 size={13} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}><X size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||||
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
|
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import type { Campaign, Quest, Objective } from '@/lib/schemas';
|
import type { Campaign, Quest, Objective } from '@/lib/schemas';
|
||||||
import { questsRepo } from '@/lib/db/repositories';
|
import { questsRepo } from '@/lib/db/repositories';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
|
import { complete } from '@/lib/llm/client';
|
||||||
|
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||||
|
import { buildQuestHookPrompt, questHookSchema, fallbackQuestHook, type QuestHook } from '@/lib/assistant/session';
|
||||||
|
import { buildCampaignContext } from '@/lib/assistant/context';
|
||||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
import { useQuests } from './hooks';
|
import { useQuests } from './hooks';
|
||||||
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
|
import { useNotes } from '@/features/world/hooks';
|
||||||
|
import { useEncounters } from '@/features/combat/hooks';
|
||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -20,15 +29,51 @@ export function QuestsPage() {
|
|||||||
|
|
||||||
function Quests({ campaign }: { campaign: Campaign }) {
|
function Quests({ campaign }: { campaign: Campaign }) {
|
||||||
const quests = useQuests(campaign.id);
|
const quests = useQuests(campaign.id);
|
||||||
|
const characters = useCharacters(campaign.id);
|
||||||
|
const notes = useNotes(campaign.id);
|
||||||
|
const encounters = useEncounters(campaign.id);
|
||||||
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
|
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
|
||||||
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
|
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
|
||||||
|
const [genBusy, setGenBusy] = useState(false);
|
||||||
|
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||||
|
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||||
|
const canUseLlm = llmEnabled && hasKey;
|
||||||
|
|
||||||
|
const generateQuest = async () => {
|
||||||
|
setGenBusy(true);
|
||||||
|
try {
|
||||||
|
let hook: QuestHook | null = null;
|
||||||
|
if (canUseLlm) {
|
||||||
|
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes });
|
||||||
|
const { system, user } = buildQuestHookPrompt(ctx);
|
||||||
|
const res = await complete<QuestHook>(getLlmConfig(), { system, user, schema: questHookSchema });
|
||||||
|
if (res.ok && 'data' in res) hook = res.data;
|
||||||
|
}
|
||||||
|
if (!hook) hook = fallbackQuestHook();
|
||||||
|
const created = await questsRepo.create(campaign.id, hook.title);
|
||||||
|
await questsRepo.update(created.id, {
|
||||||
|
description: hook.description,
|
||||||
|
reward: hook.reward,
|
||||||
|
objectives: hook.objectives.map((text) => ({ id: newId(), text, done: false })),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setGenBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Quests"
|
title="Quests"
|
||||||
subtitle={campaign.name}
|
subtitle={campaign.name}
|
||||||
actions={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>}
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" disabled={genBusy} onClick={() => void generateQuest()}>
|
||||||
|
{genBusy ? 'Generating…' : 'Generate quest hook'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{quests.length === 0 ? (
|
{quests.length === 0 ? (
|
||||||
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />
|
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />
|
||||||
@@ -65,7 +110,7 @@ function QuestCard({ quest }: { quest: Quest }) {
|
|||||||
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
|
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
|
||||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}>✕</Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -81,7 +126,7 @@ function QuestCard({ quest }: { quest: Quest }) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">
|
<div className="mb-1 smallcaps">
|
||||||
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
|
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@@ -93,7 +138,7 @@ function QuestCard({ quest }: { quest: Quest }) {
|
|||||||
value={o.text}
|
value={o.text}
|
||||||
onChange={(e) => setObjective(o.id, { text: e.target.value })}
|
onChange={(e) => setObjective(o.id, { text: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective">✕</button>
|
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"><X size={13} aria-hidden /></button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
|
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
|
||||||
ChevronRight, type LucideIcon,
|
ChevronRight, ChevronLeft, Play, type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
|
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
|
||||||
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
|
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
|
||||||
@@ -53,6 +53,8 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
|
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
|
||||||
|
|
||||||
const [tool, setTool] = useState<Tool>('move');
|
const [tool, setTool] = useState<Tool>('move');
|
||||||
|
const [textDraft, setTextDraft] = useState<MapDrawing['points'][number] | null>(null);
|
||||||
|
const [textValue, setTextValue] = useState('');
|
||||||
const [fogShape, setFogShape] = useState<FogShape>('brush');
|
const [fogShape, setFogShape] = useState<FogShape>('brush');
|
||||||
const [brush, setBrush] = useState(0);
|
const [brush, setBrush] = useState(0);
|
||||||
const [aoeShape, setAoeShape] = useState<AoeShape>('circle');
|
const [aoeShape, setAoeShape] = useState<AoeShape>('circle');
|
||||||
@@ -167,10 +169,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
}
|
}
|
||||||
if (tool === 'draw') {
|
if (tool === 'draw') {
|
||||||
if (drawKind === 'text') {
|
if (drawKind === 'text') {
|
||||||
if (phase === 'down') {
|
if (phase === 'down') setTextDraft(p.point);
|
||||||
const text = window.prompt('Label text:')?.trim();
|
|
||||||
if (text) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [p.point], color: drawColor, width: 4, text, gmOnly: gmDraw }] });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (phase === 'down') { draftPts.current = [p.point]; }
|
if (phase === 'down') { draftPts.current = [p.point]; }
|
||||||
@@ -222,6 +221,11 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
|
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
|
||||||
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
|
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
|
||||||
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
|
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
|
||||||
|
const addTextLabel = () => {
|
||||||
|
const t = textValue.trim();
|
||||||
|
if (t && textDraft) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [textDraft], color: drawColor, width: 4, text: t, gmOnly: gmDraw }] });
|
||||||
|
setTextDraft(null); setTextValue('');
|
||||||
|
};
|
||||||
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
|
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
|
||||||
const moveToken = (id: string, col: number, row: number) => setM((prev) => {
|
const moveToken = (id: string, col: number, row: number) => setM((prev) => {
|
||||||
const tokens = prev.tokens.map((t) => (t.id === id ? { ...t, col, row } : t));
|
const tokens = prev.tokens.map((t) => (t.id === id ? { ...t, col, row } : t));
|
||||||
@@ -329,9 +333,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
{activeEncounter && (
|
{activeEncounter && (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
|
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
|
||||||
<span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
|
<span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
|
||||||
<span className="font-display font-semibold text-ink">▶ {activeCombatant?.name ?? '—'}</span>
|
<span className="flex items-center gap-1 font-display font-semibold text-ink"><Play size={14} aria-hidden /> {activeCombatant?.name ?? '—'}</span>
|
||||||
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}>‹ Prev</Button>
|
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}><ChevronLeft size={14} aria-hidden /> Prev</Button>
|
||||||
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn ›</Button>
|
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn <ChevronRight size={14} aria-hidden /></Button>
|
||||||
{activeTokenId ? <span className="text-xs text-muted">— their token glows on the map</span> : <span className="text-xs text-muted">— place their token from the palette</span>}
|
{activeTokenId ? <span className="text-xs text-muted">— their token glows on the map</span> : <span className="text-xs text-muted">— place their token from the palette</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -402,6 +406,13 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
<Modal open={!!textDraft} onClose={() => { setTextDraft(null); setTextValue(''); }} title="Text label" className="max-w-sm"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => { setTextDraft(null); setTextValue(''); }}>Cancel</Button>
|
||||||
|
<Button variant="primary" disabled={!textValue.trim()} onClick={addTextLabel}>Add</Button>
|
||||||
|
</>}>
|
||||||
|
<Input autoFocus value={textValue} onChange={(e) => setTextValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTextLabel()} aria-label="Label text" placeholder="Label text" />
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
|
|||||||
return (
|
return (
|
||||||
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Tokens</h3>
|
<h3 className="smallcaps">Tokens</h3>
|
||||||
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">‹ Hide</button>
|
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">Hide</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/** Deterministic, per-class action guide for the player "What can I do?" panel. */
|
||||||
|
export interface ActionStep {
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnGuide {
|
||||||
|
actions: ActionStep[];
|
||||||
|
/** Optional level gate (shown when level < minLevel). */
|
||||||
|
upgradeAt?: { level: number; note: string };
|
||||||
|
/** Reminder shown at all levels. */
|
||||||
|
tip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 5e guides ----
|
||||||
|
|
||||||
|
const GUIDE_5E: Record<string, TurnGuide> = {
|
||||||
|
barbarian: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Attack — if you aren\'t raging yet, use a Bonus Action to Rage first (it\'s free this turn).' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Rage (once per combat, free resources). At level 2: Reckless Attack costs no action — just declare it on your first attack roll.' },
|
||||||
|
{ label: 'Movement', desc: 'Your speed is 30 ft. While raging you can\'t cast spells or concentrate, but you\'re nigh-unstoppable.' },
|
||||||
|
{ label: 'Reaction', desc: 'Opportunity Attack — if an enemy leaves your reach, you can attack as a reaction.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 5, note: 'At level 5 you gain Extra Attack — make two attacks on your Attack action.' },
|
||||||
|
tip: 'Rage gives resistance to Bludgeoning, Piercing, and Slashing damage. Never fight without it if you expect more than one hit.',
|
||||||
|
},
|
||||||
|
bard: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Cast a spell (Vicious Mockery to impose disadvantage costs nothing but a cantrip slot), or use Help.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Bardic Inspiration — give a d6 to an ally; they can add it to any roll within the next 10 minutes. At level 5 it becomes a d8.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft. Stay at range — you have light armour and low hit points.' },
|
||||||
|
{ label: 'Reaction', desc: 'Cutting Words (once per short rest, subtract your Inspiration die from an enemy\'s attack, check, or damage roll).' },
|
||||||
|
],
|
||||||
|
tip: 'Bardic Inspiration is your most powerful tool early on. Give it out before initiative is rolled when possible.',
|
||||||
|
},
|
||||||
|
cleric: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Cast a spell — Sacred Flame (cantrip, radiant damage) costs no slots. Use higher-level slots for Guiding Bolt or Spirit Guardians.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Healing Word is a bonus-action heal — save it to pick up a downed ally. Better than Cure Wounds in most combats.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft. Channel Divinity is a free action, not a bonus action — use it on the same turn as a spell.' },
|
||||||
|
{ label: 'Reaction', desc: 'Shield of Faith (concentration) boosts an ally\'s AC as your Action; your Reaction is free for Opportunity Attacks.' },
|
||||||
|
],
|
||||||
|
tip: 'Healing Word as a Bonus Action to revive downed allies, then attack or cast on your main Action. Don\'t burn all your spell slots healing mid-combat.',
|
||||||
|
},
|
||||||
|
druid: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Cast a spell (Shillelagh makes your staff a Wisdom-based melee weapon) or use Wild Shape to become a beast.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Wild Shape is a Bonus Action — transform mid-round without losing your main Action. Your gear melds into your new form.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft (or the Wild Shape form\'s speed — most animals are faster). Wild Shape HP is separate from yours; it\'s a free buffer.' },
|
||||||
|
{ label: 'Reaction', desc: 'No unique reaction early on. Use Opportunity Attacks in melee or cast Absorb Elements to halve elemental damage.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 2, note: 'At level 2 you unlock Wild Shape. Use the CR ¼ CR ½ beast forms as frontline tanks — when they run out of HP you revert, unharmed.' },
|
||||||
|
tip: 'Wild Shape HP is a free buffer on top of your own. Get hit in beast form, then revert and cast heals if needed.',
|
||||||
|
},
|
||||||
|
fighter: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Attack — one attack until level 5. Choose a Fighting Style at level 1 (Great Weapon Master, Archery, Defence, Dueling, etc.).' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Second Wind (once per short rest) — recover 1d10 + Fighter level HP. At level 3 some subclasses add bonus action attacks (Battle Master, Champion).' },
|
||||||
|
{ label: 'Movement', desc: '30 ft (35 ft in medium armour with the Mobility feat). You can split movement before and after an attack.' },
|
||||||
|
{ label: 'Reaction', desc: 'Opportunity Attack — make one attack against any creature that moves out of your reach. Strongly consider the Sentinel feat later.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 5, note: 'Level 5: Extra Attack — make two attacks per Attack action. Level 11: Three attacks. Level 20: Four attacks.' },
|
||||||
|
tip: 'You have the most Action Surge uses and the broadest weapon/armour access. Don\'t forget Second Wind between fights — it\'s free.',
|
||||||
|
},
|
||||||
|
monk: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Attack — use Dexterity with unarmed strikes and monk weapons (short sword, any simple weapon). Flurry of Blows can add 2 more hits per turn.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Flurry of Blows (1 ki — hit twice), Step of the Wind (1 ki — Dash or Disengage), or Patient Defense (1 ki — Dodge).' },
|
||||||
|
{ label: 'Movement', desc: '35 ft at level 1, increasing 5 ft every 4 levels. You don\'t need to engage by running in a straight line — use your speed creatively.' },
|
||||||
|
{ label: 'Reaction', desc: 'Deflect Missiles — catch or redirect incoming ranged weapon attacks. At level 3: Stunning Strike (2 ki on a hit) makes a target lose their next turn on a failed Con save.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 5, note: 'Level 5: Extra Attack + Stunning Strike. A Stunning Strike on a melee hit removes the target\'s next action and gives advantage to all attacks against them.' },
|
||||||
|
tip: 'Ki points replenish on a short rest. Use them every short rest — hoarding ki is a common new-player mistake.',
|
||||||
|
},
|
||||||
|
paladin: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Attack. After a hit you can expend a spell slot as a Bonus Action to add Divine Smite (2d8 radiant per slot level, doubled on crits — save slots for crits).' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Divine Smite after a hit (your choice, after you see the roll). Lay on Hands is an Action. Healing using a spell slot is also an Action.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft in heavy armour. Your Auras (level 6+) are passive — they don\'t cost actions, they just work.' },
|
||||||
|
{ label: 'Reaction', desc: 'Opportunity Attack. At level 6: Aura of Protection adds your Charisma modifier to all saving throws for nearby allies — no action needed.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 5, note: 'Level 5: Extra Attack + 2nd-level spell slots. Smiting with a 2nd-level slot adds 3d8 radiant — devastating on a crit.' },
|
||||||
|
tip: 'Divine Smite is declared after you know the attack hit and whether it\'s a crit (2× dice on a crit). Save your highest spell slots for confirmed crits.',
|
||||||
|
},
|
||||||
|
ranger: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Attack at range or melee. Mark a creature as your Favoured Enemy for bonus damage. Hunter\'s Mark (concentration, Bonus Action to recast) adds 1d6 per hit.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Cast Hunter\'s Mark (1st-level spell, concentration), or move it to a new target when the old one dies.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft. Ranger subclasses often grant additional Bonus Actions at level 3 (Hunter: Horde Breaker; Beastmaster: command your companion).' },
|
||||||
|
{ label: 'Reaction', desc: 'Opportunity Attack. At level 7: Evasion — half damage (zero on a success) vs area attacks that target Dexterity saves.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 5, note: 'Level 5: Extra Attack. Combine with Hunter\'s Mark for consistent damage every turn.' },
|
||||||
|
tip: 'Hunter\'s Mark is concentration — don\'t cast other concentration spells while it\'s active. It moves for free when a target drops, no spell slot needed.',
|
||||||
|
},
|
||||||
|
rogue: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Attack — one attack per turn (use finesse or ranged weapons for Dexterity-based Sneak Attack). Sneak Attack triggers once per turn when you have advantage or an ally is adjacent to your target.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Cunning Action (level 2): Dash, Disengage, or Hide as a Bonus Action. Disengage means you can walk away without triggering Opportunity Attacks.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft. Use Disengage (Cunning Action) to safely retreat after attacking from range or melee.' },
|
||||||
|
{ label: 'Reaction', desc: 'Uncanny Dodge (level 5): use your Reaction to halve damage from one attack per round. Evasion (level 7): half/no damage from Dexterity saves.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 2, note: 'Level 2: Cunning Action unlocks the Rogue\'s unique mobility. Disengage every turn after attacking to avoid Opportunity Attacks.' },
|
||||||
|
tip: 'You only need Sneak Attack once per turn but it can trigger on Opportunity Attacks too. Set up flanking (adjacent ally) to guarantee advantage.',
|
||||||
|
},
|
||||||
|
sorcerer: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Cast a spell. Fire Bolt and Ray of Frost are damage cantrips that cost no slots. Chromatic Orb (1st slot) deals 3d8 of any element — strong early choice.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Quicken Spell (2 Sorcery Points): cast a spell as a Bonus Action instead of an Action — lets you attack twice in one turn with two spells.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft. Stay far from enemies — use Misty Step (2nd-level slot) to teleport 30 ft if surrounded.' },
|
||||||
|
{ label: 'Reaction', desc: 'Counterspell (3rd-level slot, level 6): cancel a spell being cast within 60 ft. No other unique reaction early on.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 3, note: 'Level 3: Metamagic unlocks. Twinned Spell (double target, 1 point per spell level) and Empowered Spell (1 point to reroll damage dice) are often picked first.' },
|
||||||
|
tip: 'Sorcery Points convert to extra spell slots (1 short rest). Don\'t spend all your Sorcery Points on Metamagic — keep some for emergency slots.',
|
||||||
|
},
|
||||||
|
warlock: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Cast a spell or use Eldritch Blast (cantrip, force damage, scales to 4 beams at level 17). Eldritch Blast + Agonizing Blast invocation is your go-to attack.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Hex (1st-level slot, concentration): choose an ability, target has disadvantage on checks with it, and you add 1d6 necrotic per hit. Move it when the target dies for free.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft. Your spell slots recharge on a short rest — take short rests as often as the group allows to keep up output.' },
|
||||||
|
{ label: 'Reaction', desc: 'No unique early reaction. Devil\'s Sight invocation lets you see in magical darkness — combine with Darkness spell for advantage on all attacks.' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 5, note: 'Level 5: 3rd-level spell slots (still only 2). Hypnotic Pattern and Hunger of Hadar are crowd-control standouts at this level.' },
|
||||||
|
tip: 'Your spell slots are few but powerful — always at your highest slot level. Short-rest classes (Monk, Fighter) decide short rest frequency; advocate for them if your DM forgets.',
|
||||||
|
},
|
||||||
|
wizard: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Cast a spell. Fire Bolt and Chill Touch are free cantrips. Hold your best slots (Fireball, Hypnotic Pattern) for fights that matter.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'No universal bonus action. Misty Step (2nd-level) uses an Action, not a Bonus Action. Some subclass features add bonus action options.' },
|
||||||
|
{ label: 'Movement', desc: '30 ft. Stand behind your party and out of reach. Blur and Mirror Image are Concentration-free self-protections.' },
|
||||||
|
{ label: 'Reaction', desc: 'Counterspell (3rd-level slot): cancel a spell being cast. Shield (1st-level slot): add +5 AC until your next turn (excellent use of a reaction slot).' },
|
||||||
|
],
|
||||||
|
upgradeAt: { level: 5, note: 'Level 5: Fireball and Lightning Bolt become available. These 3rd-level spells are game-changers — conserve 3rd-level slots for them early on.' },
|
||||||
|
tip: 'Copy spells into your spellbook whenever you find scrolls or can access another wizard\'s book. The more spells you know, the more prepared you can be.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- PF2e guides ----
|
||||||
|
|
||||||
|
const GUIDE_PF2E: Record<string, TurnGuide> = {
|
||||||
|
barbarian: {
|
||||||
|
actions: [
|
||||||
|
{ label: '1 action', desc: 'Strike — make one weapon attack. In PF2e, you can Strike up to 3 times in one turn, but each additional attack takes a -5 / -10 penalty to hit.' },
|
||||||
|
{ label: '2 actions', desc: 'Enter Rage (free the first time per encounter). Most of your power comes from raging — always start combat in Rage.' },
|
||||||
|
{ label: '3 actions', desc: 'Double-Strike, Sudden Charge (move + attack), or take the Intimidate action to Demoralize enemies.' },
|
||||||
|
{ label: 'Reaction', desc: 'Attack of Opportunity (from the Reactive Strike feat — Fighters get it free; Barbarians can buy it). You can also Step away from threats as a free action when not engaged.' },
|
||||||
|
],
|
||||||
|
tip: 'In PF2e you have 3 actions and 1 reaction per round. Move, Strike, Strike is a common sequence (with -5 on the second). Rage first to maximise your bonus damage.',
|
||||||
|
},
|
||||||
|
champion: {
|
||||||
|
actions: [
|
||||||
|
{ label: '1 action', desc: 'Strike — use your deity\'s favoured weapon for extra class synergy.' },
|
||||||
|
{ label: '2 actions', desc: 'Raise a Shield (1 action) + Strike (1 action) = Block reaction available AND make an attack. Shield\'s AC bonus only applies when raised.' },
|
||||||
|
{ label: '3 actions', desc: 'Move (1) + Raise Shield (1) + Strike (1) is your bread-and-butter turn.' },
|
||||||
|
{ label: 'Reaction', desc: 'Champion\'s Reaction — Retributive Strike (Paladin) or Rescuing Reach (Liberator): when an ally within 15 ft is hit, you can interpose and reduce the damage. This is why you exist.' },
|
||||||
|
],
|
||||||
|
tip: 'Your Champion\'s Reaction is the most powerful defensive tool in PF2e. Always stay within 15 ft of the party\'s squishiest member. Raise your Shield every single turn.',
|
||||||
|
},
|
||||||
|
cleric: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Heal / Harm', desc: 'Heal at 1 action targets only you; 2 actions targets a touched creature; 3 actions is an area burst that heals all allies and damages undead. Pick based on the situation.' },
|
||||||
|
{ label: 'Cast a Spell', desc: 'Most spells take 2 actions. Divine Spellcasters have access to Harm / Heal, Bless, and weapon spells through their deity.' },
|
||||||
|
{ label: 'Channel Smite', desc: '2 actions: Strike AND add a Harm/Heal charge to the hit (1 + spell rank damage). Extremely efficient if you want to be in melee.' },
|
||||||
|
{ label: 'Reaction', desc: 'No class reaction at level 1. Take a shield if you want a Block reaction — it matters against single big hits.' },
|
||||||
|
],
|
||||||
|
tip: 'The 3-action Heal burst is PF2e\'s most efficient healing: one 3-action spell heals every ally in range. Decide at the start of combat whether you\'re healing or offending that round.',
|
||||||
|
},
|
||||||
|
fighter: {
|
||||||
|
actions: [
|
||||||
|
{ label: '1 action', desc: 'Strike — Fighters have the highest weapon proficiency in the game (Legendary by level 17).' },
|
||||||
|
{ label: '2 actions', desc: 'Power Attack (level 1 feat) — Strike for extra damage with a -2 MAP. Double Slice (two weapon) or Knockdown (felling blow). Use one of these every turn early on.' },
|
||||||
|
{ label: '3 actions', desc: 'Sudden Charge (2 actions — move 2× speed + Strike), or Intimidating Strike (Strike + Demoralize in one action at level 10).' },
|
||||||
|
{ label: 'Reaction', desc: 'Reactive Strike — hit any creature that uses a manipulate action, Stands Up, or moves through your reach. Extremely valuable; use it every time it triggers.' },
|
||||||
|
],
|
||||||
|
tip: 'Your Reactive Strike is a free extra attack every single round. Build around triggering it: hold position, let enemies come to you, and punish movement.',
|
||||||
|
},
|
||||||
|
ranger: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Hunt Prey', desc: '1 action at the start of each combat. Gives +2 to Perception checks and +2 to damage against your Prey for the whole fight. Never skip this.' },
|
||||||
|
{ label: 'Strike', desc: '1 action. Ranger\'s Flurry (level 1 optional rule) reduces MAP on second Strike by 2 — great for two-weapon and bow builds.' },
|
||||||
|
{ label: 'Spells', desc: 'You get a small list of ranger spells from level 1 if you take the Spellcasting archetype (or the Druidic Ranger subclass). Many have 1-minute durations — prep before combat.' },
|
||||||
|
{ label: 'Reaction', desc: 'No class reaction at level 1. Consider the Attack of Opportunity feat chain for melee rangers. Evasive Arrow (ranged) can redirect enemy fire at level 12.' },
|
||||||
|
],
|
||||||
|
tip: 'Hunt Prey first, always. Your target has essentially -2 to -4 to their effective AC relative to you. If your Prey drops, use the free Hunt Prey reaction to pick a new one.',
|
||||||
|
},
|
||||||
|
rogue: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Strike', desc: 'Sneak Attack (+1d6 per 2 rogue levels) triggers on any flat-footed target. Flanking (you and an ally both adjacent to the target) makes the target flat-footed.' },
|
||||||
|
{ label: 'Feint', desc: '1 action — Deception check vs target\'s Perception DC. On success: flat-footed to you until end of your next turn. Enables Sneak Attack solo.' },
|
||||||
|
{ label: 'Skill Actions', desc: 'Tumble Through (Acrobatics), Steal, Disable Device, Recall Knowledge — Rogues have more skill actions than anyone. Use them every turn.' },
|
||||||
|
{ label: 'Reaction', desc: 'Nimble Dodge (level 1) — add +2 to AC once per round as a Reaction when attacked. Better than many class reactions.' },
|
||||||
|
],
|
||||||
|
tip: 'Sneak Attack requires flat-footed — arrange flanking at the start of every fight. Nimble Dodge protects you when flanking fails.',
|
||||||
|
},
|
||||||
|
sorcerer: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Cast a Spell (2 actions)', desc: 'Most spells cost 2 actions. Haste is 2 actions on an ally — one of the best buffs in PF2e. Burning Hands, Fear, and Magic Missile are strong early options.' },
|
||||||
|
{ label: 'Cast a Spell (1 action)', desc: 'Cantrips and some focus spells can be cast for 1 action (with limitations). A cantrip each turn fills your 3rd action after moving and casting.' },
|
||||||
|
{ label: 'Blood Magic', desc: 'Your bloodline grants a Focus Pool and a Focus Spell. These replenish on 10-minute refocuses — use them freely.' },
|
||||||
|
{ label: 'Reaction', desc: 'No class reaction. Contingency (level 14 wizard spell) is the gold standard; until then, just move away from danger.' },
|
||||||
|
],
|
||||||
|
tip: 'PF2e Sorcerers cast Spontaneously — pick fewer spells but cast them at any level. Choose flexible options like Fear (scales well) over highly situational picks.',
|
||||||
|
},
|
||||||
|
wizard: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Cast a Spell (2 actions)', desc: 'Most spells cost 2 actions. At level 1, Grease (difficult terrain in an area), Magic Missile (no roll), and Fear are excellent. Save your best slots for fights that demand them.' },
|
||||||
|
{ label: 'Recall Knowledge', desc: '1 action — identify a creature\'s weaknesses, immunities, and key abilities. In PF2e this is vital; knowing a troll regenerates fire damage prevents a wasted round.' },
|
||||||
|
{ label: 'Cantrip', desc: 'Electric Arc hits two adjacent targets for 1d4+INT — the best damage cantrip in PF2e. Use it when you don\'t have a better target for a spell slot.' },
|
||||||
|
{ label: 'Reaction', desc: 'Counterspell — expend a prepared copy of a spell to cancel the same spell cast against you. Prepare Counterspell-worthy copies of any spell the enemy might cast.' },
|
||||||
|
],
|
||||||
|
tip: 'Prepare Recall Knowledge on at least one party member (via the Identifying feature or ask your DM). In PF2e, knowing resistances and weaknesses is almost as valuable as a damage spell.',
|
||||||
|
},
|
||||||
|
monk: {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Flurry of Blows', desc: '1 action — Strike twice in 1 action (first at -0, second at -4 MAP instead of the usual -5). Your signature move — use it every turn.' },
|
||||||
|
{ label: 'Ki Strike', desc: '1 action (focus spell) — Strike + add d6 force damage. Replenish with 10-minute refocus.' },
|
||||||
|
{ label: 'Stunning Fist', desc: '1 action (focus, 1 focus point) — Strike + Constitution save. On failure: Stunned 1 (lost 1 action next turn). An action-economy nightmare for enemies.' },
|
||||||
|
{ label: 'Reaction', desc: 'Deflect Arrow (level 2 feat) — reduce ranged weapon damage by 1d10+DEX with a Reaction. Consider the Crane Stance for +1 AC Reaction-free.' },
|
||||||
|
],
|
||||||
|
tip: 'Monks are an action-economy class — Flurry of Blows gives 2 Strikes for 1 action. Build your kit around actions that generate value: Flurry, a focus spell, and a positioning move.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTurnGuide(system: string, classSlug: string): TurnGuide | undefined {
|
||||||
|
const guides = system === 'pf2e' ? GUIDE_PF2E : GUIDE_5E;
|
||||||
|
return guides[classSlug.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic guide when no class-specific data is available. */
|
||||||
|
export function getGenericGuide(system: string): TurnGuide {
|
||||||
|
if (system === 'pf2e') {
|
||||||
|
return {
|
||||||
|
actions: [
|
||||||
|
{ label: '1 action', desc: 'Strike — make one weapon or unarmed attack.' },
|
||||||
|
{ label: '2 actions', desc: 'Most spells, Raise Shield, or a 2-action activity from your class.' },
|
||||||
|
{ label: '3 actions', desc: 'Move and still act: 1 Stride (1 action) + 2-action activity, or use a 3-action spell.' },
|
||||||
|
{ label: 'Reaction', desc: 'Attack of Opportunity (if you have it), Block (with a raised shield), or your class-specific reaction.' },
|
||||||
|
],
|
||||||
|
tip: 'PF2e gives you 3 actions and 1 reaction per round. Actions carry a Multiple Attack Penalty (MAP): -5 on the 2nd Strike, -10 on the 3rd.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action', desc: 'Attack, Cast a Spell, Dash, Dodge, Help, or Use an Object.' },
|
||||||
|
{ label: 'Bonus Action', desc: 'Class or spell abilities that cost a Bonus Action (e.g. second-wind, certain spells). You only have 1 per turn.' },
|
||||||
|
{ label: 'Movement', desc: 'Move up to your speed at any point during your turn. You can split it before and after your Action.' },
|
||||||
|
{ label: 'Reaction', desc: 'Opportunity Attack when an enemy leaves your reach, or a spell like Shield / Counterspell if you have one prepared.' },
|
||||||
|
],
|
||||||
|
tip: 'You get one Action, one possible Bonus Action, movement, and a Reaction each round. Reactions are used on other people\'s turns.',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -94,4 +94,38 @@ describe('suggestReinforcements', () => {
|
|||||||
it('returns null for an empty encounter (caller builds fresh instead)', () => {
|
it('returns null for an empty encounter (caller builds fresh instead)', () => {
|
||||||
expect(suggestReinforcements('5e', [3, 3], [], pool, 'Hard')).toBeNull();
|
expect(suggestReinforcements('5e', [3, 3], [], pool, 'Hard')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suggests removing monsters when the fight is already over the target', () => {
|
||||||
|
// 3 Ogres (cr 2) vs two level-3 PCs is far above Deadly; target Deadly should
|
||||||
|
// ease the fight DOWN rather than add more.
|
||||||
|
const existing = [
|
||||||
|
{ name: 'Ogre', cr: 2 }, { name: 'Ogre 2', cr: 2 }, { name: 'Ogre 3', cr: 2 },
|
||||||
|
];
|
||||||
|
const plan = suggestReinforcements('5e', [3, 3], existing, pool, 'Deadly');
|
||||||
|
expect(plan).not.toBeNull();
|
||||||
|
expect(plan!.add).toEqual([]);
|
||||||
|
expect(plan!.remove).toBeDefined();
|
||||||
|
expect(plan!.remove![0]!.name).toBe('Ogre');
|
||||||
|
expect(plan!.remove![0]!.count).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(plan!.reasoning).toMatch(/remove/i);
|
||||||
|
expect(plan!.reasoning).not.toMatch(/add/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests a swap when a single creature alone exceeds the target', () => {
|
||||||
|
// One Ogre vs two level-3 PCs already beats Medium; nothing left to remove.
|
||||||
|
const plan = suggestReinforcements('5e', [3, 3], [{ name: 'Ogre', cr: 2 }], pool, 'Medium');
|
||||||
|
expect(plan).not.toBeNull();
|
||||||
|
expect(plan!.add).toEqual([]);
|
||||||
|
expect(plan!.remove).toBeUndefined();
|
||||||
|
expect(plan!.reasoning).toMatch(/swap/i);
|
||||||
|
expect(plan!.reasoning).toMatch(/ogre/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still adds when the fight is below the target', () => {
|
||||||
|
const existing = [{ name: 'Goblin', cr: 0.25 }, { name: 'Goblin 2', cr: 0.25 }, { name: 'Goblin 3', cr: 0.25 }];
|
||||||
|
const plan = suggestReinforcements('5e', [3, 3], existing, pool, 'Hard');
|
||||||
|
expect(plan!.add.length).toBeGreaterThan(0);
|
||||||
|
expect(plan!.remove).toBeUndefined();
|
||||||
|
expect(plan!.reasoning).toMatch(/add/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type Severity = 'info' | 'warn' | 'danger';
|
|||||||
export type SuggestAction =
|
export type SuggestAction =
|
||||||
| { type: 'goto'; to: string; params?: Record<string, string>; label: string }
|
| { type: 'goto'; to: string; params?: Record<string, string>; label: string }
|
||||||
| { type: 'longRest'; characterId: string; label: string }
|
| { type: 'longRest'; characterId: string; label: string }
|
||||||
|
| { type: 'shortRest'; characterId: string; label: string }
|
||||||
|
| { type: 'completeQuest'; questId: string; label: string }
|
||||||
| { type: 'createNote'; title: string; label: string };
|
| { type: 'createNote'; title: string; label: string };
|
||||||
|
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/** Static per-class guidance shown during character creation. No AI, no async. */
|
||||||
|
export interface ClassTip {
|
||||||
|
role: string;
|
||||||
|
/** e.g. "Strength (attacks) · Charisma (spells, auras)" */
|
||||||
|
primaryAbilities: string;
|
||||||
|
beginner: boolean;
|
||||||
|
skillSuggestions: string;
|
||||||
|
abilityTip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIPS_5E: Record<string, ClassTip> = {
|
||||||
|
barbarian: {
|
||||||
|
role: 'A fearless, rage-fuelled warrior who charges into melee and soaks up punishment. Very simple to play — hit things, don\'t die.',
|
||||||
|
primaryAbilities: 'Strength (attacks, grapple) · Constitution (HP and Rage)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Athletics, Intimidation, Perception, Survival',
|
||||||
|
abilityTip: 'Put your highest score in Strength, second in Constitution. Barbarians don\'t wear heavy armour — your AC = 10 + DEX + CON, so Constitution doubly matters.',
|
||||||
|
},
|
||||||
|
bard: {
|
||||||
|
role: 'A jack-of-all-trades who inspires allies, casts spells, and out-talks everyone. Works well in any role.',
|
||||||
|
primaryAbilities: 'Charisma (spells, Bardic Inspiration) · Dexterity (AC, initiative)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Persuasion, Deception, Performance, Insight',
|
||||||
|
abilityTip: 'Charisma is everything for a Bard. Second priority: Dexterity for AC, then Constitution for concentration spells.',
|
||||||
|
},
|
||||||
|
cleric: {
|
||||||
|
role: 'A divine spellcaster who heals allies and smites enemies. Flexible — some subclasses (War, Forge) fight in the front line.',
|
||||||
|
primaryAbilities: 'Wisdom (all spells, Channel Divinity) · Strength or Dexterity (depending on build)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Medicine, Religion, Insight, Persuasion',
|
||||||
|
abilityTip: 'Wisdom first — it powers every Cleric spell. Front-line Clerics want Strength second; support Clerics want Constitution for concentration.',
|
||||||
|
},
|
||||||
|
druid: {
|
||||||
|
role: 'A nature magic caster who can transform into animals (Wild Shape). Powerful but benefits from reading the options.',
|
||||||
|
primaryAbilities: 'Wisdom (spells, Wild Shape save DC) · Constitution (concentration spells)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Nature, Perception, Survival, Insight',
|
||||||
|
abilityTip: 'Wisdom is your core stat. Constitution keeps concentration spells alive. Low Strength is fine — use Wild Shape for physical challenges.',
|
||||||
|
},
|
||||||
|
fighter: {
|
||||||
|
role: 'The most reliable front-line warrior. Gets more attacks than anyone, can wear any armour, and is very forgiving to play.',
|
||||||
|
primaryAbilities: 'Strength (melee attacks) or Dexterity (ranged / finesse builds) · Constitution (HP, Second Wind)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Athletics, Perception, Intimidation, History',
|
||||||
|
abilityTip: 'Pick a focus: heavy-armour melee (Strength + Constitution) or light-armour finesse (Dexterity + Constitution). Don\'t try to split them.',
|
||||||
|
},
|
||||||
|
monk: {
|
||||||
|
role: 'A fast unarmed martial artist who darts around the battlefield punching multiple enemies per turn.',
|
||||||
|
primaryAbilities: 'Dexterity (AC, attacks, finesse weapons) · Wisdom (Unarmored Defense, ki abilities)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Athletics, Acrobatics, Stealth, Insight',
|
||||||
|
abilityTip: 'Dexterity and Wisdom both feed your AC (= DEX + WIS) — invest in both. Ki points fuel everything; Constitution helps you take a hit while concentrating.',
|
||||||
|
},
|
||||||
|
paladin: {
|
||||||
|
role: 'A holy warrior who hits hard, heals allies, and radiates powerful protective auras. One of the strongest front-liners.',
|
||||||
|
primaryAbilities: 'Strength (melee attacks, armour) · Charisma (spells, auras, Lay on Hands, Divine Smite bonus)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Persuasion, Athletics, Religion, Insight',
|
||||||
|
abilityTip: 'You need two high stats: Strength for hitting things and Charisma for everything magical. Constitution can be your dump stat — heavy armour and d10 HP cover it.',
|
||||||
|
},
|
||||||
|
ranger: {
|
||||||
|
role: 'A skilled hunter and tracker who excels at a favoured terrain or enemy type. Best at skirmishing from range.',
|
||||||
|
primaryAbilities: 'Dexterity (ranged and finesse attacks, AC) or Strength (melee) · Wisdom (spells)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Perception, Stealth, Survival, Athletics',
|
||||||
|
abilityTip: 'Dexterity for bows and light armour is the most common build. Wisdom powers your spells — prioritise it over Charisma. Constitution for survivability.',
|
||||||
|
},
|
||||||
|
rogue: {
|
||||||
|
role: 'A sneaky striker who deals huge Sneak Attack damage on one hit per turn, plus the best skill coverage in the game.',
|
||||||
|
primaryAbilities: 'Dexterity (attacks, Sneak Attack with finesse/ranged, AC, Stealth) · Intelligence or Charisma (subclass)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Stealth, Perception, Sleight of Hand, Deception, Athletics',
|
||||||
|
abilityTip: 'Dexterity is your core stat. You only need one hit per turn for big damage — prioritise staying alive (and unseen) over maximising damage output.',
|
||||||
|
},
|
||||||
|
sorcerer: {
|
||||||
|
role: 'A blaster mage with innate magical power. Fewer spells than a Wizard but Metamagic bends the rules dramatically.',
|
||||||
|
primaryAbilities: 'Charisma (spell attacks and save DCs) · Constitution (concentration, survivability)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Persuasion, Arcana, Intimidation, Insight',
|
||||||
|
abilityTip: 'Charisma is everything. Constitution second — you are in a robe with 6 HP per level and will be targeted. Pick up the Mage Armour spell early.',
|
||||||
|
},
|
||||||
|
warlock: {
|
||||||
|
role: 'A pact-powered caster who recharges spell slots on a short rest — unique among casters. Eldritch Blast is your bread and butter.',
|
||||||
|
primaryAbilities: 'Charisma (spells, Eldritch Blast invocations) · Constitution (concentration)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Arcana, Intimidation, Deception, History',
|
||||||
|
abilityTip: 'Charisma first. Until high level you only have 1–2 spell slots — lean on Eldritch Blast with Invocations to stay useful every round.',
|
||||||
|
},
|
||||||
|
wizard: {
|
||||||
|
role: 'The most versatile spellcaster — prepare different spells each day for any situation. Deeply rewarding, needs spell knowledge.',
|
||||||
|
primaryAbilities: 'Intelligence (all spells, Arcane Recovery) · Constitution (concentration spells)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Arcana, History, Investigation, Medicine',
|
||||||
|
abilityTip: 'Intelligence powers everything. Constitution second — Concentration spells are your strongest tools and you need to maintain them after taking a hit.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIPS_PF2E: Record<string, ClassTip> = {
|
||||||
|
alchemist: {
|
||||||
|
role: 'A tinkerer who brews bombs, mutagens, and elixirs. Highly customisable but needs careful resource management between encounters.',
|
||||||
|
primaryAbilities: 'Intelligence (formula DC, items per day) · Dexterity (AC in light armour)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Crafting, Arcana, Medicine, Thievery',
|
||||||
|
},
|
||||||
|
barbarian: {
|
||||||
|
role: 'A rage-fuelled melee brawler with huge hit points. Your Instinct (animal, giant, spirit, etc.) defines your flavour and bonus.',
|
||||||
|
primaryAbilities: 'Strength (attacks) · Constitution (HP — Rage adds CON to max HP)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Athletics, Intimidation, Survival',
|
||||||
|
abilityTip: 'Strength first. Constitution second — Rage adds your Constitution modifier to HP on top of the base die. Dexterity for unarmoured builds.',
|
||||||
|
},
|
||||||
|
bard: {
|
||||||
|
role: 'A charismatic performer with occult spellcasting and composition cantrips that buff allies every round. Excellent support.',
|
||||||
|
primaryAbilities: 'Charisma (spells, composition cantrips) · Dexterity (AC, Reflex)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Performance, Deception, Diplomacy, Occultism',
|
||||||
|
abilityTip: 'Charisma is your foundation. Dexterity second for AC in light armour. Constitution for keeping concentration spells up.',
|
||||||
|
},
|
||||||
|
champion: {
|
||||||
|
role: 'A heavily armoured divine warrior whose class Reaction reduces damage taken by allies — uniquely powerful in a party.',
|
||||||
|
primaryAbilities: 'Strength (attacks) · Charisma (divine spells, Lay on Hands, Champion reaction) · Constitution (HP)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Religion, Athletics, Intimidation, Medicine',
|
||||||
|
abilityTip: 'Strength and Charisma both matter. Heavy armour covers weak Dexterity. Your Champion Reaction scales with Charisma.',
|
||||||
|
},
|
||||||
|
cleric: {
|
||||||
|
role: 'A divine spellcaster whose god determines your offensive or support role. The Divine Font gives extra heal/harm spells per day.',
|
||||||
|
primaryAbilities: 'Wisdom (spells, divine font) · Strength or Dexterity (deity weapon / unarmoured)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Religion, Medicine, Diplomacy, Insight',
|
||||||
|
abilityTip: 'Wisdom is your core stat. Warpriest Clerics add Strength; Cloistered Clerics need Constitution for sustained spellcasting.',
|
||||||
|
},
|
||||||
|
druid: {
|
||||||
|
role: 'A nature magic caster who can transform into animals or call on elemental forces. Very versatile with powerful spells.',
|
||||||
|
primaryAbilities: 'Wisdom (spells, Wild Shape save DC) · Constitution (HP in Wild Shape forms)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Nature, Survival, Medicine, Athletics',
|
||||||
|
abilityTip: 'Wisdom is your primary stat. Constitution for HP when in Wild Shape. Dexterity for AC when not transformed.',
|
||||||
|
},
|
||||||
|
fighter: {
|
||||||
|
role: 'The best weapon combatant in the game — higher proficiency than any other class. Multiple attacks and powerful stances.',
|
||||||
|
primaryAbilities: 'Strength (melee / thrown attacks) or Dexterity (ranged / finesse attacks)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Athletics, Intimidation, Acrobatics, Perception',
|
||||||
|
abilityTip: 'Pick Strength for melee or Dexterity for ranged/finesse. Constitution for hit points. Fighters don\'t need a third stat to shine.',
|
||||||
|
},
|
||||||
|
monk: {
|
||||||
|
role: 'An unarmed martial artist with extreme mobility. Ki abilities let you do things no weapon fighter can — flying kicks, stunning strikes.',
|
||||||
|
primaryAbilities: 'Strength or Dexterity (attacks and movement) · Wisdom (Unarmored Defense, ki)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Athletics, Acrobatics, Perception, Stealth',
|
||||||
|
abilityTip: 'Your AC = 10 + DEX + WIS, so both stats matter. Decide early: Strength-based martial arts or Dexterity-based finesse.',
|
||||||
|
},
|
||||||
|
oracle: {
|
||||||
|
role: 'A divine spontaneous caster with a Curse that grows as you cast higher-rank spells. Dramatic power with real tradeoffs.',
|
||||||
|
primaryAbilities: 'Charisma (all spells) · Constitution (HP, curse management)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Religion, Occultism, Diplomacy, Medicine',
|
||||||
|
abilityTip: 'Charisma first. Constitution second — your curse adds drawbacks that are harder to manage at low HP. Monitor your curse rank carefully.',
|
||||||
|
},
|
||||||
|
ranger: {
|
||||||
|
role: 'A skilled hunter and outlander. Hunt Prey gives a persistent bonus against your current target every combat — keep track of it.',
|
||||||
|
primaryAbilities: 'Dexterity (ranged attacks, AC) or Strength (melee) · Wisdom (spells)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Nature, Survival, Stealth, Athletics',
|
||||||
|
abilityTip: 'Choose a focus: ranged (Dexterity + bow) or melee (Strength + weapon). Wisdom for your ranger spells. Perception is trained free.',
|
||||||
|
},
|
||||||
|
rogue: {
|
||||||
|
role: 'A skill master and precision striker. Sneak Attack triggers when the target is Flat-Footed — positioning and flanking matter.',
|
||||||
|
primaryAbilities: 'Dexterity (attacks, AC, Stealth) · Intelligence or Charisma (Racket choice)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Stealth, Thievery, Deception, Perception, Acrobatics',
|
||||||
|
abilityTip: 'Dexterity is your core stat. Your second stat depends on your Racket: Scoundrel needs Charisma, Mastermind needs Intelligence, Thief needs Dexterity first.',
|
||||||
|
},
|
||||||
|
sorcerer: {
|
||||||
|
role: 'A spontaneous arcane or occult caster with innate power from a magical bloodline. More flexible casting than prepared classes.',
|
||||||
|
primaryAbilities: 'Charisma (all spells) · Constitution (concentration and survivability)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Arcana, Intimidation, Deception, Occultism',
|
||||||
|
abilityTip: 'Charisma is everything. Constitution second — you have light armour and will be targeted. Your bloodline gives you bonus spells automatically.',
|
||||||
|
},
|
||||||
|
swashbuckler: {
|
||||||
|
role: 'A dashing duelist who builds Panache through daring deeds, then spends it on devastating Finishers. Style and substance.',
|
||||||
|
primaryAbilities: 'Dexterity (attacks, AC) · Charisma (Panache, some class feats)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Acrobatics, Athletics, Deception, Performance',
|
||||||
|
abilityTip: 'Dexterity for everything combat-related. Charisma for Panache and social moments. Invest in whichever skill triggers your Style.',
|
||||||
|
},
|
||||||
|
witch: {
|
||||||
|
role: 'A patron-bonded spellcaster with powerful hexes and a familiar that delivers your touch spells. Protect your familiar.',
|
||||||
|
primaryAbilities: 'Intelligence, Wisdom, or Charisma (depends on patron) · Constitution for spellcasting',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Arcana, Occultism, Nature, Diplomacy',
|
||||||
|
abilityTip: 'Your patron determines your casting stat — check your patron carefully. Constitution second to keep casting without disruption.',
|
||||||
|
},
|
||||||
|
wizard: {
|
||||||
|
role: 'A prepared arcane caster with more spells over a career than any other class. Thorough preparation wins battles.',
|
||||||
|
primaryAbilities: 'Intelligence (all spells, Arcane Thesis) · Constitution (concentration spells)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Arcana, Occultism, Society, History',
|
||||||
|
abilityTip: 'Intelligence first, always. Constitution second for concentration spells. Dexterity for AC matters early when you have no armour.',
|
||||||
|
},
|
||||||
|
animist: {
|
||||||
|
role: 'A divine/spirit caster who channels apparitions for shifting spell options. Versatile but bookkeeping-heavy.',
|
||||||
|
primaryAbilities: 'Wisdom (spells, focus) · Constitution (durability)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Religion, Nature, Occultism, Medicine',
|
||||||
|
},
|
||||||
|
exemplar: {
|
||||||
|
role: 'A mythic melee striker who channels divine sparks (Ikons) into legendary feats of arms. Big, cinematic hits.',
|
||||||
|
primaryAbilities: 'Strength (attacks) · Constitution (durability)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Athletics, Religion, Intimidation, Diplomacy',
|
||||||
|
},
|
||||||
|
gunslinger: {
|
||||||
|
role: 'A firearms and crossbow specialist with devastating first-shot damage and unique reload tactics.',
|
||||||
|
primaryAbilities: 'Dexterity (attacks, AC) · Perception (initiative, deadly aim)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Crafting, Acrobatics, Stealth, Intimidation',
|
||||||
|
},
|
||||||
|
inventor: {
|
||||||
|
role: 'A gadgeteer who builds an innovation (armour, weapon, or construct) and Overdrives it for risky power spikes.',
|
||||||
|
primaryAbilities: 'Intelligence (key checks) · Constitution or Dexterity (defence)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Crafting, Arcana, Society, Athletics',
|
||||||
|
},
|
||||||
|
kineticist: {
|
||||||
|
role: 'An elemental blaster who channels kinetic gates for at-will impulses — no spell slots to track. Easy to play, hard to run dry.',
|
||||||
|
primaryAbilities: 'Constitution (impulse DC, durability)',
|
||||||
|
beginner: true,
|
||||||
|
skillSuggestions: 'Nature, Athletics, Acrobatics, Crafting',
|
||||||
|
},
|
||||||
|
magus: {
|
||||||
|
role: 'A spell-and-sword duelist who fuses a spell into a strike with Spellstrike. Bursty; manage your spell slots carefully.',
|
||||||
|
primaryAbilities: 'Strength or Dexterity (attacks) · Intelligence (spells, DC)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Arcana, Athletics, Acrobatics, Society',
|
||||||
|
},
|
||||||
|
psychic: {
|
||||||
|
role: 'An occult spontaneous caster with amped cantrips and a psyche you unleash for big turns. Few spells, huge cantrips.',
|
||||||
|
primaryAbilities: 'Charisma or Intelligence (spells, DC) · Constitution (durability)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Occultism, Arcana, Society, Intimidation',
|
||||||
|
},
|
||||||
|
summoner: {
|
||||||
|
role: 'You and your eidolon act as one — a powerful summoned ally you command. Two bodies, one shared action economy.',
|
||||||
|
primaryAbilities: 'Charisma (spells, eidolon) · Constitution (shared HP matters)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Arcana, Nature, Athletics, Diplomacy',
|
||||||
|
},
|
||||||
|
thaumaturge: {
|
||||||
|
role: 'An occult investigator who exploits monster weaknesses with esoteric implements and a tome of lore. Skill-rich and adaptable.',
|
||||||
|
primaryAbilities: 'Charisma (key checks, Exploit Vulnerability) · Constitution or Dexterity (defence)',
|
||||||
|
beginner: false,
|
||||||
|
skillSuggestions: 'Occultism, Religion, Arcana, Diplomacy',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getClassTip(system: string, classSlug: string): ClassTip | undefined {
|
||||||
|
const tips = system === 'pf2e' ? TIPS_PF2E : TIPS_5E;
|
||||||
|
return tips[classSlug.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a synergy note if the race's meta string contains ability boosts that
|
||||||
|
* match the class's key abilities. Both systems' meta formats are handled:
|
||||||
|
* 5e: "+2 DEX, +1 WIS · 30 ft"
|
||||||
|
* PF2e: "+Con/+Wis · 10 HP"
|
||||||
|
*/
|
||||||
|
export function raceSynergyNote(raceMeta: string, keyAbilities: string[]): string | null {
|
||||||
|
if (!raceMeta || !keyAbilities.length) return null;
|
||||||
|
const boosted = new Set<string>();
|
||||||
|
// Match "+2 DEX" or "+Wis" or "+Con" patterns
|
||||||
|
for (const m of raceMeta.matchAll(/\+\d*([A-Za-z]{3})/g)) {
|
||||||
|
const ab = m[1];
|
||||||
|
if (ab) boosted.add(ab.toLowerCase());
|
||||||
|
}
|
||||||
|
const matches = keyAbilities.filter((k) => boosted.has(k.toLowerCase()));
|
||||||
|
if (!matches.length) return null;
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
str: 'Strength', dex: 'Dexterity', con: 'Constitution',
|
||||||
|
int: 'Intelligence', wis: 'Wisdom', cha: 'Charisma',
|
||||||
|
};
|
||||||
|
const label = matches.map((k) => names[k] ?? k).join(' and ');
|
||||||
|
return `This ancestry boosts ${label} — a natural fit for this class's primary abilities.`;
|
||||||
|
}
|
||||||
@@ -62,6 +62,8 @@ export interface ReinforceMonster {
|
|||||||
export interface ReinforcePlan {
|
export interface ReinforcePlan {
|
||||||
reasoning: string;
|
reasoning: string;
|
||||||
add: { name: string; count: number }[];
|
add: { name: string; count: number }[];
|
||||||
|
/** Monsters to take out when the fight is already at/above the target (over-tuned). */
|
||||||
|
remove?: { name: string; count: number }[];
|
||||||
targetDifficulty: string;
|
targetDifficulty: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,18 @@ export function suggestReinforcements(
|
|||||||
const target = thresholds.find((t) => t.label.toLowerCase() === targetDifficulty.toLowerCase())?.value ?? 0;
|
const target = thresholds.find((t) => t.label.toLowerCase() === targetDifficulty.toLowerCase())?.value ?? 0;
|
||||||
if (target <= 0) return null;
|
if (target <= 0) return null;
|
||||||
|
|
||||||
|
// If the fight is already AT or ABOVE the target, it's tuned correctly or
|
||||||
|
// over-tuned — adding more would only make it harder. Suggest softening it
|
||||||
|
// instead of reinforcing it.
|
||||||
|
const currentXp = computeBudget(
|
||||||
|
system,
|
||||||
|
partyLevels,
|
||||||
|
existing.map((m) => ({ cr: m.cr, level: m.level })),
|
||||||
|
).ratingXp;
|
||||||
|
if (currentXp >= target) {
|
||||||
|
return easeReinforcements(system, partyLevels, existing, target, targetDifficulty);
|
||||||
|
}
|
||||||
|
|
||||||
const minCountToReach = (rating: number): number | null => {
|
const minCountToReach = (rating: number): number | null => {
|
||||||
for (let n = 1; n <= maxAdds; n++) {
|
for (let n = 1; n <= maxAdds; n++) {
|
||||||
const extra = Array.from({ length: n }, () => (system === '5e' ? { cr: rating } : { level: rating }));
|
const extra = Array.from({ length: n }, () => (system === '5e' ? { cr: rating } : { level: rating }));
|
||||||
@@ -157,3 +171,59 @@ export function suggestReinforcements(
|
|||||||
: `Add ${best.count}${more} ${best.name} to push this fight toward ${targetDifficulty}.`;
|
: `Add ${best.count}${more} ${best.name} to push this fight toward ${targetDifficulty}.`;
|
||||||
return { reasoning, add: [{ name: best.name, count: best.count }], targetDifficulty };
|
return { reasoning, add: [{ name: best.name, count: best.count }], targetDifficulty };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fight already meets or exceeds the target difficulty. Suggest taking out the
|
||||||
|
* fewest bodies that drops it back UNDER the target so it's no harder than intended.
|
||||||
|
* Removing creatures also shrinks the 5e encounter multiplier, so the budget is
|
||||||
|
* recomputed after each removal rather than subtracted linearly. We never empty the
|
||||||
|
* encounter: if even leaving a single creature stays at/above target, we say so and
|
||||||
|
* point at swapping for something weaker.
|
||||||
|
*/
|
||||||
|
function easeReinforcements(
|
||||||
|
system: SystemId,
|
||||||
|
partyLevels: number[],
|
||||||
|
existing: ReinforceMonster[],
|
||||||
|
target: number,
|
||||||
|
targetDifficulty: string,
|
||||||
|
): ReinforcePlan {
|
||||||
|
// Strongest first: dropping the heaviest hitters eases the fight with the fewest
|
||||||
|
// bodies removed. Stable on equal ratings so ties resolve deterministically.
|
||||||
|
const order = existing
|
||||||
|
.map((m, i) => ({ m, i, rating: ratingOf(system, m) ?? -Infinity }))
|
||||||
|
.sort((a, b) => b.rating - a.rating || a.i - b.i);
|
||||||
|
|
||||||
|
const removedCounts = new Map<string, number>();
|
||||||
|
const keep = [...order];
|
||||||
|
// Take from the front (strongest) but always leave at least one creature behind.
|
||||||
|
while (keep.length > 1) {
|
||||||
|
const dropped = keep.shift()!;
|
||||||
|
const bn = baseName(dropped.m.name);
|
||||||
|
removedCounts.set(bn, (removedCounts.get(bn) ?? 0) + 1);
|
||||||
|
const remaining = keep.map((e) => ({ cr: e.m.cr, level: e.m.level }));
|
||||||
|
if (computeBudget(system, partyLevels, remaining).ratingXp < target) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = [...removedCounts].map(([name, count]) => ({ name, count }));
|
||||||
|
const totalRemoved = remove.reduce((s, r) => s + r.count, 0);
|
||||||
|
|
||||||
|
if (totalRemoved === 0) {
|
||||||
|
// Couldn't trim anything (a single over-budget creature). Suggest a swap instead.
|
||||||
|
const solo = baseName(existing[0]!.name);
|
||||||
|
return {
|
||||||
|
reasoning:
|
||||||
|
`This fight is already at or above ${targetDifficulty}. ` +
|
||||||
|
`Swap the ${solo} for a weaker creature to soften it.`,
|
||||||
|
add: [],
|
||||||
|
targetDifficulty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const phrase = remove
|
||||||
|
.map((r) => `${r.count} ${r.name}`)
|
||||||
|
.join(' and ');
|
||||||
|
const reasoning =
|
||||||
|
`This fight is already at or above ${targetDifficulty}. ` +
|
||||||
|
`Remove ${phrase} to ease it back toward ${targetDifficulty}.`;
|
||||||
|
return { reasoning, add: [], remove, targetDifficulty };
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,4 +44,34 @@ describe('balanceSuggestionSchema', () => {
|
|||||||
expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], targetDifficulty: 'Medium' }).success).toBe(false);
|
expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], targetDifficulty: 'Medium' }).success).toBe(false);
|
||||||
expect(balanceSuggestionSchema.safeParse({ add: [], targetDifficulty: 'Medium' }).success).toBe(false);
|
expect(balanceSuggestionSchema.safeParse({ add: [], targetDifficulty: 'Medium' }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
it('coerces string counts (a common model quirk) instead of rejecting', () => {
|
||||||
|
const r = balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: '2' }], targetDifficulty: 'Medium' });
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
if (r.success) expect(r.data.add[0]!.count).toBe(2);
|
||||||
|
});
|
||||||
|
it('finds the add array whatever the model names it', () => {
|
||||||
|
// Exact shapes DeepSeek returned in the wild: "creatures", then "addCreatures".
|
||||||
|
for (const key of ['creatures', 'addCreatures', 'monsters', 'someInventedKey'] as const) {
|
||||||
|
const r = balanceSuggestionSchema.safeParse({
|
||||||
|
[key]: [{ name: 'Goblin', count: 2 }],
|
||||||
|
reasoning: 'Two goblins push this to Deadly for a lone level 1 paladin.',
|
||||||
|
targetDifficulty: 'Deadly',
|
||||||
|
});
|
||||||
|
expect(r.success, `key="${key}"`).toBe(true);
|
||||||
|
if (r.success) {
|
||||||
|
expect(r.data.add).toHaveLength(1);
|
||||||
|
expect(r.data.add[0]!.name).toBe('Goblin');
|
||||||
|
expect(r.data.add[0]!.count).toBe(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('does not mistake the remove list for additions', () => {
|
||||||
|
const r = balanceSuggestionSchema.safeParse({
|
||||||
|
remove: [{ name: 'Ogre', count: 1 }],
|
||||||
|
reasoning: 'Drop the ogre to ease this fight.',
|
||||||
|
targetDifficulty: 'Medium',
|
||||||
|
});
|
||||||
|
// No additions array at all → add is required and absent → reject.
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,54 @@ import { z } from 'zod';
|
|||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
import type { CampaignContext, CreatureCandidate } from './context';
|
import type { CampaignContext, CreatureCandidate } from './context';
|
||||||
|
|
||||||
export const balanceSuggestionSchema = z.object({
|
// Counts are coerced (models routinely return numbers as JSON strings, e.g. "2")
|
||||||
|
// and allow a wider upper bound than the prompt asks for, so a slightly
|
||||||
|
// over-eager suggestion is accepted rather than rejecting the whole response.
|
||||||
|
const monsterCount = z.coerce.number().int().min(1).max(12);
|
||||||
|
|
||||||
|
/** An array of at least one `{ name, … }` object — the shape of an additions list. */
|
||||||
|
function looksLikeCreatureList(v: unknown): v is Record<string, unknown>[] {
|
||||||
|
return Array.isArray(v) && v.length > 0 &&
|
||||||
|
v.every((x) => !!x && typeof x === 'object' && typeof (x as { name?: unknown }).name === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models keep inventing their own name for the additions array — DeepSeek has
|
||||||
|
* returned "creatures", "addCreatures", … instead of the expected "add". Rather
|
||||||
|
* than chase a fixed alias list, find the additions array structurally: a known
|
||||||
|
* synonym first, else any other top-level array of `{ name, … }` objects that
|
||||||
|
* isn't the "remove" list. Keeps a correct suggestion from being discarded over a
|
||||||
|
* key-name mismatch.
|
||||||
|
*/
|
||||||
|
function normalizeBalanceShape(raw: unknown): unknown {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
|
||||||
|
const o = { ...(raw as Record<string, unknown>) };
|
||||||
|
if (Array.isArray(o.add)) return o;
|
||||||
|
|
||||||
|
for (const alias of ['creatures', 'monsters', 'additions', 'addCreatures', 'add_creatures', 'toAdd']) {
|
||||||
|
if (looksLikeCreatureList(o[alias])) { o.add = o[alias]; return o; }
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(o)) {
|
||||||
|
if (key === 'add' || key === 'remove') continue;
|
||||||
|
if (looksLikeCreatureList(value)) { o.add = value; return o; }
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const balanceSuggestionSchema = z.preprocess(normalizeBalanceShape, z.object({
|
||||||
reasoning: z.string(),
|
reasoning: z.string(),
|
||||||
add: z.array(z.object({
|
add: z.array(z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
count: z.number().int().min(1).max(8),
|
count: monsterCount,
|
||||||
ref: z.string().optional(),
|
ref: z.string().optional(),
|
||||||
})),
|
})),
|
||||||
|
/** Monsters to take out when the fight is over-tuned (already at/above target). */
|
||||||
|
remove: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
count: monsterCount,
|
||||||
|
})).optional(),
|
||||||
targetDifficulty: z.string(),
|
targetDifficulty: z.string(),
|
||||||
});
|
}));
|
||||||
export type BalanceSuggestion = z.infer<typeof balanceSuggestionSchema>;
|
export type BalanceSuggestion = z.infer<typeof balanceSuggestionSchema>;
|
||||||
|
|
||||||
function partyLine(ctx: CampaignContext): string {
|
function partyLine(ctx: CampaignContext): string {
|
||||||
@@ -28,9 +67,14 @@ export function buildBalancePrompt(
|
|||||||
const system =
|
const system =
|
||||||
`${ctx.systemConstraint}\n\n` +
|
`${ctx.systemConstraint}\n\n` +
|
||||||
`You are an encounter-balancing assistant for a ${ctx.systemLabel} game master. ` +
|
`You are an encounter-balancing assistant for a ${ctx.systemLabel} game master. ` +
|
||||||
`Recommend which creatures to ADD to the current encounter to bring it to roughly "${current.targetDifficulty}" difficulty. ` +
|
`Recommend which creatures to add to the current encounter to bring it to roughly "${current.targetDifficulty}" difficulty. ` +
|
||||||
`You MUST choose only from the provided candidate creatures (by exact name). Do not invent creatures or use any from another system. ` +
|
`You MUST choose only from the provided candidate creatures (by exact name). Do not invent creatures or use any from another system. ` +
|
||||||
`Prefer 1–3 distinct creatures with sensible counts. Keep the reasoning to one or two sentences.`;
|
`Prefer 1–3 distinct creatures with sensible counts.\n\n` +
|
||||||
|
`Respond with a JSON object with EXACTLY these keys:\n` +
|
||||||
|
` "add": array of objects, each { "name": <exact candidate name>, "count": <integer ≥ 1> }\n` +
|
||||||
|
` "reasoning": a one or two sentence string\n` +
|
||||||
|
` "targetDifficulty": the target difficulty as a string\n` +
|
||||||
|
`Name the array "add" — not "creatures" or "monsters". Counts are plain integers (2, not "2").`;
|
||||||
|
|
||||||
const candidateList = current.candidates
|
const candidateList = current.candidates
|
||||||
.map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`)
|
.map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`)
|
||||||
@@ -41,7 +85,7 @@ export function buildBalancePrompt(
|
|||||||
`Current encounter difficulty: ${current.difficulty}.\n` +
|
`Current encounter difficulty: ${current.difficulty}.\n` +
|
||||||
`Target difficulty: ${current.targetDifficulty}.\n\n` +
|
`Target difficulty: ${current.targetDifficulty}.\n\n` +
|
||||||
`Candidate creatures (choose only from these):\n${candidateList}\n\n` +
|
`Candidate creatures (choose only from these):\n${candidateList}\n\n` +
|
||||||
`Respond with the creatures to add (name + count), a short reasoning, and the targetDifficulty.`;
|
`Return the JSON object described above (keys: "add", "reasoning", "targetDifficulty").`;
|
||||||
|
|
||||||
return { system, user };
|
return { system, user };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { CampaignContext } from './context';
|
||||||
|
|
||||||
|
// ---- Session hooks ----
|
||||||
|
|
||||||
|
export const sessionHookSchema = z.object({
|
||||||
|
hooks: z.array(z.object({
|
||||||
|
title: z.string(),
|
||||||
|
opening: z.string(),
|
||||||
|
tension: z.string(),
|
||||||
|
})).min(3).max(3),
|
||||||
|
});
|
||||||
|
export type SessionHook = z.infer<typeof sessionHookSchema>['hooks'][number];
|
||||||
|
|
||||||
|
export function buildSessionHookPrompt(ctx: CampaignContext): { system: string; user: string } {
|
||||||
|
const system =
|
||||||
|
`${ctx.systemConstraint}\n\n` +
|
||||||
|
`You are a session-prep assistant for a ${ctx.systemLabel} game master. ` +
|
||||||
|
`Generate exactly 3 distinct session opening hooks. Each hook needs a short title (3-6 words), ` +
|
||||||
|
`an opening paragraph (2-3 sentences, suitable to read aloud or adapt at the table), and a one-sentence tension note ` +
|
||||||
|
`(what makes this session interesting from a dramatic standpoint). ` +
|
||||||
|
`Ground your hooks in the party's current situation — active quests, recent encounters, NPC threads. ` +
|
||||||
|
`Vary the tone: one action-forward, one character/social, one mystery or twist.`;
|
||||||
|
|
||||||
|
const partyLine = ctx.party.map((p) => `${p.name} (level ${p.level} ${p.className})`).join(', ') || 'the party';
|
||||||
|
const user =
|
||||||
|
`Campaign context:\n` +
|
||||||
|
`Party: ${partyLine}\n` +
|
||||||
|
`Active quests: ${ctx.questsSummary}\n` +
|
||||||
|
`Notes/lore: ${ctx.notesSummary}\n` +
|
||||||
|
(ctx.recentEncounters.length ? `Recent encounters: ${ctx.recentEncounters.slice(0, 3).map((e) => `${e.name} (${e.difficulty})`).join(', ')}\n` : '') +
|
||||||
|
`\nGenerate 3 opening hooks for the next session.`;
|
||||||
|
|
||||||
|
return { system, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOOK_TEMPLATES = [
|
||||||
|
{
|
||||||
|
title: 'Into the Unknown',
|
||||||
|
opening: (q: string) =>
|
||||||
|
`The road ahead leads deeper into uncertain territory. ${q ? `The party's current task — ${q} — draws them onward, ` : 'There\'s work to be done, '}but rumours along the way hint that things may be more complicated than expected.`,
|
||||||
|
tension: 'An unexpected complication threatens to derail the party\'s immediate plans.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Old Debts',
|
||||||
|
opening: () =>
|
||||||
|
`A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They\'re not hostile, but they want something only the party can provide.`,
|
||||||
|
tension: 'What they need may directly conflict with the party\'s current goals.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Crossed Paths',
|
||||||
|
opening: () =>
|
||||||
|
`Two separate threads tangle without warning: an urgent job lands at the worst possible moment, or unexpected help arrives from a quarter no one anticipated. The party has to decide quickly which thread to pull.`,
|
||||||
|
tension: 'The party must prioritise between two competing urgent situations.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function fallbackSessionHooks(ctx: CampaignContext): SessionHook[] {
|
||||||
|
const quests = ctx.questsSummary !== 'No active quests.'
|
||||||
|
? ctx.questsSummary.replace(/^\d+ active: /, '').split('; ')
|
||||||
|
: [];
|
||||||
|
const firstQuest = quests[0] ?? '';
|
||||||
|
return HOOK_TEMPLATES.map((t) => ({
|
||||||
|
title: t.title,
|
||||||
|
opening: t.opening(firstQuest),
|
||||||
|
tension: t.tension,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NPC quick-gen ----
|
||||||
|
|
||||||
|
export const npcQuickSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
motivation: z.string(),
|
||||||
|
secret: z.string(),
|
||||||
|
hook: z.string(),
|
||||||
|
});
|
||||||
|
export type NpcQuick = z.infer<typeof npcQuickSchema>;
|
||||||
|
|
||||||
|
export function buildNpcPrompt(
|
||||||
|
ctx: Pick<CampaignContext, 'systemConstraint' | 'systemLabel' | 'questsSummary' | 'notesSummary'>,
|
||||||
|
hint?: string,
|
||||||
|
): { system: string; user: string } {
|
||||||
|
const system =
|
||||||
|
`${ctx.systemConstraint}\n\n` +
|
||||||
|
`You are an NPC generator for a ${ctx.systemLabel} game master. ` +
|
||||||
|
`Generate one vivid, playable NPC with a name, a role (short phrase: "innkeeper", "mercenary captain", "hedge mage", etc.), ` +
|
||||||
|
`a motivation (what they want right now — concrete, specific), a secret (one thing they would never willingly reveal), ` +
|
||||||
|
`and a hook (one sentence: how you introduce or use them at the table). Keep all fields concise — the GM fills in the rest.`;
|
||||||
|
|
||||||
|
const user =
|
||||||
|
`Campaign notes: ${ctx.notesSummary}. Active quests: ${ctx.questsSummary}.` +
|
||||||
|
(hint ? ` Requested type / context: ${hint}.` : '') +
|
||||||
|
` Generate one NPC.`;
|
||||||
|
|
||||||
|
return { system, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
const NPC_TEMPLATES: NpcQuick[] = [
|
||||||
|
{
|
||||||
|
name: 'Aldric Stonehaven',
|
||||||
|
role: 'Inn owner',
|
||||||
|
motivation: 'Keep his inn profitable and his guests\' troubles from spilling into his common room',
|
||||||
|
secret: 'He owes a significant debt to a local thieves\' guild and occasionally passes along information about guests',
|
||||||
|
hook: 'He nervously wipes the same glass over and over, eyes darting to the door — and quietly asks the party if they\'re looking for work',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sister Vera',
|
||||||
|
role: 'Temple healer',
|
||||||
|
motivation: 'Acquire a rare alchemical reagent to complete a cure for a sickness spreading in the lower city',
|
||||||
|
secret: 'She was once a skilled thief; the temple doesn\'t know, and she prefers it stays that way',
|
||||||
|
hook: 'She approaches the party after tending to their wounds, asking if they know anything about certain missing reagents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Captain Brenn Ashvale',
|
||||||
|
role: 'Mercenary captain',
|
||||||
|
motivation: 'Collect on a long-overdue contract before her company falls apart from unpaid wages',
|
||||||
|
secret: 'The contract she\'s chasing was supposed to be straightforward — she\'s terrified of what she found in the ruins instead',
|
||||||
|
hook: 'She\'s drinking alone in the corner, fully armoured and clearly not planning to sleep tonight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mira of the Crossroads',
|
||||||
|
role: 'Travelling merchant',
|
||||||
|
motivation: 'Reach the next city before a rival merchant corners the market on her rare goods',
|
||||||
|
secret: 'The "rare goods" she\'s carrying were lifted from a noble\'s estate under ambiguous circumstances',
|
||||||
|
hook: 'She flagged down the party on the road, offering a discount on supplies in exchange for travelling together — she doesn\'t want to be alone',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function fallbackNpc(): NpcQuick {
|
||||||
|
return NPC_TEMPLATES[Math.floor(Math.random() * NPC_TEMPLATES.length)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Quest hook gen ----
|
||||||
|
|
||||||
|
export const questHookSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
reward: z.string(),
|
||||||
|
objectives: z.array(z.string()).min(1).max(4),
|
||||||
|
});
|
||||||
|
export type QuestHook = z.infer<typeof questHookSchema>;
|
||||||
|
|
||||||
|
export function buildQuestHookPrompt(ctx: CampaignContext): { system: string; user: string } {
|
||||||
|
const system =
|
||||||
|
`${ctx.systemConstraint}\n\n` +
|
||||||
|
`You are a quest designer for a ${ctx.systemLabel} game master. ` +
|
||||||
|
`Generate one compelling quest: a title (5 words or fewer), a 2–3 sentence description for the GM (not a read-aloud — a situation summary), ` +
|
||||||
|
`a reward line (gold amount, item, or favour — be specific), and 2–4 concrete objectives as short imperative phrases.` +
|
||||||
|
`The quest should be completable in one session. Connect it to the existing campaign notes or active quests if possible.`;
|
||||||
|
|
||||||
|
const partyAvgLevel = ctx.partyLevels.length
|
||||||
|
? Math.round(ctx.partyLevels.reduce((a, b) => a + b, 0) / ctx.partyLevels.length)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const user =
|
||||||
|
`Party average level: ${partyAvgLevel}. Active quests: ${ctx.questsSummary}. Notes: ${ctx.notesSummary}.\n` +
|
||||||
|
`Generate one new side quest appropriate for this party.`;
|
||||||
|
|
||||||
|
return { system, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUEST_TEMPLATES: QuestHook[] = [
|
||||||
|
{
|
||||||
|
title: 'The Missing Merchant',
|
||||||
|
description: 'A local merchant departed three days ago for a nearby town and never arrived. His family has received no word and fears the worst. The road is not normally dangerous, which makes the disappearance all the stranger.',
|
||||||
|
reward: '150 gp and the merchant\'s gratitude (discount on future trade goods)',
|
||||||
|
objectives: ['Investigate the merchant\'s last known stop', 'Search the road between settlements', 'Return the merchant safely — or discover what happened'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trouble at the Mill',
|
||||||
|
description: 'The grain mill that feeds the town has fallen silent. The miller and his apprentices vanished two nights ago. Locals report strange lights and sounds from the millpond after dark — something is in the water.',
|
||||||
|
reward: '100 gp from the town council and free lodging for a month',
|
||||||
|
objectives: ['Investigate the mill and surrounding area', 'Discover what is lurking in the millpond', 'Ensure the mill is safe to operate again'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'A Stolen Heirloom',
|
||||||
|
description: 'A noble family\'s ancestral signet ring was taken by a skilled thief during a dinner party. Every guest is a suspect. The family will pay handsomely to recover it quietly — and even more to know who took it.',
|
||||||
|
reward: '200 gp and a favour from a noble house',
|
||||||
|
objectives: ['Question the guests and staff', 'Identify the thief through investigation', 'Recover the signet ring'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Old Ruins, New Danger',
|
||||||
|
description: 'Explorers opened a sealed chamber in nearby ruins and haven\'t returned. Their employer wants to know what happened and whether the contents of the chamber are still accessible. The ruins were sealed for a reason.',
|
||||||
|
reward: 'A share of whatever the explorers found, plus 100 gp per party member',
|
||||||
|
objectives: ['Enter the ruins through the explorers\' route', 'Locate the sealed chamber', 'Deal with whatever the explorers disturbed', 'Report back to the employer'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function fallbackQuestHook(): QuestHook {
|
||||||
|
return QUEST_TEMPLATES[Math.floor(Math.random() * QUEST_TEMPLATES.length)]!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { characterDefaults, type Character, type Quest } from '@/lib/schemas';
|
||||||
|
import { questSignals, casterSignals, buildSignals } from './signals';
|
||||||
|
|
||||||
|
function pc(over: Partial<Character> = {}): Character {
|
||||||
|
return {
|
||||||
|
id: 'pc', campaignId: 'c', system: '5e', kind: 'pc', name: 'Mage', ancestry: '', className: 'Wizard', level: 5,
|
||||||
|
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
|
||||||
|
hp: { current: 30, max: 30, temp: 0 }, speed: 30, armorBonus: 0,
|
||||||
|
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||||
|
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function quest(over: Partial<Quest> = {}): Quest {
|
||||||
|
return { id: 'q', campaignId: 'c', title: 'Find the relic', status: 'active', description: '', reward: '', objectives: [], createdAt: '', updatedAt: '', ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('questSignals', () => {
|
||||||
|
it('offers to complete a quest whose objectives are all done', () => {
|
||||||
|
const q = quest({ objectives: [{ id: 'o1', text: 'a', done: true }, { id: 'o2', text: 'b', done: true }] });
|
||||||
|
const [s] = questSignals([q]);
|
||||||
|
expect(s?.action).toEqual({ type: 'completeQuest', questId: 'q', label: 'Mark complete' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays quiet when an objective is unfinished or there are none', () => {
|
||||||
|
expect(questSignals([quest({ objectives: [{ id: 'o1', text: 'a', done: false }] })])).toEqual([]);
|
||||||
|
expect(questSignals([quest({ objectives: [] })])).toEqual([]);
|
||||||
|
expect(questSignals([quest({ status: 'completed', objectives: [{ id: 'o1', text: 'a', done: true }] })])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('casterSignals', () => {
|
||||||
|
it('flags a caster with all slots expended and offers a short rest', () => {
|
||||||
|
const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 0 }, { level: 2, max: 2, current: 0 }], spells: [] } });
|
||||||
|
const sig = casterSignals([c]).find((s) => s.id === 'no-slots-pc');
|
||||||
|
expect(sig?.action).toEqual({ type: 'shortRest', characterId: 'pc', label: 'Short rest' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag when some slots remain', () => {
|
||||||
|
const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 1 }], spells: [] } });
|
||||||
|
expect(casterSignals([c]).some((s) => s.id === 'no-slots-pc')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when concentrating while bloodied', () => {
|
||||||
|
const c = pc({ hp: { current: 10, max: 30, temp: 0 }, concentration: { spellId: 's', spellName: 'Bless' } });
|
||||||
|
expect(casterSignals([c]).some((s) => s.id === 'conc-risk-pc')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSignals', () => {
|
||||||
|
it('merges detectors, de-dupes, and sorts danger → warn → info', () => {
|
||||||
|
const downed = pc({ id: 'a', name: 'A', hp: { current: 0, max: 30, temp: 0 } });
|
||||||
|
const noSlots = pc({ id: 'b', name: 'B', spellcasting: { slots: [{ level: 1, max: 2, current: 0 }], spells: [] } });
|
||||||
|
const out = buildSignals({ characters: [downed, noSlots], notes: [], quests: [], activeEncounter: undefined });
|
||||||
|
expect(out.length).toBeGreaterThan(0);
|
||||||
|
// severity is non-decreasing (danger=0 first)
|
||||||
|
const ranks = out.map((s) => ({ danger: 0, warn: 1, info: 2 })[s.severity]);
|
||||||
|
expect(ranks).toEqual([...ranks].sort((x, y) => x - y));
|
||||||
|
// unique ids
|
||||||
|
expect(new Set(out.map((s) => s.id)).size).toBe(out.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Character, Encounter, Note, Quest } from '@/lib/schemas';
|
||||||
|
import {
|
||||||
|
resourceSuggestions,
|
||||||
|
planningSuggestions,
|
||||||
|
combatSuggestions,
|
||||||
|
type Suggestion,
|
||||||
|
} from './advisors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Signals engine: pure detectors over campaign + combat + character state
|
||||||
|
* that emit actionable, one-click proposals. This is the "buttons, not chatbot"
|
||||||
|
* layer — it recognizes a situation and offers the fix; the user never has to
|
||||||
|
* formulate a request. It builds on the existing advisor detectors and adds
|
||||||
|
* kernel-aware ones now that combat/spellcasting state is enforced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A quest whose objectives are all done but is still marked active → offer to complete it. */
|
||||||
|
export function questSignals(quests: Quest[]): Suggestion[] {
|
||||||
|
const out: Suggestion[] = [];
|
||||||
|
for (const q of quests) {
|
||||||
|
if (q.status !== 'active' || q.objectives.length === 0) continue;
|
||||||
|
if (q.objectives.every((o) => o.done)) {
|
||||||
|
out.push({
|
||||||
|
id: `quest-done-${q.id}`,
|
||||||
|
category: 'planning',
|
||||||
|
severity: 'info',
|
||||||
|
title: `“${q.title}” is finished`,
|
||||||
|
detail: 'Every objective is checked off — mark the quest complete?',
|
||||||
|
action: { type: 'completeQuest', questId: q.id, label: 'Mark complete' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spellcasters who have burned through their slots, and concentration-at-risk nudges. */
|
||||||
|
export function casterSignals(characters: Character[]): Suggestion[] {
|
||||||
|
const out: Suggestion[] = [];
|
||||||
|
for (const c of characters) {
|
||||||
|
if (c.kind !== 'pc' || c.hp.current <= 0) continue;
|
||||||
|
|
||||||
|
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
|
||||||
|
if (slots.length > 0 && slots.every((s) => s.current === 0)) {
|
||||||
|
out.push({
|
||||||
|
id: `no-slots-${c.id}`,
|
||||||
|
category: 'resources',
|
||||||
|
severity: 'warn',
|
||||||
|
title: `${c.name} is out of spell slots`,
|
||||||
|
detail: 'No slots remaining — a rest would restore them.',
|
||||||
|
action: { type: 'shortRest', characterId: c.id, label: 'Short rest' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concentration while bloodied is fragile — surface it so the GM keeps it in mind.
|
||||||
|
if (c.concentration && c.hp.max > 0 && c.hp.current < c.hp.max * 0.5) {
|
||||||
|
out.push({
|
||||||
|
id: `conc-risk-${c.id}`,
|
||||||
|
category: 'combat',
|
||||||
|
severity: 'info',
|
||||||
|
title: `${c.name} is concentrating on ${c.concentration.spellName} while bloodied`,
|
||||||
|
detail: 'Damage will force a Constitution save — watch for it.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_RANK: Record<Suggestion['severity'], number> = { danger: 0, warn: 1, info: 2 };
|
||||||
|
|
||||||
|
export interface BuildSignalsInput {
|
||||||
|
characters: Character[];
|
||||||
|
notes: Note[];
|
||||||
|
quests: Quest[];
|
||||||
|
activeEncounter?: Encounter | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate every detector into one severity-sorted, de-duplicated list — the
|
||||||
|
* single source the ambient signals UI and the Assistant page both render.
|
||||||
|
*/
|
||||||
|
export function buildSignals(input: BuildSignalsInput): Suggestion[] {
|
||||||
|
const all = [
|
||||||
|
...combatSuggestions(input.activeEncounter),
|
||||||
|
...casterSignals(input.characters),
|
||||||
|
...resourceSuggestions(input.characters),
|
||||||
|
...questSignals(input.quests),
|
||||||
|
...planningSuggestions(input.notes, input.quests),
|
||||||
|
];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = all.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
|
||||||
|
return deduped.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
||||||
|
}
|
||||||
@@ -40,6 +40,14 @@ export function publishCharacter(campaignId: string, character: { id: string; na
|
|||||||
export function listCloudCharacters(campaignId: string): Promise<CloudCharInfo[]> {
|
export function listCloudCharacters(campaignId: string): Promise<CloudCharInfo[]> {
|
||||||
return req<CloudCharInfo[]>(`/campaigns/${campaignId}/characters`);
|
return req<CloudCharInfo[]>(`/campaigns/${campaignId}/characters`);
|
||||||
}
|
}
|
||||||
|
/** Owner-only: invalidate the old invite code and get a fresh one. */
|
||||||
|
export function rotateInvite(campaignId: string): Promise<{ inviteCode: string }> {
|
||||||
|
return req<{ inviteCode: string }>(`/campaigns/${campaignId}/invite`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
/** Owner-only: remove a player and their published characters from the campaign. */
|
||||||
|
export function removeCloudMember(campaignId: string, userId: string): Promise<{ ok: boolean }> {
|
||||||
|
return req<{ ok: boolean }>(`/campaigns/${campaignId}/members/${userId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
export async function cloudUsageBytes(): Promise<number> {
|
export async function cloudUsageBytes(): Promise<number> {
|
||||||
return (await req<{ bytes: number }>('/usage')).bytes;
|
return (await req<{ bytes: number }>('/usage')).bytes;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-5
@@ -8,12 +8,17 @@ import { buildBackup, restoreBackup } from '@/lib/io/backup';
|
|||||||
|
|
||||||
const TOKEN_KEY = 'ttrpg-cloud-token';
|
const TOKEN_KEY = 'ttrpg-cloud-token';
|
||||||
const USER_KEY = 'ttrpg-cloud-user';
|
const USER_KEY = 'ttrpg-cloud-user';
|
||||||
|
const SYNCED_KEY = 'ttrpg-cloud-synced-at';
|
||||||
const base = () => `${location.origin}/api`;
|
const base = () => `${location.origin}/api`;
|
||||||
|
|
||||||
export function cloudUsername(): string | null { return localStorage.getItem(USER_KEY); }
|
export function cloudUsername(): string | null { return localStorage.getItem(USER_KEY); }
|
||||||
function token(): string | null { return localStorage.getItem(TOKEN_KEY); }
|
function token(): string | null { return localStorage.getItem(TOKEN_KEY); }
|
||||||
function setSession(t: string, username: string): void { localStorage.setItem(TOKEN_KEY, t); localStorage.setItem(USER_KEY, username); }
|
function setSession(t: string, username: string): void { localStorage.setItem(TOKEN_KEY, t); localStorage.setItem(USER_KEY, username); }
|
||||||
function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); }
|
function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); localStorage.removeItem(SYNCED_KEY); }
|
||||||
|
|
||||||
|
/** Version token of the cloud copy this device last synced with (for conflict detection). */
|
||||||
|
export function cloudLastSyncedAt(): number | null { const v = localStorage.getItem(SYNCED_KEY); return v ? Number(v) : null; }
|
||||||
|
function setLastSyncedAt(n: number): void { localStorage.setItem(SYNCED_KEY, String(n)); }
|
||||||
|
|
||||||
export class CloudError extends Error {}
|
export class CloudError extends Error {}
|
||||||
|
|
||||||
@@ -39,15 +44,26 @@ export async function logout(): Promise<void> {
|
|||||||
clearSession();
|
clearSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload this device's full backup to the cloud. */
|
export type PushResult = { ok: true; bytes: number } | { ok: false; conflict: true; savedAt: number };
|
||||||
export async function pushBackup(): Promise<number> {
|
|
||||||
|
/**
|
||||||
|
* Upload this device's full backup. Uses optimistic concurrency: unless `force`,
|
||||||
|
* it tells the server which version we last synced; if the cloud has moved on
|
||||||
|
* (another device pushed), the server returns a conflict and we DON'T overwrite —
|
||||||
|
* the caller resolves it (pull the cloud, or force-push to win).
|
||||||
|
*/
|
||||||
|
export async function pushBackup(opts?: { force?: boolean }): Promise<PushResult> {
|
||||||
const t = token();
|
const t = token();
|
||||||
if (!t) throw new CloudError('Not signed in.');
|
if (!t) throw new CloudError('Not signed in.');
|
||||||
const blob = JSON.stringify(await buildBackup());
|
const blob = JSON.stringify(await buildBackup());
|
||||||
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob }) });
|
const baseSavedAt = opts?.force ? undefined : cloudLastSyncedAt();
|
||||||
|
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob, baseSavedAt }) });
|
||||||
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
|
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
|
||||||
|
if (res.status === 409) { const d = (await res.json().catch(() => ({}))) as { savedAt?: number }; return { ok: false, conflict: true, savedAt: Number(d.savedAt) || 0 }; }
|
||||||
if (!res.ok) throw new CloudError('Upload failed.');
|
if (!res.ok) throw new CloudError('Upload failed.');
|
||||||
return blob.length;
|
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
|
||||||
|
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
|
||||||
|
return { ok: true, bytes: blob.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace this device's data with the cloud copy. Returns false if there's no cloud save yet. */
|
/** Replace this device's data with the cloud copy. Returns false if there's no cloud save yet. */
|
||||||
@@ -58,6 +74,8 @@ export async function pullBackup(): Promise<boolean> {
|
|||||||
if (res.status === 404) return false;
|
if (res.status === 404) return false;
|
||||||
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
|
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
|
||||||
if (!res.ok) throw new CloudError('Download failed.');
|
if (!res.ok) throw new CloudError('Download failed.');
|
||||||
|
const savedAt = Number(res.headers.get('x-saved-at'));
|
||||||
await restoreBackup(await res.text());
|
await restoreBackup(await res.text());
|
||||||
|
if (Number.isFinite(savedAt) && savedAt > 0) setLastSyncedAt(savedAt);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,27 @@ describe('HP transitions', () => {
|
|||||||
expect(applyDamage(c, Number.NaN).hp.current).toBe(10);
|
expect(applyDamage(c, Number.NaN).hp.current).toBe(10);
|
||||||
expect(applyHealing(c, -5).hp.current).toBe(10);
|
expect(applyHealing(c, -5).hp.current).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies typed-damage resistances when defenses are present', () => {
|
||||||
|
const base = { ...mk('a', 0, 20), damageDefenses: { resist: ['fire'], immune: ['poison'], vulnerable: ['cold'], conditionImmune: [], notes: [], resistFlat: [], weakness: [] } };
|
||||||
|
expect(applyDamage(base, 10, 'fire').hp.current).toBe(15); // resisted → 5
|
||||||
|
expect(applyDamage(base, 10, 'poison').hp.current).toBe(20); // immune → 0
|
||||||
|
expect(applyDamage(base, 10, 'cold').hp.current).toBe(0); // vulnerable → 20
|
||||||
|
expect(applyDamage(base, 10, 'acid').hp.current).toBe(10); // untracked type → full
|
||||||
|
expect(applyDamage(base, 10).hp.current).toBe(10); // no type → full (back-compat)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typeless damage is unchanged when no defenses are set', () => {
|
||||||
|
expect(applyDamage(mk('a', 0, 10), 4, 'fire').hp.current).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies pf2e flat weakness (adds) and resistance (subtracts)', () => {
|
||||||
|
const wk = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: [], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [{ type: 'fire', amount: 5 }] } };
|
||||||
|
expect(applyDamage(wk, 10, 'fire').hp.current).toBe(15); // 10 + 5 weakness
|
||||||
|
const rs = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: [], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [{ type: 'all', amount: 5 }], weakness: [] } };
|
||||||
|
expect(applyDamage(rs, 8, 'cold').hp.current).toBe(27); // 30 - (8 - 5 'all') = 27
|
||||||
|
expect(applyDamage(rs, 4, 'cold').hp.current).toBe(30); // 4 - 5 → 0, no change
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('endEncounter', () => {
|
describe('endEncounter', () => {
|
||||||
|
|||||||
@@ -207,11 +207,32 @@ export function moveCombatant(enc: Encounter, id: string, direction: -1 | 1): En
|
|||||||
|
|
||||||
// ---------------- HP transitions ----------------
|
// ---------------- HP transitions ----------------
|
||||||
|
|
||||||
/** Apply damage: temporary HP absorbs first, then current (may go negative). */
|
/**
|
||||||
export function applyDamage(c: Combatant, amount: number): Combatant {
|
* Apply damage of an optional type. Resistances are consulted first (immune → 0,
|
||||||
|
* resistant → halved and rounded down, vulnerable → doubled), then temporary HP
|
||||||
|
* absorbs, then current HP (which may go negative). Passing no `type` reproduces
|
||||||
|
* the original typeless behaviour exactly.
|
||||||
|
*/
|
||||||
|
export function applyDamage(c: Combatant, amount: number, type?: string): Combatant {
|
||||||
if (!Number.isFinite(amount) || amount <= 0) return c;
|
if (!Number.isFinite(amount) || amount <= 0) return c;
|
||||||
const absorbed = Math.min(c.hp.temp, amount);
|
|
||||||
const remaining = amount - absorbed;
|
let dmg = amount;
|
||||||
|
const def = c.damageDefenses;
|
||||||
|
if (type && def) {
|
||||||
|
if (def.immune.includes(type)) return c; // immune — no change
|
||||||
|
// 5e: halve / double
|
||||||
|
if (def.vulnerable.includes(type)) dmg = amount * 2;
|
||||||
|
else if (def.resist.includes(type)) dmg = Math.floor(amount / 2);
|
||||||
|
// pf2e: flat weakness adds, then flat resistance subtracts ('all' matches any type)
|
||||||
|
const wk = def.weakness?.find((w) => w.type === type || w.type === 'all');
|
||||||
|
if (wk) dmg += wk.amount;
|
||||||
|
const rs = def.resistFlat?.find((r) => r.type === type || r.type === 'all');
|
||||||
|
if (rs) dmg = Math.max(0, dmg - rs.amount);
|
||||||
|
}
|
||||||
|
if (dmg <= 0) return c; // fully resisted / immune — no change
|
||||||
|
|
||||||
|
const absorbed = Math.min(c.hp.temp, dmg);
|
||||||
|
const remaining = dmg - absorbed;
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - remaining },
|
hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - remaining },
|
||||||
|
|||||||
@@ -95,12 +95,12 @@ export async function loadClasses(system: SystemId): Promise<RulesetClass[]> {
|
|||||||
return cache.get(key) as RulesetClass[];
|
return cache.get(key) as RulesetClass[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRaces5e(): Promise<{ slug: string; name: string; desc: string; asi: string; speed: string; traits: string }[]> {
|
export async function loadRaces5e(): Promise<{ slug: string; name: string; desc: string; asi: string; speed: string; vision: string; traits: string }[]> {
|
||||||
if (!cache.has('races5e')) {
|
if (!cache.has('races5e')) {
|
||||||
const mod = await import('@/data/srd/races.json');
|
const mod = await import('@/data/srd/races.json');
|
||||||
cache.set('races5e', mod.default as unknown[]);
|
cache.set('races5e', mod.default as unknown[]);
|
||||||
}
|
}
|
||||||
return cache.get('races5e') as { slug: string; name: string; desc: string; asi: string; speed: string; traits: string }[];
|
return cache.get('races5e') as { slug: string; name: string; desc: string; asi: string; speed: string; vision: string; traits: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBackgrounds5e(): Promise<{ slug: string; name: string; desc: string; skills: string; feature: string }[]> {
|
export async function loadBackgrounds5e(): Promise<{ slug: string; name: string; desc: string; skills: string; feature: string }[]> {
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ export interface Spell {
|
|||||||
range?: string;
|
range?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
concentration?: boolean | string;
|
concentration?: boolean | string;
|
||||||
|
requires_concentration?: boolean;
|
||||||
ritual?: boolean | string;
|
ritual?: boolean | string;
|
||||||
|
can_be_cast_as_ritual?: boolean;
|
||||||
components?: string;
|
components?: string;
|
||||||
material?: string;
|
material?: string;
|
||||||
dnd_class?: string;
|
dnd_class?: string;
|
||||||
|
|||||||
@@ -127,6 +127,30 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
|
|
||||||
// v13 — P14b: persisted live-session recap (rolls + chat) per campaign.
|
// v13 — P14b: persisted live-session recap (rolls + chat) per campaign.
|
||||||
this.version(13).stores({ sessionLog: 'id, campaignId, ts' });
|
this.version(13).stores({ sessionLog: 'id, campaignId, ts' });
|
||||||
|
|
||||||
|
// v14 — V2 rules kernel: characters gain a top-level `concentration` slot and
|
||||||
|
// each spell entry gains snapshotted mechanics (concentration/save/damage).
|
||||||
|
// All defaulted, so this backfill is belt-and-suspenders (repos re-parse on save).
|
||||||
|
this.version(14).stores({}).upgrade(async (tx) => {
|
||||||
|
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
|
||||||
|
if (c.concentration === undefined) c.concentration = null;
|
||||||
|
if (c.equippedArmor === undefined) c.equippedArmor = null;
|
||||||
|
const sc = c.spellcasting as { spells?: Record<string, unknown>[] } | undefined;
|
||||||
|
for (const s of sc?.spells ?? []) {
|
||||||
|
if (s.concentration === undefined) s.concentration = false;
|
||||||
|
if (s.save === undefined) s.save = null;
|
||||||
|
if (s.damage === undefined) s.damage = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// v15 — V2 Phase 7: characters gain a structured `feats` array (was free text
|
||||||
|
// in notes). Defaulted, so backfill is belt-and-suspenders.
|
||||||
|
this.version(15).stores({}).upgrade(async (tx) => {
|
||||||
|
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
|
||||||
|
if (c.feats === undefined) c.feats = [];
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ export const charactersRepo = {
|
|||||||
return db.characters.where('campaignId').equals(campaignId).toArray();
|
return db.characters.where('campaignId').equals(campaignId).toArray();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** All player characters across every campaign (PCs are campaign-agnostic). */
|
||||||
|
listAllPcs(): Promise<Character[]> {
|
||||||
|
return db.characters.where('kind').equals('pc').toArray();
|
||||||
|
},
|
||||||
|
|
||||||
get(id: string): Promise<Character | undefined> {
|
get(id: string): Promise<Character | undefined> {
|
||||||
return db.characters.get(id);
|
return db.characters.get(id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,4 +30,28 @@ describe('backup round-trip', () => {
|
|||||||
await expect(restoreBackup('{"foo":1}')).rejects.toThrow();
|
await expect(restoreBackup('{"foo":1}')).rejects.toThrow();
|
||||||
await expect(restoreBackup('not json')).rejects.toThrow();
|
await expect(restoreBackup('not json')).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backfills new fields on a character from an older backup (no missing-field crash)', async () => {
|
||||||
|
const c = await campaignsRepo.create({ name: 'Old Save', system: '5e', description: '' });
|
||||||
|
// Simulate a pre-v15 character row: no feats/concentration/equippedArmor.
|
||||||
|
const legacy = {
|
||||||
|
format: 'ttrpg-manager:backup', version: 1,
|
||||||
|
data: {
|
||||||
|
campaigns: [{ id: c.id, name: 'Old Save', system: '5e', description: '', createdAt: 't', updatedAt: 't' }],
|
||||||
|
characters: [{
|
||||||
|
id: 'old1', campaignId: c.id, system: '5e', kind: 'pc', name: 'Legacy', ancestry: '', className: 'Fighter', level: 3,
|
||||||
|
abilities: { str: 14, dex: 12, con: 13, int: 10, wis: 11, cha: 9 }, hp: { current: 20, max: 20, temp: 0 },
|
||||||
|
speed: 30, armorBonus: 0, skillRanks: {}, saveRanks: {}, perceptionRank: 'trained', notes: '', createdAt: 't', updatedAt: 't',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await clearAllData();
|
||||||
|
await restoreBackup(JSON.stringify(legacy));
|
||||||
|
const [restored] = await charactersRepo.listByCampaign(c.id);
|
||||||
|
expect(restored!.name).toBe('Legacy');
|
||||||
|
// The fields the sheet reads must exist (default-backfilled), not be undefined.
|
||||||
|
expect(restored!.feats).toEqual([]);
|
||||||
|
expect(restored!.concentration).toBeNull();
|
||||||
|
expect(restored!.spellcasting.spells).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-1
@@ -1,4 +1,9 @@
|
|||||||
|
import type { ZodTypeAny } from 'zod';
|
||||||
import { db } from '@/lib/db/db';
|
import { db } from '@/lib/db/db';
|
||||||
|
import {
|
||||||
|
campaignSchema, characterSchema, encounterSchema, diceRollSchema,
|
||||||
|
noteSchema, npcSchema, questSchema, calendarSchema, battleMapSchema, homebrewSchema,
|
||||||
|
} from '@/lib/schemas';
|
||||||
import { downloadJson } from './file';
|
import { downloadJson } from './file';
|
||||||
|
|
||||||
const TABLES = [
|
const TABLES = [
|
||||||
@@ -6,6 +11,14 @@ const TABLES = [
|
|||||||
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew',
|
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** Schema per table, so restored rows are validated + backfilled with defaults
|
||||||
|
* (a backup from an older app version is missing newer fields, e.g. `feats`). */
|
||||||
|
const SCHEMA: Record<(typeof TABLES)[number], ZodTypeAny> = {
|
||||||
|
campaigns: campaignSchema, characters: characterSchema, encounters: encounterSchema,
|
||||||
|
diceRolls: diceRollSchema, notes: noteSchema, npcs: npcSchema, quests: questSchema,
|
||||||
|
calendars: calendarSchema, maps: battleMapSchema, homebrew: homebrewSchema,
|
||||||
|
};
|
||||||
|
|
||||||
const FORMAT = 'ttrpg-manager:backup';
|
const FORMAT = 'ttrpg-manager:backup';
|
||||||
|
|
||||||
export class BackupError extends Error {}
|
export class BackupError extends Error {}
|
||||||
@@ -40,7 +53,15 @@ export async function restoreBackup(text: string): Promise<void> {
|
|||||||
for (const t of TABLES) {
|
for (const t of TABLES) {
|
||||||
await db.table(t).clear();
|
await db.table(t).clear();
|
||||||
const rows = data[t];
|
const rows = data[t];
|
||||||
if (Array.isArray(rows) && rows.length) await db.table(t).bulkAdd(rows);
|
if (!Array.isArray(rows) || !rows.length) continue;
|
||||||
|
// Validate + backfill defaults so a backup from an older app version (or an
|
||||||
|
// edited/foreign file) can't land rows missing newer fields and crash the UI.
|
||||||
|
const valid: unknown[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const r = SCHEMA[t].safeParse(row);
|
||||||
|
if (r.success) valid.push(r.data);
|
||||||
|
}
|
||||||
|
if (valid.length) await db.table(t).bulkAdd(valid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,28 @@ describe('complete — structured output', () => {
|
|||||||
const r = await complete(base, { system: 's', user: 'u', schema });
|
const r = await complete(base, { system: 's', user: 'u', schema });
|
||||||
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
|
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
|
||||||
});
|
});
|
||||||
|
it('parses fenced JSON surrounded by prose (DeepSeek-style)', async () => {
|
||||||
|
const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' };
|
||||||
|
mockFetch(() => openaiReply('Here is the encounter:\n```json\n{"name":"Goblin"}\n```\nHope that helps!'));
|
||||||
|
const r = await complete(cfg, { system: 's', user: 'u', schema });
|
||||||
|
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
|
||||||
|
});
|
||||||
|
it('extracts the first JSON object from surrounding prose without fences', async () => {
|
||||||
|
mockFetch(() => anthropicReply('Sure! {"name":"Goblin"} — let me know if you need more.'));
|
||||||
|
const r = await complete(base, { system: 's', user: 'u', schema });
|
||||||
|
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
|
||||||
|
});
|
||||||
|
it('handles braces appearing in trailing prose', async () => {
|
||||||
|
mockFetch(() => anthropicReply('{"name":"Goblin"}\nUse {placeholders} as needed.'));
|
||||||
|
const r = await complete(base, { system: 's', user: 'u', schema });
|
||||||
|
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
|
||||||
|
});
|
||||||
|
it('tolerates braces inside JSON string values', async () => {
|
||||||
|
const nested = z.object({ reasoning: z.string(), name: z.string() });
|
||||||
|
mockFetch(() => anthropicReply('```\n{"reasoning":"add {2} goblins","name":"Goblin"}\n```'));
|
||||||
|
const r = await complete(base, { system: 's', user: 'u', schema: nested });
|
||||||
|
expect(r).toEqual({ ok: true, data: { reasoning: 'add {2} goblins', name: 'Goblin' } });
|
||||||
|
});
|
||||||
it('returns parse error on malformed JSON', async () => {
|
it('returns parse error on malformed JSON', async () => {
|
||||||
mockFetch(() => anthropicReply('not json at all'));
|
mockFetch(() => anthropicReply('not json at all'));
|
||||||
const r = await complete(base, { system: 's', user: 'u', schema });
|
const r = await complete(base, { system: 's', user: 'u', schema });
|
||||||
|
|||||||
+48
-6
@@ -1,7 +1,10 @@
|
|||||||
import type { CompleteOptions, LlmConfig, LlmErrorKind, LlmResult } from './types';
|
import type { CompleteOptions, LlmConfig, LlmErrorKind, LlmResult } from './types';
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT = 30_000;
|
const DEFAULT_TIMEOUT = 30_000;
|
||||||
const DEFAULT_MAX_TOKENS = 1024;
|
// Generous so reasoning models (e.g. DeepSeek, whose chain-of-thought counts
|
||||||
|
// against the completion budget) have room to finish thinking AND emit the JSON.
|
||||||
|
// A small cap truncates them mid-reasoning, leaving an empty `content`.
|
||||||
|
const DEFAULT_MAX_TOKENS = 4096;
|
||||||
|
|
||||||
function err<T>(error: LlmErrorKind, message: string): LlmResult<T> {
|
function err<T>(error: LlmErrorKind, message: string): LlmResult<T> {
|
||||||
return { ok: false, error, message };
|
return { ok: false, error, message };
|
||||||
@@ -15,12 +18,48 @@ function jsonInstruction(): string {
|
|||||||
return '\n\nRespond with ONLY a single valid JSON object — no prose, no markdown code fences — matching the requested shape exactly.';
|
return '\n\nRespond with ONLY a single valid JSON object — no prose, no markdown code fences — matching the requested shape exactly.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort extraction of a JSON object from a model response. */
|
/** Strip Markdown code fences (```json … ```) from anywhere in the text. */
|
||||||
|
function stripCodeFences(text: string): string {
|
||||||
|
// Remove an opening fence (with optional language tag) and its matching closing
|
||||||
|
// fence, even when surrounded by prose. Falls back to dropping stray fence markers.
|
||||||
|
const fenced = text.match(/```[ \t]*(?:json|jsonc|json5)?[ \t]*\r?\n([\s\S]*?)```/i);
|
||||||
|
if (fenced?.[1] !== undefined) return fenced[1];
|
||||||
|
return text.replace(/```[ \t]*[a-z0-9]*[ \t]*/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan for the first complete, brace-balanced JSON object, ignoring braces in strings. */
|
||||||
|
function extractFirstJsonObject(text: string): string | undefined {
|
||||||
|
const start = text.indexOf('{');
|
||||||
|
if (start < 0) return undefined;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let i = start; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) escaped = false;
|
||||||
|
else if (ch === '\\') escaped = true;
|
||||||
|
else if (ch === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') inString = true;
|
||||||
|
else if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return text.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort extraction of a JSON object from a model response. Tolerates Markdown
|
||||||
|
* code fences and prose around the JSON (as DeepSeek and other models often emit),
|
||||||
|
* extracting the first brace-balanced object before parsing.
|
||||||
|
*/
|
||||||
function tryParseJson(text: string): unknown {
|
function tryParseJson(text: string): unknown {
|
||||||
const trimmed = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
const unfenced = stripCodeFences(text).trim();
|
||||||
const start = trimmed.indexOf('{');
|
const candidate = extractFirstJsonObject(unfenced) ?? unfenced;
|
||||||
const end = trimmed.lastIndexOf('}');
|
|
||||||
const candidate = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(candidate);
|
return JSON.parse(candidate);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -140,6 +179,9 @@ export async function complete<T>(cfg: LlmConfig, opts: CompleteOptions<T>): Pro
|
|||||||
const text = extractText(cfg.provider, json);
|
const text = extractText(cfg.provider, json);
|
||||||
if (!wantJson) return { ok: true, text };
|
if (!wantJson) return { ok: true, text };
|
||||||
|
|
||||||
|
if (text.trim() === '') {
|
||||||
|
return err('parse', 'The model returned an empty response — it likely ran out of output tokens before answering (raise max tokens, or the model spent its whole budget reasoning).');
|
||||||
|
}
|
||||||
const parsed = tryParseJson(text);
|
const parsed = tryParseJson(text);
|
||||||
if (parsed === undefined) return err('parse', 'The model did not return valid JSON.');
|
if (parsed === undefined) return err('parse', 'The model did not return valid JSON.');
|
||||||
const result = opts.schema!.safeParse(parsed);
|
const result = opts.schema!.safeParse(parsed);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ZodType } from 'zod';
|
import type { ZodType, ZodTypeDef } from 'zod';
|
||||||
|
|
||||||
export type LlmProvider = 'anthropic' | 'openai-compatible';
|
export type LlmProvider = 'anthropic' | 'openai-compatible';
|
||||||
|
|
||||||
@@ -34,8 +34,10 @@ export interface CompleteOptions<T = unknown> {
|
|||||||
system: string;
|
system: string;
|
||||||
/** User message. */
|
/** User message. */
|
||||||
user: string;
|
user: string;
|
||||||
/** When provided, structured JSON output is requested and validated against it. */
|
/** When provided, structured JSON output is requested and validated against it.
|
||||||
schema?: ZodType<T>;
|
* Input is left open (`any`) so schemas using coerce/preprocess/transform — whose
|
||||||
|
* parsed input type differs from T — are accepted; T is pinned to the output. */
|
||||||
|
schema?: ZodType<T, ZodTypeDef, any>;
|
||||||
/** A short name describing the JSON shape (used by providers that require it). */
|
/** A short name describing the JSON shape (used by providers that require it). */
|
||||||
schemaName?: string;
|
schemaName?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Character, Spellcasting } from '@/lib/schemas/character';
|
||||||
|
import type { EnforceResult } from './result';
|
||||||
|
|
||||||
|
/** Consume one spell slot at `level` (regular slots first, then matching pact slot). */
|
||||||
|
function consumeSlot(sc: Spellcasting, level: number): { spellcasting: Spellcasting; usedLevel: number } | null {
|
||||||
|
const slot = sc.slots.find((s) => s.level === level);
|
||||||
|
if (slot && slot.current > 0) {
|
||||||
|
return {
|
||||||
|
spellcasting: { ...sc, slots: sc.slots.map((s) => (s.level === level ? { ...s, current: s.current - 1 } : s)) },
|
||||||
|
usedLevel: level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Warlock pact magic — a single pooled slot at its own level.
|
||||||
|
if (sc.pact && sc.pact.level === level && sc.pact.current > 0) {
|
||||||
|
return {
|
||||||
|
spellcasting: { ...sc, pact: { ...sc.pact, current: sc.pact.current - 1 } },
|
||||||
|
usedLevel: level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast a spell the character knows, at a chosen slot level (≥ the spell's own
|
||||||
|
* level, to allow upcasting). Pure: decrements a slot, sets/replaces
|
||||||
|
* concentration, and returns the patch + log. Cantrips cost no slot.
|
||||||
|
*
|
||||||
|
* Fails (no mutation) when no slot of the requested level is available — the UI
|
||||||
|
* surfaces `reason` and lets the player pick a different level.
|
||||||
|
*/
|
||||||
|
export function castSpell(c: Character, spellId: string, atLevel?: number): EnforceResult {
|
||||||
|
const spell = c.spellcasting.spells.find((s) => s.id === spellId);
|
||||||
|
if (!spell) return { ok: false, reason: 'That spell is not on this character.' };
|
||||||
|
|
||||||
|
const log: string[] = [];
|
||||||
|
const patch: Partial<Character> = {};
|
||||||
|
|
||||||
|
if (spell.level > 0) {
|
||||||
|
const useLevel = Math.max(atLevel ?? spell.level, spell.level);
|
||||||
|
const consumed = consumeSlot(c.spellcasting, useLevel);
|
||||||
|
if (!consumed) return { ok: false, reason: `No level-${useLevel} slot available for ${spell.name}.` };
|
||||||
|
patch.spellcasting = consumed.spellcasting;
|
||||||
|
log.push(`Cast ${spell.name} using a level-${consumed.usedLevel} slot.`);
|
||||||
|
} else {
|
||||||
|
log.push(`Cast ${spell.name} (cantrip).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spell.concentration) {
|
||||||
|
if (c.concentration && c.concentration.spellId !== spell.id) {
|
||||||
|
log.push(`Concentration on ${c.concentration.spellName} ends.`);
|
||||||
|
}
|
||||||
|
patch.concentration = { spellId: spell.id, spellName: spell.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, patch, log };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear active concentration (e.g. the player drops it, or it breaks). */
|
||||||
|
export function dropConcentration(c: Character): Partial<Character> {
|
||||||
|
return c.concentration ? { concentration: null } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5e Constitution save DC to maintain concentration after taking `damage`. */
|
||||||
|
export function concentrationDC(damage: number): number {
|
||||||
|
return Math.max(10, Math.floor(damage / 2));
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { SystemId } from '@/lib/rules';
|
||||||
|
import type { AbilityKey, AdvState } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mechanical effect a condition imposes — a declarative table, never
|
||||||
|
* hardcoded `if`s in combat code. `deriveState` (creatureState.ts) folds these
|
||||||
|
* into a single query the tracker and dice roller ask.
|
||||||
|
*/
|
||||||
|
export interface ConditionEffect {
|
||||||
|
/** sets effective speed to 0 */
|
||||||
|
speedZero?: boolean;
|
||||||
|
/** this creature's attack rolls */
|
||||||
|
attackRolls?: AdvState;
|
||||||
|
/** attack rolls made AGAINST this creature */
|
||||||
|
attacksAgainst?: AdvState;
|
||||||
|
/** disadvantage on ability checks (5e) */
|
||||||
|
abilityChecks?: boolean;
|
||||||
|
/** per-ability save penalty (auto-fail dominates disadvantage) */
|
||||||
|
saves?: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
|
||||||
|
/** cannot take actions or reactions */
|
||||||
|
incapacitated?: boolean;
|
||||||
|
/** pf2e: a numeric status penalty scaling with the condition's value */
|
||||||
|
statusPenalty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_PHYS: Partial<Record<AbilityKey, 'auto-fail'>> = { str: 'auto-fail', dex: 'auto-fail' };
|
||||||
|
|
||||||
|
/** D&D 5e condition mechanics (keys lowercased to match stored names). */
|
||||||
|
export const CONDITION_EFFECTS_5E: Record<string, ConditionEffect> = {
|
||||||
|
blinded: { attackRolls: 'disadvantage', attacksAgainst: 'advantage' },
|
||||||
|
frightened: { attackRolls: 'disadvantage', abilityChecks: true },
|
||||||
|
grappled: { speedZero: true },
|
||||||
|
incapacitated: { incapacitated: true },
|
||||||
|
invisible: { attackRolls: 'advantage', attacksAgainst: 'disadvantage' },
|
||||||
|
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
|
||||||
|
petrified: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
|
||||||
|
poisoned: { attackRolls: 'disadvantage', abilityChecks: true },
|
||||||
|
prone: { attackRolls: 'disadvantage', attacksAgainst: 'advantage' },
|
||||||
|
restrained: { speedZero: true, attackRolls: 'disadvantage', attacksAgainst: 'advantage', saves: { dex: 'disadvantage' } },
|
||||||
|
stunned: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
|
||||||
|
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pathfinder 2e condition mechanics. PF2e uses flat numeric penalties rather
|
||||||
|
* than advantage/disadvantage; valued conditions (frightened N, clumsy N, …)
|
||||||
|
* contribute a status penalty aggregated by `deriveState`.
|
||||||
|
*/
|
||||||
|
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
|
||||||
|
blinded: { attacksAgainst: 'advantage' },
|
||||||
|
clumsy: { statusPenalty: true },
|
||||||
|
drained: { statusPenalty: true },
|
||||||
|
dazzled: { attacksAgainst: 'advantage' },
|
||||||
|
enfeebled: { statusPenalty: true },
|
||||||
|
fatigued: { statusPenalty: true },
|
||||||
|
frightened: { statusPenalty: true },
|
||||||
|
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
|
||||||
|
immobilized: { speedZero: true },
|
||||||
|
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
|
||||||
|
petrified: { incapacitated: true, speedZero: true },
|
||||||
|
prone: { attacksAgainst: 'advantage' },
|
||||||
|
restrained: { speedZero: true, incapacitated: false, attacksAgainst: 'advantage' },
|
||||||
|
sickened: { statusPenalty: true },
|
||||||
|
slowed: { statusPenalty: true },
|
||||||
|
stunned: { incapacitated: true },
|
||||||
|
stupefied: { statusPenalty: true },
|
||||||
|
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
|
||||||
|
'off-guard': { attacksAgainst: 'advantage' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function conditionEffects(system: SystemId): Record<string, ConditionEffect> {
|
||||||
|
return system === 'pf2e' ? CONDITION_EFFECTS_PF2E : CONDITION_EFFECTS_5E;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { deriveState } from './creatureState';
|
||||||
|
import { concentrationDC } from './cast';
|
||||||
|
import type { Condition } from '@/lib/schemas/common';
|
||||||
|
|
||||||
|
const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name });
|
||||||
|
|
||||||
|
describe('deriveState (5e)', () => {
|
||||||
|
it('grappled zeroes speed', () => {
|
||||||
|
const s = deriveState('5e', 30, [cond('Grappled')]);
|
||||||
|
expect(s.speed).toBe(0);
|
||||||
|
expect(s.badges).toContain('Speed 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('poisoned imposes disadvantage on attacks and ability checks', () => {
|
||||||
|
const s = deriveState('5e', 30, [cond('Poisoned')]);
|
||||||
|
expect(s.attackModifier).toBe('disadvantage');
|
||||||
|
expect(s.abilityCheckDisadvantage).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restrained: speed 0, disadv attacks, attacked with advantage, dex save disadvantage', () => {
|
||||||
|
const s = deriveState('5e', 30, [cond('Restrained')]);
|
||||||
|
expect(s.speed).toBe(0);
|
||||||
|
expect(s.attackModifier).toBe('disadvantage');
|
||||||
|
expect(s.attacksAgainstModifier).toBe('advantage');
|
||||||
|
expect(s.saveModifiers.dex).toBe('disadvantage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paralyzed auto-fails str/dex saves and incapacitates', () => {
|
||||||
|
const s = deriveState('5e', 30, [cond('Paralyzed')]);
|
||||||
|
expect(s.incapacitated).toBe(true);
|
||||||
|
expect(s.saveModifiers.str).toBe('auto-fail');
|
||||||
|
expect(s.saveModifiers.dex).toBe('auto-fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opposing advantage and disadvantage cancel to normal', () => {
|
||||||
|
// invisible grants advantage to attack; poisoned imposes disadvantage → normal
|
||||||
|
const s = deriveState('5e', 30, [cond('Invisible'), cond('Poisoned')]);
|
||||||
|
expect(s.attackModifier).toBe('normal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown/homebrew condition contributes nothing', () => {
|
||||||
|
const s = deriveState('5e', 30, [cond('Inspired by bards')]);
|
||||||
|
expect(s).toMatchObject({ speed: 30, attackModifier: 'normal', incapacitated: false });
|
||||||
|
expect(s.badges).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveState (pf2e)', () => {
|
||||||
|
it('frightened contributes a numeric status penalty equal to its value', () => {
|
||||||
|
const s = deriveState('pf2e', 25, [cond('Frightened', 2)]);
|
||||||
|
expect(s.statusPenalty).toBe(2);
|
||||||
|
expect(s.badges).toContain('−2 status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('status penalties do not stack — worst value wins', () => {
|
||||||
|
const s = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]);
|
||||||
|
expect(s.statusPenalty).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grabbed zeroes speed and grants advantage to attackers', () => {
|
||||||
|
const s = deriveState('pf2e', 25, [cond('Grabbed')]);
|
||||||
|
expect(s.speed).toBe(0);
|
||||||
|
expect(s.attacksAgainstModifier).toBe('advantage');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('concentrationDC', () => {
|
||||||
|
it('is half the damage, floored, minimum 10', () => {
|
||||||
|
expect(concentrationDC(8)).toBe(10); // floor(4)=4 → min 10
|
||||||
|
expect(concentrationDC(22)).toBe(11);
|
||||||
|
expect(concentrationDC(40)).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { SystemId } from '@/lib/rules';
|
||||||
|
import type { Condition } from '@/lib/schemas/common';
|
||||||
|
import { conditionEffects } from './conditionEffects';
|
||||||
|
import type { AbilityKey, AdvState } from './types';
|
||||||
|
|
||||||
|
export interface MechanicalState {
|
||||||
|
/** effective speed after conditions (0 if any condition zeroes it) */
|
||||||
|
speed: number;
|
||||||
|
/** advantage state on THIS creature's attack rolls (5e) */
|
||||||
|
attackModifier: AdvState;
|
||||||
|
/** advantage state on attacks made AGAINST this creature (5e) */
|
||||||
|
attacksAgainstModifier: AdvState;
|
||||||
|
/** cannot take actions/reactions */
|
||||||
|
incapacitated: boolean;
|
||||||
|
/** per-ability save penalties (auto-fail dominates disadvantage) */
|
||||||
|
saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
|
||||||
|
/** disadvantage on ability checks (5e) */
|
||||||
|
abilityCheckDisadvantage: boolean;
|
||||||
|
/** pf2e: aggregated numeric status penalty (worst single value, penalties don't stack) */
|
||||||
|
statusPenalty: number;
|
||||||
|
/** short human-readable badges for the UI, e.g. "Speed 0", "Disadv. attacks" */
|
||||||
|
badges: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combine two advantage states by the 5e rule: opposing sources cancel to normal. */
|
||||||
|
function combineAdv(a: AdvState, b: AdvState): AdvState {
|
||||||
|
if (a === b) return a;
|
||||||
|
if (a === 'normal') return b;
|
||||||
|
if (b === 'normal') return a;
|
||||||
|
return 'normal'; // one advantage + one disadvantage cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a creature's conditions into a single mechanical read-model. Pure and
|
||||||
|
* synchronous — combat, the dice roller, and the player view all call this
|
||||||
|
* (the player view ships the same table and derives effects locally from the
|
||||||
|
* broadcast condition names, so no derived state crosses the wire).
|
||||||
|
*
|
||||||
|
* Unknown / homebrew condition names contribute nothing (they remain visible
|
||||||
|
* labels), exactly as before.
|
||||||
|
*/
|
||||||
|
export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[]): MechanicalState {
|
||||||
|
const table = conditionEffects(system);
|
||||||
|
let speedZero = false;
|
||||||
|
let attackModifier: AdvState = 'normal';
|
||||||
|
let attacksAgainstModifier: AdvState = 'normal';
|
||||||
|
let incapacitated = false;
|
||||||
|
let abilityCheckDisadvantage = false;
|
||||||
|
let statusPenalty = 0;
|
||||||
|
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
|
||||||
|
|
||||||
|
for (const cond of conditions) {
|
||||||
|
const eff = table[cond.name.trim().toLowerCase()];
|
||||||
|
if (!eff) continue;
|
||||||
|
if (eff.speedZero) speedZero = true;
|
||||||
|
if (eff.incapacitated) incapacitated = true;
|
||||||
|
if (eff.abilityChecks) abilityCheckDisadvantage = true;
|
||||||
|
if (eff.attackRolls) attackModifier = combineAdv(attackModifier, eff.attackRolls);
|
||||||
|
if (eff.attacksAgainst) attacksAgainstModifier = combineAdv(attacksAgainstModifier, eff.attacksAgainst);
|
||||||
|
if (eff.saves) {
|
||||||
|
for (const [ability, level] of Object.entries(eff.saves) as [AbilityKey, 'disadvantage' | 'auto-fail'][]) {
|
||||||
|
// auto-fail dominates disadvantage
|
||||||
|
if (level === 'auto-fail' || saveModifiers[ability] === undefined) saveModifiers[ability] = level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pf2e: status penalties don't stack — take the worst single value.
|
||||||
|
if (eff.statusPenalty && cond.value !== undefined) statusPenalty = Math.max(statusPenalty, cond.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badges: string[] = [];
|
||||||
|
if (speedZero) badges.push('Speed 0');
|
||||||
|
if (incapacitated) badges.push('Incapacitated');
|
||||||
|
if (attackModifier === 'disadvantage') badges.push('Disadv. attacks');
|
||||||
|
if (attackModifier === 'advantage') badges.push('Adv. attacks');
|
||||||
|
if (attacksAgainstModifier === 'advantage') badges.push('Attacked w/ adv.');
|
||||||
|
if (statusPenalty > 0) badges.push(`−${statusPenalty} status`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
speed: speedZero ? 0 : baseSpeed,
|
||||||
|
attackModifier,
|
||||||
|
attacksAgainstModifier,
|
||||||
|
incapacitated,
|
||||||
|
saveModifiers,
|
||||||
|
abilityCheckDisadvantage,
|
||||||
|
statusPenalty,
|
||||||
|
badges,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Character } from '@/lib/schemas/character';
|
||||||
|
|
||||||
|
export interface DeathSaveOutcome {
|
||||||
|
patch: Partial<Character>;
|
||||||
|
/** 'pending' = still rolling; 'stable' = 3 successes; 'dead' = 3 failures; 'revived' = nat 20 */
|
||||||
|
outcome: 'pending' | 'stable' | 'dead' | 'revived';
|
||||||
|
log: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one 5e death saving throw given a raw d20 face. Pure:
|
||||||
|
* - nat 20 → regain 1 HP, clear the death-save clock (back in the fight)
|
||||||
|
* - nat 1 → two failures
|
||||||
|
* - ≥10 → one success (3 → stable)
|
||||||
|
* - <10 → one failure (3 → dead)
|
||||||
|
*/
|
||||||
|
export function rollDeathSave(c: Character, d20: number): DeathSaveOutcome {
|
||||||
|
const ds = c.defenses.deathSaves;
|
||||||
|
|
||||||
|
if (d20 === 20) {
|
||||||
|
return {
|
||||||
|
patch: {
|
||||||
|
hp: { ...c.hp, current: 1 },
|
||||||
|
defenses: { ...c.defenses, deathSaves: { successes: 0, failures: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'revived',
|
||||||
|
log: `${c.name} rolls a natural 20 and regains 1 HP!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let successes = ds.successes;
|
||||||
|
let failures = ds.failures;
|
||||||
|
if (d20 === 1) failures = Math.min(3, failures + 2);
|
||||||
|
else if (d20 >= 10) successes = Math.min(3, successes + 1);
|
||||||
|
else failures = Math.min(3, failures + 1);
|
||||||
|
|
||||||
|
const patch: Partial<Character> = { defenses: { ...c.defenses, deathSaves: { successes, failures } } };
|
||||||
|
|
||||||
|
if (failures >= 3) return { patch, outcome: 'dead', log: `${c.name} fails a third death save and dies.` };
|
||||||
|
if (successes >= 3) return { patch, outcome: 'stable', log: `${c.name} stabilizes.` };
|
||||||
|
return {
|
||||||
|
patch,
|
||||||
|
outcome: 'pending',
|
||||||
|
log: d20 >= 10
|
||||||
|
? `${c.name} succeeds a death save (${successes}/3).`
|
||||||
|
: `${c.name} fails a death save (${failures}/3).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { characterDefaults, newSpellEntry, type Character } from '@/lib/schemas';
|
||||||
|
import { castSpell, dropConcentration } from './cast';
|
||||||
|
import { spendResource, regainResource } from './resources';
|
||||||
|
import { rollDeathSave } from './deathSaves';
|
||||||
|
|
||||||
|
function mk(over: Partial<Character> = {}): Character {
|
||||||
|
return {
|
||||||
|
id: 'pc', campaignId: 'c', system: '5e', kind: 'pc', name: 'Mage', ancestry: '', className: 'Wizard', level: 5,
|
||||||
|
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
|
||||||
|
hp: { current: 0, max: 24, temp: 0 }, speed: 30, armorBonus: 0,
|
||||||
|
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||||
|
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('castSpell', () => {
|
||||||
|
const fireball = newSpellEntry({ id: 's1', name: 'Fireball', level: 3, save: { ability: 'dex', basis: 'half' } });
|
||||||
|
const haste = newSpellEntry({ id: 's2', name: 'Haste', level: 3, concentration: true });
|
||||||
|
const bless = newSpellEntry({ id: 's3', name: 'Bless', level: 1, concentration: true });
|
||||||
|
const firebolt = newSpellEntry({ id: 's4', name: 'Fire Bolt', level: 0 });
|
||||||
|
|
||||||
|
const base = mk({
|
||||||
|
spellcasting: { slots: [{ level: 1, max: 4, current: 4 }, { level: 3, max: 3, current: 2 }], spells: [fireball, haste, bless, firebolt] },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrements the slot of the spell level', () => {
|
||||||
|
const r = castSpell(base, 's1');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.spellcasting!.slots.find((s) => s.level === 3)!.current).toBe(1);
|
||||||
|
expect(r.log[0]).toMatch(/level-3 slot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upcasts: casting a level-3 spell with a higher slot consumes that slot', () => {
|
||||||
|
const withHigh = mk({ spellcasting: { slots: [{ level: 3, max: 1, current: 0 }, { level: 5, max: 1, current: 1 }], spells: [fireball] } });
|
||||||
|
const r = castSpell(withHigh, 's1', 5);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.spellcasting!.slots.find((s) => s.level === 5)!.current).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when no slot of the requested level remains', () => {
|
||||||
|
const empty = mk({ spellcasting: { slots: [{ level: 3, max: 2, current: 0 }], spells: [fireball] } });
|
||||||
|
const r = castSpell(empty, 's1');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (r.ok) return;
|
||||||
|
expect(r.reason).toMatch(/No level-3 slot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cantrips cost no slot', () => {
|
||||||
|
const r = castSpell(base, 's4');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.spellcasting).toBeUndefined();
|
||||||
|
expect(r.log[0]).toMatch(/cantrip/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets concentration and replaces a prior concentration spell', () => {
|
||||||
|
const concentrating = mk({
|
||||||
|
concentration: { spellId: 's3', spellName: 'Bless' },
|
||||||
|
spellcasting: { slots: [{ level: 3, max: 3, current: 2 }], spells: [haste, bless] },
|
||||||
|
});
|
||||||
|
const r = castSpell(concentrating, 's2');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.concentration).toEqual({ spellId: 's2', spellName: 'Haste' });
|
||||||
|
expect(r.log.some((l) => /Concentration on Bless ends/.test(l))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes a warlock pact slot when no regular slot matches', () => {
|
||||||
|
const warlock = mk({ spellcasting: { slots: [], pact: { level: 3, max: 2, current: 2 }, spells: [haste] } });
|
||||||
|
const r = castSpell(warlock, 's2', 3);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.spellcasting!.pact!.current).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dropConcentration clears the slot', () => {
|
||||||
|
const concentrating = mk({ concentration: { spellId: 's2', spellName: 'Haste' } });
|
||||||
|
expect(dropConcentration(concentrating)).toEqual({ concentration: null });
|
||||||
|
expect(dropConcentration(mk())).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spendResource / regainResource', () => {
|
||||||
|
const base = mk({ resources: [{ id: 'ki', name: 'Ki', current: 2, max: 5, recovery: 'short' }] });
|
||||||
|
|
||||||
|
it('spends and fails when too low', () => {
|
||||||
|
const ok = spendResource(base, 'ki', 2);
|
||||||
|
expect(ok.ok).toBe(true);
|
||||||
|
if (ok.ok) expect(ok.patch.resources![0]!.current).toBe(0);
|
||||||
|
const bad = spendResource(base, 'ki', 3);
|
||||||
|
expect(bad.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regains up to max', () => {
|
||||||
|
const r = regainResource(base, 'ki', 10);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.patch.resources![0]!.current).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rollDeathSave', () => {
|
||||||
|
it('nat 20 revives at 1 HP and clears the clock', () => {
|
||||||
|
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 1, failures: 2 } } }), 20);
|
||||||
|
expect(r.outcome).toBe('revived');
|
||||||
|
expect(r.patch.hp!.current).toBe(1);
|
||||||
|
expect(r.patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nat 1 adds two failures → dead at three', () => {
|
||||||
|
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 0, failures: 1 } } }), 1);
|
||||||
|
expect(r.outcome).toBe('dead');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('≥10 succeeds; third success stabilizes', () => {
|
||||||
|
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 2, failures: 0 } } }), 12);
|
||||||
|
expect(r.outcome).toBe('stable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('<10 is a single failure, still pending', () => {
|
||||||
|
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 0, failures: 0 } } }), 7);
|
||||||
|
expect(r.outcome).toBe('pending');
|
||||||
|
expect(r.patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* The mechanics kernel: structured, enforceable game rules derived on-demand
|
||||||
|
* from the already-cached compendium JSON. No new assets are loaded — these
|
||||||
|
* wrap the existing lazy loaders in src/lib/compendium, so the PWA cache config
|
||||||
|
* is unchanged. Derived mechanics are memoized per process.
|
||||||
|
*/
|
||||||
|
import { loadSpells, loadArmor5e, loadMonsters } from '@/lib/compendium';
|
||||||
|
import { normalizeSpell5e } from './normalize/spell';
|
||||||
|
import { normalizeArmor5e } from './normalize/armor';
|
||||||
|
import { normalizeMonsterDefenses } from './normalize/monsterDefenses';
|
||||||
|
import type { ArmorMechanics, DamageDefenses, SpellMechanics } from './types';
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export type { EnforceResult } from './result';
|
||||||
|
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
|
||||||
|
export { normalizeArmor5e } from './normalize/armor';
|
||||||
|
export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefenses } from './normalize/monsterDefenses';
|
||||||
|
export { castSpell, dropConcentration, concentrationDC } from './cast';
|
||||||
|
export { spendResource, regainResource } from './resources';
|
||||||
|
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
|
||||||
|
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect } from './conditionEffects';
|
||||||
|
export { deriveState, type MechanicalState } from './creatureState';
|
||||||
|
|
||||||
|
let spellMechCache: Map<string, SpellMechanics> | null = null;
|
||||||
|
let armorMechCache: Map<string, ArmorMechanics> | null = null;
|
||||||
|
|
||||||
|
/** Mechanics for a single 5e spell by slug (undefined if not in the SRD set). */
|
||||||
|
export async function spellMechanics5e(slug: string): Promise<SpellMechanics | undefined> {
|
||||||
|
if (!spellMechCache) {
|
||||||
|
const raw = await loadSpells();
|
||||||
|
spellMechCache = new Map();
|
||||||
|
for (const s of raw) {
|
||||||
|
const m = normalizeSpell5e(s);
|
||||||
|
if (m) spellMechCache.set(m.slug, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spellMechCache.get(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mechanics for a single 5e armor by name (case-insensitive). */
|
||||||
|
export async function armorMechanics5e(name: string): Promise<ArmorMechanics | undefined> {
|
||||||
|
if (!armorMechCache) {
|
||||||
|
const raw = await loadArmor5e();
|
||||||
|
armorMechCache = new Map();
|
||||||
|
for (const a of raw) {
|
||||||
|
const m = normalizeArmor5e(a);
|
||||||
|
if (m) armorMechCache.set(m.name.toLowerCase(), m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return armorMechCache.get(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All 5e armor mechanics (for the "equip" picker). */
|
||||||
|
export async function allArmorMechanics5e(): Promise<ArmorMechanics[]> {
|
||||||
|
await armorMechanics5e('');
|
||||||
|
return [...(armorMechCache?.values() ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Damage/condition defenses for a 5e monster by slug. */
|
||||||
|
export async function monsterDefenses5e(slug: string): Promise<DamageDefenses | undefined> {
|
||||||
|
const raw = await loadMonsters();
|
||||||
|
const m = raw.find((x) => x.slug === slug);
|
||||||
|
return m ? normalizeMonsterDefenses(m) : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Armor5e } from '@/lib/compendium/types';
|
||||||
|
import type { ArmorMechanics } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5e equipment armor → unified mechanics.
|
||||||
|
*
|
||||||
|
* The source `ac` is a string ("11 + Dex", "14 + Dex (max 2)", "18", "+2" for a
|
||||||
|
* shield) and `dexCap` is null (light/unlimited), a number (medium cap), or 0
|
||||||
|
* (heavy). We trust `dexCap` and parse the leading number out of `ac`.
|
||||||
|
*/
|
||||||
|
export function normalizeArmor5e(raw: Armor5e): ArmorMechanics | null {
|
||||||
|
if (!raw.name) return null;
|
||||||
|
const type = (raw.type ?? '').toLowerCase();
|
||||||
|
const isShield = type === 'shield';
|
||||||
|
const acStr = raw.ac ?? '';
|
||||||
|
// Shields read "+2"; armor reads "11 + Dex" or "18".
|
||||||
|
const baseAc = Number(/-?\d+/.exec(acStr)?.[0] ?? (isShield ? 2 : 10));
|
||||||
|
|
||||||
|
const category: ArmorMechanics['category'] = isShield
|
||||||
|
? 'shield'
|
||||||
|
: type === 'light' ? 'light'
|
||||||
|
: type === 'medium' ? 'medium'
|
||||||
|
: type === 'heavy' ? 'heavy'
|
||||||
|
: 'unarmored';
|
||||||
|
|
||||||
|
// Shields don't cap Dex (they're additive); honor the source dexCap otherwise.
|
||||||
|
const dexCap = isShield ? null : (raw.dexCap ?? null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: raw.name,
|
||||||
|
category,
|
||||||
|
baseAc,
|
||||||
|
dexCap,
|
||||||
|
stealthDisadvantage: !!raw.stealthPenalty,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Monster } from '@/lib/compendium/types';
|
||||||
|
import { asDamageType, type DamageDefenses, type DamageType } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a free-text defense string like "cold, fire" or
|
||||||
|
* "bludgeoning, piercing, and slashing from nonmagical attacks".
|
||||||
|
*
|
||||||
|
* We deliberately only enforce *clean* lists: a clause that carries a qualifier
|
||||||
|
* ("from nonmagical attacks", "that is silvered", etc.) is dropped into `notes`
|
||||||
|
* for the GM to read, never auto-applied — matching the "don't over-promise
|
||||||
|
* automation" rule. Returns the clean damage types plus any leftover notes.
|
||||||
|
*/
|
||||||
|
function parseDamageList(raw: string | undefined): { types: DamageType[]; notes: string[] } {
|
||||||
|
const types: DamageType[] = [];
|
||||||
|
const notes: string[] = [];
|
||||||
|
const text = (raw ?? '').trim();
|
||||||
|
if (!text) return { types, notes };
|
||||||
|
|
||||||
|
// Split on ';' first (distinct clauses), then commas/"and" within a clause.
|
||||||
|
for (const clause of text.split(/;/)) {
|
||||||
|
const c = clause.trim();
|
||||||
|
if (!c) continue;
|
||||||
|
// A qualifier ("from", "that aren't", "except") means it's conditional — note it.
|
||||||
|
if (/\b(from|that|except|unless|nonmagical|magical|silvered|adamantine)\b/i.test(c)) {
|
||||||
|
notes.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const word of c.split(/,|\band\b/)) {
|
||||||
|
const t = asDamageType(word);
|
||||||
|
if (t && !types.includes(t)) types.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { types, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConditionList(raw: string | undefined): string[] {
|
||||||
|
return (raw ?? '')
|
||||||
|
.split(/,|\band\b|;/)
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a pf2e damage/condition word ("non-magical attacks", "cold_iron"). */
|
||||||
|
function pf2eType(s: string): DamageType | undefined {
|
||||||
|
return asDamageType(s.replace(/_/g, ' ').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pf2e creature flat-defense shape, ready to attach to a combatant. */
|
||||||
|
export interface Pf2eDefenses {
|
||||||
|
immune: DamageType[];
|
||||||
|
conditionImmune: string[];
|
||||||
|
resistFlat: { type: string; amount: number }[];
|
||||||
|
weakness: { type: string; amount: number }[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pf2e creature → flat resistances/weaknesses + immunities. pf2e uses flat
|
||||||
|
* amounts (resistance 5, weakness 5) rather than 5e's halve/double, and 'all'
|
||||||
|
* applies to every type. Immunities mix damage types and conditions.
|
||||||
|
*/
|
||||||
|
export function normalizeMonsterDefensesPf2e(raw: {
|
||||||
|
immunity?: unknown; resistance?: unknown; weakness?: unknown;
|
||||||
|
}): Pf2eDefenses {
|
||||||
|
const immune: DamageType[] = [];
|
||||||
|
const conditionImmune: string[] = [];
|
||||||
|
for (const i of Array.isArray(raw.immunity) ? (raw.immunity as string[]) : []) {
|
||||||
|
const t = pf2eType(String(i));
|
||||||
|
if (t) immune.push(t);
|
||||||
|
else conditionImmune.push(String(i).trim().toLowerCase());
|
||||||
|
}
|
||||||
|
const flat = (obj: unknown): { type: string; amount: number }[] => {
|
||||||
|
if (!obj || typeof obj !== 'object') return [];
|
||||||
|
const out: { type: string; amount: number }[] = [];
|
||||||
|
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
const amount = Number(v);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) continue;
|
||||||
|
const key = k.toLowerCase() === 'all' ? 'all' : pf2eType(k);
|
||||||
|
if (key) out.push({ type: key, amount });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
return { immune, conditionImmune, resistFlat: flat(raw.resistance), weakness: flat(raw.weakness), notes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open5e monster → structured damage/condition defenses. */
|
||||||
|
export function normalizeMonsterDefenses(raw: Monster): DamageDefenses {
|
||||||
|
const resist = parseDamageList(raw.damage_resistances);
|
||||||
|
const immune = parseDamageList(raw.damage_immunities);
|
||||||
|
const vulnerable = parseDamageList(raw.damage_vulnerabilities);
|
||||||
|
return {
|
||||||
|
resist: resist.types,
|
||||||
|
immune: immune.types,
|
||||||
|
vulnerable: vulnerable.types,
|
||||||
|
conditionImmune: parseConditionList(raw.condition_immunities),
|
||||||
|
notes: [...resist.notes, ...immune.notes, ...vulnerable.notes],
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user