diff --git a/packages/mcp-bridge/src/client.js b/packages/mcp-bridge/src/client.js index c8c15d99..c7d8b334 100644 --- a/packages/mcp-bridge/src/client.js +++ b/packages/mcp-bridge/src/client.js @@ -96,6 +96,15 @@ async function getCharacters() { return result.characters || []; } +async function getCharacter() { + const profile = activeHandle.resolveProfileName(); + if (!profile) return null; + const profileData = townClient.loadProfile(profile); + if (!profileData) return null; + const { result } = await authenticatedRequest('GET', `/api/characters/${profileData.id}`); + return result || null; +} + async function authenticatedRequest(method, apiPath, body) { const { auth, result, profile } = await activeHandle.request(method, apiPath, body); if (profile?.profile) setActiveProfileName(profile.profile); @@ -250,6 +259,24 @@ function flushContext() { }); } +function formatCharacter(character) { + if (!character) return '你还没有登录角色。'; + + let text = `🎭 【${character.name} 的角色面板】\n\n`; + text += `⭐ 等级: ${character.level} (经验: ${character.xp})\n`; + text += `❤️ 生命: ${character.hp}/${character.maxHp}\n`; + text += `💰 金币: ${character.gold}\n\n`; + text += `📈 属性:\n`; + text += ` 力 ${character.str} (STR)\n`; + text += ` 敏 ${character.dex} (DEX)\n`; + text += ` 智 ${character.int} (INT)\n`; + text += ` 体 ${character.vit} (VIT)\n\n`; + text += `🎁 可分配点数: ${character.statPoints}\n`; + text += `✨ 技能点数: ${character.skillPoints}`; + + return text; +} + module.exports = { connect, disconnect, @@ -257,6 +284,7 @@ module.exports = { logout, listProfiles, getCharacters, + getCharacter, getMap, look, walk, @@ -273,6 +301,7 @@ module.exports = { formatLogin: townClient.formatLogin, formatProfilesList: townClient.formatProfilesList, formatCharacters: townClient.formatCharacters, + formatCharacter, formatMap: townClient.formatMap, formatLook: townClient.formatLook, formatWalk: townClient.formatWalk, diff --git a/packages/mcp-bridge/src/tools/character.js b/packages/mcp-bridge/src/tools/character.js index d33b4269..0263d38e 100644 --- a/packages/mcp-bridge/src/tools/character.js +++ b/packages/mcp-bridge/src/tools/character.js @@ -32,6 +32,12 @@ const definitions = [ inputSchema: { type: 'object', properties: {} }, annotations: { title: 'Characters', readOnlyHint: true, destructiveHint: false, openWorldHint: false }, }, + { + name: 'check_character', + description: '查看当前登录角色的属性面板,包括等级、经验、生命值、属性点和金币等', + inputSchema: { type: 'object', properties: {} }, + annotations: { title: 'Check Character', readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, ]; async function handle(name, args, client) { @@ -53,6 +59,14 @@ async function handle(name, args, client) { return { content: [{ type: 'text', text: client.formatCharacters(characters) }] }; } + if (name === 'check_character') { + const character = await client.getCharacter(); + if (!character) { + return { content: [{ type: 'text', text: '你还没有登录角色,请先使用 login 命令登录。' }] }; + } + return { content: [{ type: 'text', text: client.formatCharacter(character) }] }; + } + return null; } diff --git a/server/src/engine/world-engine.js b/server/src/engine/world-engine.js index a2bd73b1..4629183f 100644 --- a/server/src/engine/world-engine.js +++ b/server/src/engine/world-engine.js @@ -39,6 +39,20 @@ const playerActivities = {}; const walkAborts = new Map(); const events = new EventEmitter(); +const ZONE_REWARDS = { + weapon: { xp: 10, gold: 5 }, + practice: { xp: 10, gold: 0 }, + restaurant: { xp: 5, gold: 0 }, + inn: { xp: 5, gold: 0 }, + dock: { xp: 0, gold: 10 }, + pond: { xp: 0, gold: 5 }, + farm: { xp: 5, gold: 0 }, + blacksmith: { xp: 10, gold: 0 }, + shrine: { xp: 5, gold: 0 }, + marketplace: { xp: 3, gold: 5 }, + hotspring: { xp: 5, gold: 0 }, +}; + /** @type {import('./plugin-manager').PluginManager|null} */ let pluginManager = null; @@ -317,6 +331,56 @@ function getProfileByHandle(handle) { return sqliteStateStore.getProfileByHandle(handle); } +function getOrCreateCharacter(profileId, name) { + const existing = sqliteStateStore.getCharacterByProfileId(profileId); + if (existing) return existing; + + const now = new Date().toISOString(); + const character = sqliteStateStore.createCharacter({ + id: nextSnowflakeId(), + profileId, + name, + level: 1, + xp: 0, + hp: 100, + maxHp: 100, + str: 5, + dex: 5, + int: 5, + vit: 5, + gold: 50, + statPoints: 0, + skillPoints: 0, + createdAt: now, + updatedAt: now, + }); + return character; +} + +function getCharacter(profileId) { + return sqliteStateStore.getCharacterByProfileId(profileId); +} + +function getCharacterById(id) { + return sqliteStateStore.getCharacter(id); +} + +function updateCharacter(id, updates) { + return sqliteStateStore.updateCharacter(id, updates); +} + +function addCharacterXp(id, amount) { + return sqliteStateStore.addCharacterXp(id, amount); +} + +function addCharacterGold(id, amount) { + return sqliteStateStore.addCharacterGold(id, amount); +} + +function allocateStatPoint(id, stat) { + return sqliteStateStore.allocateStatPoint(id, stat); +} + function verifyLoginProof(profile, timestamp, signature) { if (!profile || !profile.publicKey || typeof signature !== 'string') return false; if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) return false; @@ -361,6 +425,7 @@ function loginProfile(handle, timestamp, signature) { const token = crypto.randomUUID(); const now = Date.now(); const player = join(profile.id, profile.name, profile.sprite, { trackActivity: true }); + const character = getOrCreateCharacter(profile.id, profile.name); const session = { id: profile.id, playerId: profile.id, @@ -385,6 +450,7 @@ function loginProfile(handle, timestamp, signature) { expires_at: new Date(session.expiresAt).toISOString(), lease_expires_at: new Date(session.leaseExpiresAt).toISOString(), player: sanitize(player), + character, message: hadActiveSession ? `已接管角色 ${profile.name} 的在线会话。` : `已登录角色 ${profile.name}。`, }; } @@ -691,6 +757,36 @@ function interact(playerId, item) { player.interactionText = result.action; player.interactionIcon = result.icon || ''; player.interactionSound = result.sound || 'interact'; + + // Determine zone category for rewards + let zoneCategory = null; + if (zone) { + const normalizedName = (zone.name || '').toLowerCase(); + for (const [matcher, category] of ZONE_CATEGORY_MAP) { + if (matcher.test(normalizedName)) { + zoneCategory = category; + break; + } + } + } + + // Grant XP/Gold rewards if applicable + const rewards = ZONE_REWARDS[zoneCategory]; + let rewardText = ''; + if (rewards) { + const character = getOrCreateCharacter(playerId, player.name); + if (character) { + if (rewards.xp > 0) { + addCharacterXp(character.id, rewards.xp); + rewardText += ` +${rewards.xp} XP`; + } + if (rewards.gold > 0) { + addCharacterGold(character.id, rewards.gold); + rewardText += ` +${rewards.gold} Gold`; + } + } + } + emitPerception('interact', playerId, player.name, player.x, player.y, { zone: zone ? zone.name : '小镇街道', action: result.action }); broadcast(); setTimeout(() => { @@ -712,8 +808,8 @@ function interact(playerId, item) { item: result.item || item || null, }; events.emit('interaction', entry); - addActivity(playerId, { type: 'interact', text: `在${zone ? zone.name : '街道'}: ${result.action}` }); - return { zone: zone ? zone.name : '小镇街道', ...result }; + addActivity(playerId, { type: 'interact', text: `在${zone ? zone.name : '街道'}: ${result.action}${rewardText}` }); + return { zone: zone ? zone.name : '小镇街道', rewards: rewards || null, ...result }; } function look(playerId) { @@ -777,6 +873,13 @@ module.exports = { getTokenSession, getProfile, getProfileByHandle, + getOrCreateCharacter, + getCharacter, + getCharacterById, + updateCharacter, + addCharacterXp, + addCharacterGold, + allocateStatPoint, pruneExpiredSessions, join, removePlayer, diff --git a/server/src/persistence/sqlite-state-store.js b/server/src/persistence/sqlite-state-store.js index 8192b551..b9e07f92 100644 --- a/server/src/persistence/sqlite-state-store.js +++ b/server/src/persistence/sqlite-state-store.js @@ -40,6 +40,26 @@ class SQLiteStateStore { profile_id TEXT PRIMARY KEY, token TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS characters ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + name TEXT NOT NULL, + level INTEGER DEFAULT 1, + xp INTEGER DEFAULT 0, + hp INTEGER, + max_hp INTEGER, + str INTEGER DEFAULT 1, + dex INTEGER DEFAULT 1, + int INTEGER DEFAULT 1, + vit INTEGER DEFAULT 1, + gold INTEGER DEFAULT 0, + stat_points INTEGER DEFAULT 0, + skill_points INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (profile_id) REFERENCES profiles(id) + ); `); const columns = new Set( @@ -55,6 +75,7 @@ class SQLiteStateStore { this.database.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_profiles_handle ON profiles(handle); CREATE UNIQUE INDEX IF NOT EXISTS idx_profiles_public_key ON profiles(public_key); + CREATE INDEX IF NOT EXISTS idx_characters_profile_id ON characters(profile_id); `); } @@ -196,6 +217,143 @@ class SQLiteStateStore { WHERE profile_id = ? `).run(profileId); } + + createCharacter(character) { + const existing = this.getCharacterByProfileId(character.profileId); + if (existing) return existing; + + this.database.prepare(` + INSERT INTO characters (id, profile_id, name, level, xp, hp, max_hp, str, dex, int, vit, gold, stat_points, skill_points, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + character.id, + character.profileId, + character.name, + character.level || 1, + character.xp || 0, + character.hp, + character.maxHp, + character.str || 1, + character.dex || 1, + character.int || 1, + character.vit || 1, + character.gold || 0, + character.statPoints || 0, + character.skillPoints || 0, + character.createdAt, + character.updatedAt, + ); + + return this.getCharacter(character.id); + } + + getCharacter(id) { + const row = this.database.prepare(` + SELECT id, profile_id AS profileId, name, level, xp, hp, max_hp AS maxHp, str, dex, int, vit, gold, stat_points AS statPoints, skill_points AS skillPoints, created_at AS createdAt, updated_at AS updatedAt + FROM characters + WHERE id = ? + `).get(id); + return row || null; + } + + getCharacterByProfileId(profileId) { + const row = this.database.prepare(` + SELECT id, profile_id AS profileId, name, level, xp, hp, max_hp AS maxHp, str, dex, int, vit, gold, stat_points AS statPoints, skill_points AS skillPoints, created_at AS createdAt, updated_at AS updatedAt + FROM characters + WHERE profile_id = ? + `).get(profileId); + return row || null; + } + + updateCharacter(id, updates) { + const character = this.getCharacter(id); + if (!character) return null; + + const updated = { ...character, ...updates, updatedAt: new Date().toISOString() }; + + this.database.prepare(` + UPDATE characters + SET name = ?, level = ?, xp = ?, hp = ?, max_hp = ?, str = ?, dex = ?, int = ?, vit = ?, gold = ?, stat_points = ?, skill_points = ?, updated_at = ? + WHERE id = ? + `).run( + updated.name, + updated.level, + updated.xp, + updated.hp, + updated.maxHp, + updated.str, + updated.dex, + updated.int, + updated.vit, + updated.gold, + updated.statPoints, + updated.skillPoints, + updated.updatedAt, + id, + ); + + return this.getCharacter(id); + } + + addCharacterXp(id, xpAmount) { + const character = this.getCharacter(id); + if (!character) return null; + + const newXp = character.xp + xpAmount; + const newLevel = Math.floor(newXp / 100) + 1; + const leveledUp = newLevel > character.level; + const statPointsGained = leveledUp ? (newLevel - character.level) * 3 : 0; + + this.database.prepare(` + UPDATE characters + SET xp = ?, level = ?, stat_points = stat_points + ?, updated_at = ? + WHERE id = ? + `).run(newXp, newLevel, statPointsGained, new Date().toISOString(), id); + + return this.getCharacter(id); + } + + addCharacterGold(id, goldAmount) { + const character = this.getCharacter(id); + if (!character) return null; + + this.database.prepare(` + UPDATE characters + SET gold = gold + ?, updated_at = ? + WHERE id = ? + `).run(goldAmount, new Date().toISOString(), id); + + return this.getCharacter(id); + } + + updateCharacterHp(id, hp) { + const character = this.getCharacter(id); + if (!character) return null; + + this.database.prepare(` + UPDATE characters + SET hp = ?, updated_at = ? + WHERE id = ? + `).run(hp, new Date().toISOString(), id); + + return this.getCharacter(id); + } + + allocateStatPoint(id, stat) { + const character = this.getCharacter(id); + if (!character || character.statPoints <= 0) return null; + + const validStats = ['str', 'dex', 'int', 'vit']; + if (!validStats.includes(stat)) return null; + + this.database.prepare(` + UPDATE characters + SET ${stat} = ${stat} + 1, stat_points = stat_points - 1, updated_at = ? + WHERE id = ? + `).run(new Date().toISOString(), id); + + return this.getCharacter(id); + } } const sqliteStateStore = new SQLiteStateStore(DATABASE_FILE); diff --git a/server/src/routes.js b/server/src/routes.js index 90e6fa46..cc376bb4 100644 --- a/server/src/routes.js +++ b/server/src/routes.js @@ -26,6 +26,49 @@ router.get('/characters', (_req, res) => { res.json({ characters: worldEngine.getCharacterList() }); }); +router.get('/characters/:profileId', requireSession, (req, res) => { + const { profileId } = req.params; + const character = worldEngine.getCharacter(profileId); + if (!character) return res.status(404).json({ error: '角色不存在' }); + res.json(character); +}); + +router.put('/characters/:id', requireSession, (req, res) => { + const { id } = req.params; + const updates = req.body || {}; + const character = worldEngine.getCharacterById(id); + if (!character) return res.status(404).json({ error: '角色不存在' }); + const updated = worldEngine.updateCharacter(id, updates); + res.json(updated); +}); + +router.post('/characters/:id/xp', requireSession, (req, res) => { + const { id } = req.params; + const { amount } = req.body || {}; + if (typeof amount !== 'number') return res.status(400).json({ error: '缺少 amount 字段' }); + const character = worldEngine.addCharacterXp(id, amount); + if (!character) return res.status(404).json({ error: '角色不存在' }); + res.json(character); +}); + +router.post('/characters/:id/gold', requireSession, (req, res) => { + const { id } = req.params; + const { amount } = req.body || {}; + if (typeof amount !== 'number') return res.status(400).json({ error: '缺少 amount 字段' }); + const character = worldEngine.addCharacterGold(id, amount); + if (!character) return res.status(404).json({ error: '角色不存在' }); + res.json(character); +}); + +router.post('/characters/:id/stats', requireSession, (req, res) => { + const { id } = req.params; + const { stat } = req.body || {}; + if (!stat) return res.status(400).json({ error: '缺少 stat 字段' }); + const character = worldEngine.allocateStatPoint(id, stat); + if (!character) return res.status(400).json({ error: '角色不存在或没有可分配的点数' }); + res.json(character); +}); + router.get('/map', maybeSession, (req, res) => { res.json({ directory: worldEngine.readMap(req.requestHandle?.playerId || null) }); });