Skip to content

Commit 17884fb

Browse files
SociableSteveclaude
andcommitted
Add roleplay, relationships, adventure log, and cross-adventure carry-over
Persistent campaign characters: ideals/bonds/flaws/appearance (captured in the creator, fed to the DM for portrayal); relationships and an adventure log carried on the canonical character. New DM tools — grant_item, adjust_gold, award_xp (with SRD level-up that recomputes HP/prof/slots), and record_relationship — write back to the stored character so gains persist across adventures (the engine tool layer now takes the store). Postgres gains an `extra` JSONB column for xp/adventureLog; roleplay and relationships ride in the sheet JSONB. ensureCharacterFields backfills the new fields. Sheet UI shows XP, roleplay, relationships, and the log. 1 carry-over test (34 server, 80 engine). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab19d31 commit 17884fb

14 files changed

Lines changed: 397 additions & 8 deletions

File tree

packages/engine/src/creature.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ export interface Spellcasting {
4141
known: SpellRef[];
4242
}
4343

44+
/** Free-text roleplay cues (not used by rules; carried with the sheet for the DM). */
45+
export interface Roleplay {
46+
ideals?: string;
47+
bonds?: string;
48+
flaws?: string;
49+
traits?: string;
50+
appearance?: string;
51+
}
52+
53+
/** A known NPC/faction relationship, accrued through play. */
54+
export interface Relationship {
55+
name: string;
56+
kind: string; // ally | rival | mentor | faction | contact …
57+
disposition: string; // friendly | hostile | wary | owes-a-debt …
58+
notes?: string;
59+
}
60+
4461
/** An inventory item. */
4562
export interface Item {
4663
id: string;
@@ -93,6 +110,10 @@ export interface Creature {
93110
inventory?: Item[];
94111
/** Gold pieces. */
95112
gold?: number;
113+
/** Roleplay cues (for the DM's portrayal). */
114+
roleplay?: Roleplay;
115+
/** Known NPCs/factions (accrued through play; carried between adventures). */
116+
relationships?: Relationship[];
96117
}
97118

98119
/** A fully-derived skill line for display / DM context. */

packages/server/src/backend.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { createRealtime } from "./realtime.js";
1111
import { GameRuntime } from "./runtime.js";
1212
import { getAdventureTemplate } from "./adventureTemplates.js";
1313
import { buildStateContext } from "./dm.js";
14-
import { appendLog, createSession, fromSnapshot, toSnapshot } from "./session.js";
14+
import { addCharacterToSession, appendLog, createSession, fromSnapshot, toSnapshot } from "./session.js";
1515
import { buildCharacter, ensureCharacterFields } from "./srd.js";
1616
import { suggestCharacterBuild } from "./suggest.js";
1717
import { JsonStore, newId, type Character, type User } from "./store.js";
@@ -114,6 +114,37 @@ describe("character sheet: spells, inventory, gold", () => {
114114
});
115115
});
116116

117+
describe("carry-over tools persist to the canonical character", () => {
118+
it("grant_item, award_xp (with level-up), and record_relationship write back to the store", async () => {
119+
const store = tmpStore();
120+
const images = new PlaceholderImageProvider();
121+
const d = buildCharacter({
122+
name: "Kael", raceId: "human", classId: "fighter", backgroundId: "soldier",
123+
level: 1, baseScores: ARRAY, chosenSkills: ["athletics", "perception"],
124+
});
125+
const character: Character = {
126+
id: d.creature.id, userId: "u", name: d.creature.name, createdAt: Date.now(), xp: 0, adventureLog: [],
127+
meta: { race: d.meta.race, className: d.meta.className, background: d.meta.background, level: d.meta.level },
128+
sheet: { creature: d.creature, attack: d.attack },
129+
};
130+
await store.createCharacter(character);
131+
132+
const session = createSession("adv", "A", 1);
133+
addCharacterToSession(session, structuredClone(d.creature), d.attack);
134+
const tools = createTools(session, images, store);
135+
136+
await tools.grantItem(character.id, "Healing Potion", 2, "consumable");
137+
await tools.awardXp(character.id, 350); // crosses the level-2 threshold (300)
138+
await tools.recordRelationship(character.id, { name: "Duke Aldric", kind: "noble", disposition: "owes-a-debt" });
139+
140+
const saved = (await store.getCharacter(character.id))!;
141+
expect(saved.sheet.creature.inventory!.some((i) => i.name === "Healing Potion")).toBe(true);
142+
expect(saved.xp).toBe(350);
143+
expect(saved.meta.level).toBe(2); // leveled up
144+
expect(saved.sheet.creature.relationships!.some((r) => r.name === "Duke Aldric")).toBe(true);
145+
});
146+
});
147+
117148
describe("suggestCharacterBuild (heuristic, no model configured)", () => {
118149
it("maps a devout back-story to a cleric", async () => {
119150
const s = await suggestCharacterBuild("A devout priest raised in a mountain temple, sworn to her god.");

packages/server/src/dm.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,93 @@ const DM_TOOLS: DMTool[] = [
251251
: `No level-${level} spell slots remaining.`;
252252
},
253253
},
254+
{
255+
spec: {
256+
type: "function",
257+
function: {
258+
name: "grant_item",
259+
description: "Give the player an item (loot, reward, purchase). Persists to their character across adventures.",
260+
parameters: {
261+
type: "object",
262+
properties: {
263+
name: { type: "string" },
264+
quantity: { type: "integer" },
265+
item_type: { type: "string", description: "weapon | armor | gear | consumable | treasure | wondrous" },
266+
description: { type: "string" },
267+
},
268+
required: ["name"],
269+
},
270+
},
271+
},
272+
async execute(ctx, a) {
273+
const pc = ctx.tools.playerCharacter(ctx.playerId);
274+
if (!pc) return "No character.";
275+
await ctx.tools.grantItem(pc.id, str(a.name, "an item"), num(a.quantity, 1), str(a.item_type, "gear"), a.description ? str(a.description) : undefined);
276+
return `Granted ${str(a.name)}.`;
277+
},
278+
},
279+
{
280+
spec: {
281+
type: "function",
282+
function: {
283+
name: "adjust_gold",
284+
description: "Add or subtract gold pieces from the player (negative to spend). Persists across adventures.",
285+
parameters: { type: "object", properties: { amount: { type: "integer" } }, required: ["amount"] },
286+
},
287+
},
288+
async execute(ctx, a) {
289+
const pc = ctx.tools.playerCharacter(ctx.playerId);
290+
if (!pc) return "No character.";
291+
const total = await ctx.tools.adjustGold(pc.id, num(a.amount, 0));
292+
return `Gold is now ${total} gp.`;
293+
},
294+
},
295+
{
296+
spec: {
297+
type: "function",
298+
function: {
299+
name: "award_xp",
300+
description: "Award experience points for overcoming a challenge. Persists and may level the character up.",
301+
parameters: { type: "object", properties: { amount: { type: "integer" } }, required: ["amount"] },
302+
},
303+
},
304+
async execute(ctx, a) {
305+
const pc = ctx.tools.playerCharacter(ctx.playerId);
306+
if (!pc) return "No character.";
307+
const r = await ctx.tools.awardXp(pc.id, num(a.amount, 0));
308+
return r.leveledUp ? `Awarded XP — leveled up to ${r.level}!` : "XP awarded.";
309+
},
310+
},
311+
{
312+
spec: {
313+
type: "function",
314+
function: {
315+
name: "record_relationship",
316+
description: "Record or update a relationship with an NPC or faction the player has met. Persists across adventures.",
317+
parameters: {
318+
type: "object",
319+
properties: {
320+
name: { type: "string" },
321+
kind: { type: "string", description: "ally | rival | mentor | faction | contact" },
322+
disposition: { type: "string", description: "friendly | hostile | wary | owes-a-debt …" },
323+
notes: { type: "string" },
324+
},
325+
required: ["name", "disposition"],
326+
},
327+
},
328+
},
329+
async execute(ctx, a) {
330+
const pc = ctx.tools.playerCharacter(ctx.playerId);
331+
if (!pc) return "No character.";
332+
await ctx.tools.recordRelationship(pc.id, {
333+
name: str(a.name),
334+
kind: str(a.kind, "contact"),
335+
disposition: str(a.disposition, "neutral"),
336+
notes: a.notes ? str(a.notes) : undefined,
337+
});
338+
return `Noted relationship with ${str(a.name)}.`;
339+
},
340+
},
254341
{
255342
spec: {
256343
type: "function",
@@ -389,6 +476,7 @@ TOOLS:
389476
- start_combat when a fight begins (name the enemies + count + difficulty).
390477
- player_attack when the player attacks a target on their turn — the engine also resolves the enemies' turns and tells you what happened.
391478
- end_combat when the fight is over or the party flees.
479+
- grant_item / adjust_gold when the player finds loot, is rewarded, or spends; award_xp for overcoming challenges; record_relationship when they meet or change standing with an NPC/faction. These persist on the character across adventures — use the PC's roleplay cues and relationships (in CURRENT STATE) to make the story personal.
392480
393481
After calling tools, write the narration the player will read. If no mechanics are needed, just narrate. NEVER write a tool name or its JSON arguments in your narration text — either call the tool or speak as the DM, never both.`;
394482

@@ -432,6 +520,21 @@ function describePlayerSheet(c: Creature): string {
432520
}
433521
}
434522

523+
const rp = c.roleplay;
524+
if (rp && (rp.ideals || rp.bonds || rp.flaws || rp.traits)) {
525+
const bits = [
526+
rp.traits && `traits: ${rp.traits}`,
527+
rp.ideals && `ideals: ${rp.ideals}`,
528+
rp.bonds && `bonds: ${rp.bonds}`,
529+
rp.flaws && `flaws: ${rp.flaws}`,
530+
].filter(Boolean);
531+
out.push(` Roleplay — ${bits.join("; ")}`);
532+
}
533+
534+
if (c.relationships?.length) {
535+
out.push(` Relationships: ${c.relationships.map((r) => `${r.name} (${r.disposition})`).join(", ")}`);
536+
}
537+
435538
const inv = c.inventory ?? [];
436539
if (inv.length || c.gold != null) {
437540
const equipped = inv.filter((i) => i.equipped).map((i) => i.name);

packages/server/src/gameEngine.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import {
1212
currentCombatantId,
1313
distanceFeet,
1414
endEncounter,
15+
type Item,
1516
nextTurn,
17+
type Relationship,
1618
resolveAttack,
1719
roll,
1820
type RollResult,
@@ -22,6 +24,8 @@ import {
2224
import { makeGoblin, makeMonster } from "./content.js";
2325
import { type ImageKind, type ImageProvider } from "./images.js";
2426
import { addSheet, appendLog, type Session } from "./session.js";
27+
import { awardXp as awardCharacterXp } from "./srd.js";
28+
import { newId, type Store } from "./store.js";
2529

2630
export interface CombatStatus {
2731
over: boolean;
@@ -34,6 +38,11 @@ export interface DMTools {
3438
findLivingEnemy(name?: string): Creature | undefined;
3539
rollDice(notation: string, reason?: string): RollResult;
3640
castSpell(casterId: string, level: number, spellName: string): { ok: boolean; remaining: number };
41+
/** Persistent character changes (carry over to the canonical sheet across adventures). */
42+
grantItem(casterId: string, name: string, qty: number, type: string, description?: string): Promise<void>;
43+
adjustGold(casterId: string, delta: number): Promise<number>;
44+
awardXp(casterId: string, amount: number): Promise<{ level: number; leveledUp: boolean }>;
45+
recordRelationship(casterId: string, rel: Relationship): Promise<void>;
3746
setScene(title: string, description: string): void;
3847
generateImage(kind: ImageKind, subject: string): Promise<string | undefined>;
3948
startCombat(enemyCount: number, opts?: { name?: string; difficulty?: string }): void;
@@ -49,7 +58,15 @@ export interface DMTools {
4958
const isDown = (c: Creature | undefined): boolean =>
5059
!c || Boolean(c.isDead) || c.currentHp === 0;
5160

52-
export function createTools(session: Session, images: ImageProvider): DMTools {
61+
export function createTools(session: Session, images: ImageProvider, store?: Store): DMTools {
62+
// Apply a change to the canonical stored character (carry-over) if we have a store.
63+
async function updateCanonical(id: string, fn: (creatureOrChar: { creature: Creature }) => void): Promise<void> {
64+
if (!store) return;
65+
const ch = await store.getCharacter(id);
66+
if (!ch) return;
67+
fn({ creature: ch.sheet.creature });
68+
await store.createCharacter(ch);
69+
}
5370
const skipDead = (id: string) => isDown(session.creatures.get(id));
5471

5572
function livingPCs(): Creature[] {
@@ -158,6 +175,62 @@ export function createTools(session: Session, images: ImageProvider): DMTools {
158175
return { ok, remaining };
159176
},
160177

178+
async grantItem(casterId, name, qty, type, description) {
179+
const item: Item = { id: newId("item"), name, qty: Math.max(1, qty || 1), type, description };
180+
const c = session.creatures.get(casterId);
181+
if (c) c.inventory = [...(c.inventory ?? []), item];
182+
await updateCanonical(casterId, ({ creature }) => {
183+
creature.inventory = [...(creature.inventory ?? []), { ...item, id: newId("item") }];
184+
});
185+
appendLog(session, { kind: "system", text: `${c?.name ?? "Someone"} obtains ${qty > 1 ? `${qty}× ` : ""}${name}.` });
186+
},
187+
188+
async adjustGold(casterId, delta) {
189+
const c = session.creatures.get(casterId);
190+
const next = Math.max(0, (c?.gold ?? 0) + delta);
191+
if (c) c.gold = next;
192+
await updateCanonical(casterId, ({ creature }) => {
193+
creature.gold = Math.max(0, (creature.gold ?? 0) + delta);
194+
});
195+
appendLog(session, { kind: "system", text: `${c?.name ?? "Someone"} ${delta >= 0 ? "gains" : "spends"} ${Math.abs(delta)} gp.` });
196+
return next;
197+
},
198+
199+
async awardXp(casterId, amount) {
200+
const c = session.creatures.get(casterId);
201+
let result = { level: 0, leveledUp: false };
202+
if (store) {
203+
const ch = await store.getCharacter(casterId);
204+
if (ch) {
205+
result = awardCharacterXp(ch, amount);
206+
await store.createCharacter(ch);
207+
}
208+
}
209+
appendLog(session, {
210+
kind: "system",
211+
text: `${c?.name ?? "Someone"} gains ${amount} XP${result.leveledUp ? ` and reaches level ${result.level}! (takes effect next rest)` : ""}.`,
212+
});
213+
return result;
214+
},
215+
216+
async recordRelationship(casterId, rel) {
217+
const upsert = (list: Relationship[] = []): Relationship[] => {
218+
const i = list.findIndex((r) => r.name.toLowerCase() === rel.name.toLowerCase());
219+
if (i >= 0) {
220+
const next = [...list];
221+
next[i] = rel;
222+
return next;
223+
}
224+
return [...list, rel];
225+
};
226+
const c = session.creatures.get(casterId);
227+
if (c) c.relationships = upsert(c.relationships);
228+
await updateCanonical(casterId, ({ creature }) => {
229+
creature.relationships = upsert(creature.relationships);
230+
});
231+
appendLog(session, { kind: "system", text: `${c?.name ?? "Someone"}'s relationship with ${rel.name} is noted (${rel.disposition}).` });
232+
},
233+
161234
setScene(title, description) {
162235
session.scene.title = title;
163236
session.scene.description = description;

packages/server/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async function processTurn(session: Session, userId: string, text: string): Prom
7979
await broadcastSnapshot(session);
8080

8181
try {
82-
const tools = createTools(session, images);
82+
const tools = createTools(session, images, store);
8383
const narration = await dm.respond({ session, playerId: userId, text, tools });
8484
appendLog(session, { kind: "narration", author: "Dungeon Master", text: narration });
8585
} catch (err) {

packages/server/src/postgres.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,12 @@ CREATE TABLE IF NOT EXISTS characters (
3838
portrait_description text,
3939
meta jsonb NOT NULL,
4040
sheet jsonb NOT NULL,
41+
extra jsonb,
4142
created_at bigint NOT NULL
4243
);
4344
CREATE INDEX IF NOT EXISTS characters_user_id_idx ON characters (user_id);
45+
-- For tables created before the extra column existed:
46+
ALTER TABLE characters ADD COLUMN IF NOT EXISTS extra jsonb;
4447
4548
CREATE TABLE IF NOT EXISTS adventures (
4649
id text PRIMARY KEY,
@@ -71,6 +74,7 @@ function rowToUser(r: Record<string, unknown>): User {
7174
}
7275

7376
function rowToCharacter(r: Record<string, unknown>): Character {
77+
const extra = (r.extra as { xp?: number; adventureLog?: Character["adventureLog"] } | null) ?? {};
7478
return {
7579
id: r.id as string,
7680
userId: r.user_id as string,
@@ -79,6 +83,8 @@ function rowToCharacter(r: Record<string, unknown>): Character {
7983
portraitUrl: (r.portrait_url as string) ?? undefined,
8084
portraitVariant: (r.portrait_variant as number) ?? undefined,
8185
portraitDescription: (r.portrait_description as string) ?? undefined,
86+
xp: extra.xp,
87+
adventureLog: extra.adventureLog,
8288
meta: r.meta as Character["meta"],
8389
sheet: r.sheet as Character["sheet"],
8490
createdAt: Number(r.created_at),
@@ -136,14 +142,14 @@ export class PostgresStore implements Store {
136142
async createCharacter(c: Character): Promise<void> {
137143
// Upsert — also used to persist portrait regeneration.
138144
await this.pool.query(
139-
`INSERT INTO characters (id,user_id,name,backstory,portrait_url,portrait_variant,portrait_description,meta,sheet,created_at)
140-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
145+
`INSERT INTO characters (id,user_id,name,backstory,portrait_url,portrait_variant,portrait_description,meta,sheet,extra,created_at)
146+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
141147
ON CONFLICT (id) DO UPDATE SET
142148
name=EXCLUDED.name, backstory=EXCLUDED.backstory, portrait_url=EXCLUDED.portrait_url,
143149
portrait_variant=EXCLUDED.portrait_variant, portrait_description=EXCLUDED.portrait_description,
144-
meta=EXCLUDED.meta, sheet=EXCLUDED.sheet`,
150+
meta=EXCLUDED.meta, sheet=EXCLUDED.sheet, extra=EXCLUDED.extra`,
145151
[c.id, c.userId, c.name, c.backstory ?? null, c.portraitUrl ?? null, c.portraitVariant ?? null,
146-
c.portraitDescription ?? null, J(c.meta), J(c.sheet), c.createdAt],
152+
c.portraitDescription ?? null, J(c.meta), J(c.sheet), J({ xp: c.xp ?? 0, adventureLog: c.adventureLog ?? [] }), c.createdAt],
147153
);
148154
}
149155
async listCharacters(userId: string): Promise<Character[]> {

packages/server/src/routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ export async function registerRoutes(app: FastifyInstance, store: Store, images:
136136
createdAt: Date.now(),
137137
backstory: typeof body.backstory === "string" ? body.backstory.slice(0, 4000) : undefined,
138138
portraitVariant: 0,
139+
xp: 0,
140+
adventureLog: [],
139141
meta: {
140142
race: derived.meta.race,
141143
className: derived.meta.className,

0 commit comments

Comments
 (0)