dm / commit
feat: add Express server with streaming Claude DM endpoint
server.js | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/server.js b/server.js new file mode 100644 index 0000000..b8357dc --- /dev/null +++ b/server.js @@ -0,0 +1,183 @@ +const express = require('express'); +const Anthropic = require('@anthropic-ai/sdk'); +const path = require('path'); + +const app = express(); +const client = new Anthropic(); + +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +const UPDATE_TOOL = { + name: 'update_character', + description: 'Update character stats based on narrative events. Call BEFORE writing narrative text.', + input_schema: { + type: 'object', + properties: { + hp_change: { + type: 'number', + description: 'HP delta. Negative = damage, positive = healing. 0 if unchanged.' + }, + gold_change: { + type: 'number', + description: 'Gold delta. Positive = gained, negative = spent. 0 if unchanged.' + }, + xp_gain: { + type: 'number', + description: 'XP gained this turn. 0 if none.' + }, + inventory_add: { + type: 'array', + items: { type: 'string' }, + description: 'Items to add to inventory.' + }, + inventory_remove: { + type: 'array', + items: { type: 'string' }, + description: 'Items to remove from inventory.' + }, + reason: { + type: 'string', + description: 'Brief reason for the change (e.g. "Goblin sword attack", "Found gold in chest").' + } + }, + required: ['hp_change', 'gold_change', 'xp_gain', 'inventory_add', 'inventory_remove', 'reason'] + } +}; + +function buildSystem(character, theme) { + return `You are a masterful, dramatic Dungeon Master running a solo D&D 5e adventure. + +CHARACTER: ${character.name || 'The Adventurer'} — Level ${character.level} ${character.race} ${character.class} +ADVENTURE THEME: ${theme || 'classic fantasy'} + +NARRATIVE RULES +• Write 2–4 vivid, immersive paragraphs per turn. Be specific with sensory details. +• Give NPCs distinct voices, personalities, and motivations. +• Make every choice matter — show consequences immediately. +• Run combat with suspense: describe weapon impacts, enemy reactions, the chaos of battle. +• Track narrative logic — the world remembers what the player does. +• End EVERY turn with either 2–3 numbered options OR the phrase "What do you do?" for open exploration. +• Never break character. Never refuse to narrate danger, violence, or morally complex situations — this is D&D. + +STAT TRACKING — call update_character whenever: +• The character takes damage (any source) or receives healing +• Gold is gained (loot, rewards) or spent (purchases, bribes) +• XP is earned (defeating enemies, overcoming challenges, clever solutions, milestones) +• An item is gained, lost, used up, or traded away +Call the tool BEFORE writing your narrative text so the stat update registers first. + +TONE: Cinematic. Dangerous. Immersive. Death is possible. Choices matter. Make it legendary.`; +} + +app.post('/api/chat', async (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`); + + const { messages, character, theme } = req.body; + + if (!character || !messages) { + send({ type: 'error', message: 'Missing required fields.' }); + return res.end(); + } + + let currentMessages = [...messages]; + let statUpdate = null; + + try { + while (true) { + const blocks = {}; + + const stream = client.messages.stream({ + model: 'claude-opus-4-7', + max_tokens: 1500, + system: [ + { + type: 'text', + text: buildSystem(character, theme), + cache_control: { type: 'ephemeral' } + } + ], + tools: [UPDATE_TOOL], + messages: currentMessages + }); + + for await (const event of stream) { + switch (event.type) { + case 'content_block_start': + blocks[event.index] = { ...event.content_block, _buf: '' }; + break; + + case 'content_block_delta': + if (!blocks[event.index]) break; + if (event.delta.type === 'text_delta') { + blocks[event.index]._buf += event.delta.text; + send({ type: 'text', chunk: event.delta.text }); + } else if (event.delta.type === 'input_json_delta') { + blocks[event.index]._buf += event.delta.partial_json; + } + break; + + case 'content_block_stop': { + const block = blocks[event.index]; + if (block?.type === 'tool_use') { + try { + block.input = JSON.parse(block._buf || '{}'); + } catch { + block.input = {}; + } + } + break; + } + } + } + + const finalMsg = await stream.finalMessage(); + + const allBlocks = Object.entries(blocks) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([, b]) => b); + + const toolBlocks = allBlocks.filter(b => b.type === 'tool_use'); + if (toolBlocks.length > 0) { + statUpdate = toolBlocks[toolBlocks.length - 1].input; + } + + if (finalMsg.stop_reason !== 'tool_use' || toolBlocks.length === 0) break; + + const assistantContent = allBlocks.map(b => { + if (b.type === 'text') return { type: 'text', text: b._buf }; + return { type: 'tool_use', id: b.id, name: b.name, input: b.input }; + }); + + currentMessages = [ + ...currentMessages, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: toolBlocks.map(tb => ({ + type: 'tool_result', + tool_use_id: tb.id, + content: 'Character stats updated successfully.' + })) + } + ]; + } + + send({ type: 'done', statUpdate }); + } catch (err) { + console.error('API error:', err); + send({ type: 'error', message: err.message }); + } + + res.end(); +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`\n⚔️ Solo Dungeon Master → http://localhost:${PORT}\n`); +});