diff --git a/packages/mcp-bridge/src/client.js b/packages/mcp-bridge/src/client.js index c4fa51ce..ac4b389b 100644 --- a/packages/mcp-bridge/src/client.js +++ b/packages/mcp-bridge/src/client.js @@ -2,6 +2,13 @@ const townClient = require('../../../shared/town-client'); let activeHandle = new townClient.SessionHandle(); let heartbeatTimer = null; +let requestQueue = Promise.resolve(); + +function runSerial(task) { + const operation = requestQueue.catch(() => {}).then(task); + requestQueue = operation.catch(() => {}); + return operation; +} function setActiveProfileName(profile) { if (profile) activeHandle = new townClient.SessionHandle(profile); @@ -10,15 +17,17 @@ function setActiveProfileName(profile) { function startHeartbeatLoop() { if (heartbeatTimer) return; heartbeatTimer = setInterval(async () => { - const targetProfile = activeHandle.resolveProfileName(); - if (!targetProfile) return; - try { - const result = await activeHandle.heartbeat(); - if (!result.ok && result.reason === 'unauthorized') { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - } catch {} + await runSerial(async () => { + const targetProfile = activeHandle.resolveProfileName(); + if (!targetProfile) return; + try { + const result = await activeHandle.heartbeat(); + if (!result.ok && result.reason === 'unauthorized') { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + } catch {} + }); }, townClient.HEARTBEAT_INTERVAL_MS); } @@ -33,21 +42,25 @@ async function connect() { } async function disconnect() { - stopHeartbeatLoop(); - const targetProfile = activeHandle.resolveProfileName(); - if (targetProfile) await activeHandle.logout(); - console.error('👋 已离开小镇'); + await runSerial(async () => { + stopHeartbeatLoop(); + const targetProfile = activeHandle.resolveProfileName(); + if (targetProfile) await activeHandle.logout(); + console.error('👋 已离开小镇'); + }); } async function login(args = {}) { - const result = await activeHandle.login(args); - if (result.profile) { - setActiveProfileName(result.profile); - if (result.status === 'authenticated' || result.status === 'created_and_authenticated' || result.status === 'took_over_session') { - startHeartbeatLoop(); + return runSerial(async () => { + const result = await activeHandle.login(args); + if (result.profile) { + setActiveProfileName(result.profile); + if (result.status === 'authenticated' || result.status === 'created_and_authenticated' || result.status === 'took_over_session') { + startHeartbeatLoop(); + } } - } - return result; + return result; + }); } function listProfiles() { @@ -61,44 +74,56 @@ async function getCharacters() { } async function getMap() { - const { auth, result, profile } = await activeHandle.request('GET', '/api/map'); - if (profile?.profile) setActiveProfileName(profile.profile); - if (profile) startHeartbeatLoop(); - return { auth, result: result ? result.directory : null }; + return runSerial(async () => { + const { auth, result, profile } = await activeHandle.request('GET', '/api/map'); + if (profile?.profile) setActiveProfileName(profile.profile); + if (profile) startHeartbeatLoop(); + return { auth, result: result ? result.directory : null }; + }); } async function look() { - const { auth, result, profile } = await activeHandle.request('GET', '/api/look'); - if (profile?.profile) setActiveProfileName(profile.profile); - if (profile) startHeartbeatLoop(); - return { auth, result }; + return runSerial(async () => { + const { auth, result, profile } = await activeHandle.request('GET', '/api/look'); + if (profile?.profile) setActiveProfileName(profile.profile); + if (profile) startHeartbeatLoop(); + return { auth, result }; + }); } async function walk(direction, steps) { - const { auth, result, profile } = await activeHandle.request('POST', '/api/walk', { direction, steps }); - if (profile?.profile) setActiveProfileName(profile.profile); - if (profile) startHeartbeatLoop(); - return { auth, result }; + return runSerial(async () => { + const { auth, result, profile } = await activeHandle.request('POST', '/api/walk', { direction, steps }); + if (profile?.profile) setActiveProfileName(profile.profile); + if (profile) startHeartbeatLoop(); + return { auth, result }; + }); } async function say(text) { - const { auth, result, profile } = await activeHandle.request('POST', '/api/say', { text }); - if (profile?.profile) setActiveProfileName(profile.profile); - if (profile) startHeartbeatLoop(); - return { auth, result }; + return runSerial(async () => { + const { auth, result, profile } = await activeHandle.request('POST', '/api/say', { text }); + if (profile?.profile) setActiveProfileName(profile.profile); + if (profile) startHeartbeatLoop(); + return { auth, result }; + }); } async function interact() { - const { auth, result, profile } = await activeHandle.request('POST', '/api/interact'); - if (profile?.profile) setActiveProfileName(profile.profile); - if (profile) startHeartbeatLoop(); - return { auth, result }; + return runSerial(async () => { + const { auth, result, profile } = await activeHandle.request('POST', '/api/interact'); + if (profile?.profile) setActiveProfileName(profile.profile); + if (profile) startHeartbeatLoop(); + return { auth, result }; + }); } async function setThinking(isThinking) { - const { profile } = await activeHandle.request('PUT', '/api/status', { isThinking }); - if (profile?.profile) setActiveProfileName(profile.profile); - if (profile) startHeartbeatLoop(); + return runSerial(async () => { + const { profile } = await activeHandle.request('PUT', '/api/status', { isThinking }); + if (profile?.profile) setActiveProfileName(profile.profile); + if (profile) startHeartbeatLoop(); + }); } module.exports = { diff --git a/packages/mcp-bridge/src/tools/character.js b/packages/mcp-bridge/src/tools/character.js index 70309d76..fd68e8fe 100644 --- a/packages/mcp-bridge/src/tools/character.js +++ b/packages/mcp-bridge/src/tools/character.js @@ -9,6 +9,8 @@ const definitions = [ create: { type: 'boolean', description: '是否进入创建模式' }, name: { type: 'string', description: '创建模式下的新角色名字' }, sprite: { type: 'string', description: '创建模式下使用的角色外观' }, + loginMode: { type: 'string', enum: ['resume', 'spawn'], description: '登录方式:resume 恢复原地,spawn 回到起点' }, + respawn: { type: 'boolean', description: '是否显式回到起点重新入镇;等价于 loginMode=spawn' }, }, }, annotations: { title: 'Login', readOnlyHint: false, destructiveHint: false, openWorldHint: false }, diff --git a/packages/mcp-bridge/src/tools/movement.js b/packages/mcp-bridge/src/tools/movement.js index 85908712..ff7d410f 100644 --- a/packages/mcp-bridge/src/tools/movement.js +++ b/packages/mcp-bridge/src/tools/movement.js @@ -20,7 +20,7 @@ async function handle(name, args, client) { if (!result) { return { content: [{ type: 'text', text: auth?.message || '当前还没有可用 profile,请先 login。' }] }; } - return { content: [{ type: 'text', text: client.formatWalk(args.direction, args.steps) }] }; + return { content: [{ type: 'text', text: client.formatWalk(args.direction, args.steps, result) }] }; } module.exports = { definitions, handle }; diff --git a/packages/mcp-bridge/test/client-serialization.test.js b/packages/mcp-bridge/test/client-serialization.test.js new file mode 100644 index 00000000..0f3f1392 --- /dev/null +++ b/packages/mcp-bridge/test/client-serialization.test.js @@ -0,0 +1,79 @@ +const { describe, it, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); + +const CLIENT_PATH = require.resolve('../src/client'); +const TOWN_CLIENT_PATH = require.resolve('../../../shared/town-client'); + +const originalClientModule = require.cache[CLIENT_PATH]; +const originalTownClientModule = require.cache[TOWN_CLIENT_PATH]; + +function clearModules() { + delete require.cache[CLIENT_PATH]; + delete require.cache[TOWN_CLIENT_PATH]; +} + +afterEach(() => { + clearModules(); + if (originalClientModule) require.cache[CLIENT_PATH] = originalClientModule; + if (originalTownClientModule) require.cache[TOWN_CLIENT_PATH] = originalTownClientModule; +}); + +describe('bridge client serialization', () => { + it('runs overlapping actions against the active session one at a time', async () => { + const events = []; + + class MockSessionHandle { + resolveProfileName() { + return 'mock-profile'; + } + + async request(_method, path) { + events.push(`start:${path}`); + await new Promise((resolve) => setTimeout(resolve, path === '/api/look' ? 20 : 0)); + events.push(`end:${path}`); + return { + auth: null, + result: { player: { x: 5, y: 5, zone: '小镇街道', zoneDesc: '空旷的街道' }, nearby: [] }, + profile: { profile: 'mock-profile' }, + }; + } + + async heartbeat() { + events.push('heartbeat'); + return { ok: true }; + } + } + + require.cache[TOWN_CLIENT_PATH] = { + exports: { + SessionHandle: MockSessionHandle, + HEARTBEAT_INTERVAL_MS: 60_000, + listProfiles: () => ({}), + discoverServer: async () => 'http://example.test', + requestJson: async () => ({}), + formatLogin: () => '', + formatProfilesList: () => '', + formatCharacters: () => '', + formatMap: () => '', + formatLook: () => '', + formatWalk: () => '', + formatSay: () => '', + formatInteract: () => '', + }, + }; + + const client = require('../src/client'); + + await Promise.all([ + client.look(), + client.walk('E', 1), + ]); + + assert.deepEqual(events, [ + 'start:/api/look', + 'end:/api/look', + 'start:/api/walk', + 'end:/api/walk', + ]); + }); +}); diff --git a/packages/mcp-bridge/test/smoke.test.js b/packages/mcp-bridge/test/smoke.test.js index 13b2ba86..4a765670 100644 --- a/packages/mcp-bridge/test/smoke.test.js +++ b/packages/mcp-bridge/test/smoke.test.js @@ -18,6 +18,7 @@ function createMockServer() { const profiles = new Map(); const tokens = new Map(); const activeTokens = new Map(); + const playerStates = new Map(); let sequence = 2000n; return new Promise((resolve) => { @@ -68,6 +69,13 @@ function createMockServer() { const previousToken = activeTokens.get(profile.id); if (previousToken) tokens.delete(previousToken); + const loginMode = payload.loginMode === 'spawn' || payload.respawn ? 'spawn' : 'resume'; + const priorState = playerStates.get(profile.id) || { x: 5, y: 5, direction: 'S', zone: 'Town Center', zoneDesc: 'Central square' }; + const currentState = loginMode === 'spawn' + ? { x: 5, y: 5, direction: 'S', zone: 'Town Center', zoneDesc: 'Central square' } + : priorState; + playerStates.set(profile.id, currentState); + const token = `token-${profile.id}-${Date.now()}`; tokens.set(token, { id: profile.id, name: profile.name, sprite: profile.sprite }); activeTokens.set(profile.id, token); @@ -79,7 +87,11 @@ function createMockServer() { token, expires_at: new Date(Date.now() + 3600000).toISOString(), lease_expires_at: new Date(Date.now() + 180000).toISOString(), - message: previousToken ? `已接管角色 ${profile.name} 的在线会话。` : `已登录角色 ${profile.name}。`, + login_mode: loginMode, + player: { id: profile.id, name: profile.name, sprite: profile.sprite, ...currentState, isThinking: false, message: null, presenceState: 'active' }, + message: loginMode === 'spawn' + ? `已重新进入小镇,${profile.name} 已回到起点。` + : (previousToken ? `已接管角色 ${profile.name} 的在线会话。` : `已登录角色 ${profile.name}。`), })); return; } @@ -114,8 +126,10 @@ function createMockServer() { res.end(JSON.stringify({ error: 'unauthorized' })); return; } + const session = tokens.get(auth); + const state = playerStates.get(session.id) || { x: 5, y: 5, zone: 'Town Center', zoneDesc: 'Central square' }; res.end(JSON.stringify({ - player: { x: 5, y: 5, zone: 'Town Center', zoneDesc: 'Central square' }, + player: { x: state.x, y: state.y, zone: state.zone, zoneDesc: state.zoneDesc }, nearby: [], })); return; @@ -127,8 +141,18 @@ function createMockServer() { res.end(JSON.stringify({ error: 'unauthorized' })); return; } + const session = tokens.get(auth); + const current = playerStates.get(session.id) || { x: 5, y: 5, direction: 'S', zone: 'Town Center', zoneDesc: 'Central square' }; + const next = { + x: current.x + payload.steps, + y: current.y, + direction: payload.direction, + zone: 'Town Center', + zoneDesc: 'Central square', + }; + playerStates.set(session.id, next); res.end(JSON.stringify({ - player: { x: 8, y: 5, zone: 'Town Center', zoneDesc: 'Central square' }, + player: { x: next.x, y: next.y, zone: next.zone, zoneDesc: next.zoneDesc }, actualSteps: payload.steps, blocked: false, })); diff --git a/packages/town-cli/src/lib/act.js b/packages/town-cli/src/lib/act.js index fc34195a..71744c94 100644 --- a/packages/town-cli/src/lib/act.js +++ b/packages/town-cli/src/lib/act.js @@ -16,7 +16,7 @@ async function walk(args) { const { auth, result } = await runAuthenticated('POST', '/api/walk', { direction, steps }); if (!result) throwForAuth(auth); - console.log(formatWalk(direction, steps)); + console.log(formatWalk(direction, steps, result)); } async function say(args) { diff --git a/packages/town-cli/src/lib/auth.js b/packages/town-cli/src/lib/auth.js index 656f3b65..69734b87 100644 --- a/packages/town-cli/src/lib/auth.js +++ b/packages/town-cli/src/lib/auth.js @@ -7,6 +7,8 @@ async function loginCommand(args) { create: Boolean(flags.create), name: flags.name, sprite: flags.sprite, + loginMode: flags['login-mode'], + respawn: Boolean(flags.respawn), }); console.log(formatLogin(result)); } diff --git a/packages/town-cli/src/town.js b/packages/town-cli/src/town.js index 1691d48e..5a06eb4b 100644 --- a/packages/town-cli/src/town.js +++ b/packages/town-cli/src/town.js @@ -43,7 +43,7 @@ async function main() { 用法: node town [args...] 身份: - login [--profile ] [--create --name --sprite ] + login [--profile ] [--create --name --sprite ] [--login-mode ] [--respawn] list-profile 查询: diff --git a/packages/town-cli/test/smoke.test.js b/packages/town-cli/test/smoke.test.js index 990913f9..08121b1b 100644 --- a/packages/town-cli/test/smoke.test.js +++ b/packages/town-cli/test/smoke.test.js @@ -20,6 +20,7 @@ function createMockServer() { const profiles = new Map(); const tokens = new Map(); const activeTokens = new Map(); + const playerStates = new Map(); let sequence = 1000n; return new Promise((resolve) => { @@ -70,6 +71,13 @@ function createMockServer() { const previousToken = activeTokens.get(profile.id); if (previousToken) tokens.delete(previousToken); + const loginMode = payload.loginMode === 'spawn' || payload.respawn ? 'spawn' : 'resume'; + const priorState = playerStates.get(profile.id) || { x: 5, y: 5, direction: 'S', zone: 'Town Center', zoneDesc: 'Central square' }; + const currentState = loginMode === 'spawn' + ? { x: 5, y: 5, direction: 'S', zone: 'Town Center', zoneDesc: 'Central square' } + : priorState; + playerStates.set(profile.id, currentState); + const token = `token-${profile.id}-${Date.now()}`; tokens.set(token, { id: profile.id, name: profile.name, sprite: profile.sprite }); activeTokens.set(profile.id, token); @@ -81,7 +89,11 @@ function createMockServer() { token, expires_at: new Date(Date.now() + 3600000).toISOString(), lease_expires_at: new Date(Date.now() + 180000).toISOString(), - message: previousToken ? `已接管角色 ${profile.name} 的在线会话。` : `已登录角色 ${profile.name}。`, + login_mode: loginMode, + player: { id: profile.id, name: profile.name, sprite: profile.sprite, ...currentState, isThinking: false, message: null, presenceState: 'active' }, + message: loginMode === 'spawn' + ? `已重新进入小镇,${profile.name} 已回到起点。` + : (previousToken ? `已接管角色 ${profile.name} 的在线会话。` : `已登录角色 ${profile.name}。`), })); return; } @@ -117,8 +129,9 @@ function createMockServer() { return; } const session = tokens.get(auth); + const state = playerStates.get(session.id) || { x: 5, y: 5, zone: 'Town Center', zoneDesc: 'Central square' }; res.end(JSON.stringify({ - player: { x: 5, y: 5, zone: 'Town Center', zoneDesc: 'Central square', sprite: session.sprite, name: session.name }, + player: { x: state.x, y: state.y, zone: state.zone, zoneDesc: state.zoneDesc, sprite: session.sprite, name: session.name }, nearby: [{ name: 'Alice', distance: 2, relativeDirection: '左侧', zone: 'Town Center', message: 'hello' }], })); return; @@ -130,8 +143,18 @@ function createMockServer() { res.end(JSON.stringify({ error: 'unauthorized' })); return; } + const session = tokens.get(auth); + const current = playerStates.get(session.id) || { x: 5, y: 5, direction: 'S', zone: 'Town Center', zoneDesc: 'Central square' }; + const next = { + x: current.x + payload.steps, + y: current.y, + direction: payload.direction, + zone: 'Town Center', + zoneDesc: 'Central square', + }; + playerStates.set(session.id, next); res.end(JSON.stringify({ - player: { x: 7, y: 5, zone: 'Town Center', zoneDesc: 'Central square' }, + player: { x: next.x, y: next.y, zone: next.zone, zoneDesc: next.zoneDesc }, actualSteps: payload.steps, blocked: false, })); @@ -235,7 +258,8 @@ describe('Town CLI (smoke)', () => { assert.match(look.stdout, /左侧/); const walk = await runCli(['walk', '--direction', 'E', '--steps', '2'], env); - assert.match(walk.stdout, /你试图向 E 走 2 步/); + assert.match(walk.stdout, /实际移动 2 步/); + assert.match(walk.stdout, /\(7, 5\)/); const say = await runCli(['say', '--text', '你好'], env); assert.match(say.stdout, /你说: 你好/); @@ -247,6 +271,7 @@ describe('Town CLI (smoke)', () => { const reloginResult = JSON.parse(relogin.stdout); assert.equal(reloginResult.profile, loginResult.profile); assert.equal(reloginResult.handle, loginResult.handle); + assert.equal(reloginResult.login_mode, 'resume'); }); it('supports multiple local profiles and explicit profile switching', async () => { diff --git a/server/src/engine/world-engine.js b/server/src/engine/world-engine.js index db22d7d8..aa71ab4d 100644 --- a/server/src/engine/world-engine.js +++ b/server/src/engine/world-engine.js @@ -199,6 +199,36 @@ function createProfile(name, sprite, publicKey) { return { handle: createdProfile.handle, name: createdProfile.name, sprite: createdProfile.sprite }; } +function getRespawnState(profile) { + const spawnX = Number.isInteger(profile?.lastX) ? profile.lastX : 5; + const spawnY = Number.isInteger(profile?.lastY) ? profile.lastY : 5; + const isBlocked = !worldMap + || spawnX < 0 + || spawnY < 0 + || spawnX >= worldMap.width + || spawnY >= worldMap.height + || collisionMap[spawnY * worldMap.width + spawnX] === 1; + + if (isBlocked) { + return { x: 5, y: 5, direction: 'S' }; + } + + return { + x: spawnX, + y: spawnY, + direction: ['N', 'S', 'W', 'E'].includes(profile?.lastDirection) ? profile.lastDirection : 'S', + }; +} + +function persistPlayerState(player) { + if (!player) return; + sqliteStateStore.updateProfilePosition(player.id, { + x: player.x, + y: player.y, + direction: player.lastDirection, + }); +} + function getProfile(id) { return sqliteStateStore.getProfile(id); } @@ -236,7 +266,13 @@ function destroyToken(token) { removePlayer(existing.id); } -function loginProfile(handle, timestamp, signature) { +function resolveLoginMode(options = {}) { + if (options.respawn === true) return 'spawn'; + if (options.loginMode === 'spawn') return 'spawn'; + return 'resume'; +} + +function loginProfile(handle, timestamp, signature, options = {}) { const profile = getProfileByHandle(handle) || getProfile(handle); if (!profile) return { error: '未找到对应 profile,请先使用 login 的创建模式创建角色。', code: 404 }; if (!verifyLoginProof(profile, timestamp, signature)) { @@ -249,7 +285,11 @@ 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 loginMode = resolveLoginMode(options); + const player = join(profile.id, profile.name, profile.sprite, { + trackActivity: true, + profile: loginMode === 'resume' ? profile : null, + }); const session = { id: profile.id, playerId: profile.id, @@ -273,8 +313,11 @@ function loginProfile(handle, timestamp, signature) { token, expires_at: new Date(session.expiresAt).toISOString(), lease_expires_at: new Date(session.leaseExpiresAt).toISOString(), + login_mode: loginMode, player: sanitize(player), - message: hadActiveSession ? `已接管角色 ${profile.name} 的在线会话。` : `已登录角色 ${profile.name}。`, + message: loginMode === 'spawn' + ? `已重新进入小镇,${profile.name} 已回到起点。` + : (hadActiveSession ? `已接管角色 ${profile.name} 的在线会话。` : `已登录角色 ${profile.name}。`), }; } @@ -337,8 +380,9 @@ function join(playerId, name, sprite, options = {}) { nextSpriteIndex += 1; } - const spawnX = 5; - const spawnY = 5; + const spawn = getRespawnState(options.profile); + const spawnX = spawn.x; + const spawnY = spawn.y; const zone = getZoneAt(spawnX, spawnY); const now = Date.now(); players[playerId] = { @@ -346,7 +390,7 @@ function join(playerId, name, sprite, options = {}) { name, x: spawnX, y: spawnY, - lastDirection: 'S', + lastDirection: spawn.direction, message: '', interactionText: '', interactionIcon: '', @@ -358,6 +402,7 @@ function join(playerId, name, sprite, options = {}) { lastHeartbeatAt: now, lastActionAt: options.trackActivity === false ? null : now, }; + persistPlayerState(players[playerId]); addActivity(playerId, { type: 'join', text: `加入了小镇 (角色: ${assignedSprite})` }); broadcast(); return players[playerId]; @@ -410,6 +455,7 @@ function move(playerId, direction, steps) { actual += 1; } zoneInfo(player); + persistPlayerState(player); addActivity(playerId, { type: 'move', text: `移动到 (${player.x}, ${player.y}) - ${player.currentZoneName}` }); broadcast(); return { player: sanitize(player), actualSteps: actual, blocked }; diff --git a/server/src/persistence/sqlite-state-store.js b/server/src/persistence/sqlite-state-store.js index 9af81e7c..5b8206d0 100644 --- a/server/src/persistence/sqlite-state-store.js +++ b/server/src/persistence/sqlite-state-store.js @@ -51,6 +51,15 @@ class SQLiteStateStore { if (!columns.has('public_key')) { this.database.exec(`ALTER TABLE profiles ADD COLUMN public_key TEXT;`); } + if (!columns.has('last_x')) { + this.database.exec(`ALTER TABLE profiles ADD COLUMN last_x INTEGER;`); + } + if (!columns.has('last_y')) { + this.database.exec(`ALTER TABLE profiles ADD COLUMN last_y INTEGER;`); + } + if (!columns.has('last_direction')) { + this.database.exec(`ALTER TABLE profiles ADD COLUMN last_direction TEXT;`); + } this.database.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_profiles_handle ON profiles(handle); @@ -87,7 +96,8 @@ class SQLiteStateStore { getProfile(id) { const row = this.database.prepare(` - SELECT id, handle, name, sprite, public_key AS publicKey, created_at AS createdAt, last_used_at AS lastUsedAt + SELECT id, handle, name, sprite, public_key AS publicKey, created_at AS createdAt, last_used_at AS lastUsedAt, + last_x AS lastX, last_y AS lastY, last_direction AS lastDirection FROM profiles WHERE id = ? `).get(id); @@ -96,7 +106,8 @@ class SQLiteStateStore { getProfileByHandle(handle) { const row = this.database.prepare(` - SELECT id, handle, name, sprite, public_key AS publicKey, created_at AS createdAt, last_used_at AS lastUsedAt + SELECT id, handle, name, sprite, public_key AS publicKey, created_at AS createdAt, last_used_at AS lastUsedAt, + last_x AS lastX, last_y AS lastY, last_direction AS lastDirection FROM profiles WHERE handle = ? `).get(handle); @@ -105,7 +116,8 @@ class SQLiteStateStore { getProfileByPublicKey(publicKey) { const row = this.database.prepare(` - SELECT id, handle, name, sprite, public_key AS publicKey, created_at AS createdAt, last_used_at AS lastUsedAt + SELECT id, handle, name, sprite, public_key AS publicKey, created_at AS createdAt, last_used_at AS lastUsedAt, + last_x AS lastX, last_y AS lastY, last_direction AS lastDirection FROM profiles WHERE public_key = ? `).get(publicKey); @@ -128,6 +140,14 @@ class SQLiteStateStore { `).run(sprite, id); } + updateProfilePosition(id, { x, y, direction }) { + this.database.prepare(` + UPDATE profiles + SET last_x = ?, last_y = ?, last_direction = ? + WHERE id = ? + `).run(x, y, direction || null, id); + } + saveAuthSession(session) { this.database.prepare(` INSERT OR REPLACE INTO auth_sessions (token, profile_id, player_id, issued_at, expires_at, lease_expires_at) diff --git a/server/src/routes.js b/server/src/routes.js index 0de98979..5b944ce1 100644 --- a/server/src/routes.js +++ b/server/src/routes.js @@ -63,11 +63,14 @@ router.post('/profiles/create', (req, res) => { }); router.post('/login', (req, res) => { - const { handle, timestamp, signature } = req.body || {}; + const { handle, timestamp, signature, loginMode, respawn } = req.body || {}; if (!handle) return res.status(400).json({ error: '缺少 handle 字段' }); if (!timestamp) return res.status(400).json({ error: '缺少 timestamp 字段' }); if (!signature) return res.status(400).json({ error: '缺少 signature 字段' }); - const result = worldEngine.loginProfile(handle, timestamp, signature); + if (loginMode && !['resume', 'spawn'].includes(loginMode)) { + return res.status(400).json({ error: 'loginMode 仅支持 resume 或 spawn' }); + } + const result = worldEngine.loginProfile(handle, timestamp, signature, { loginMode, respawn }); if (result.error) return res.status(result.code || 400).json({ error: result.error }); res.json(result); }); diff --git a/server/test/smoke.test.js b/server/test/smoke.test.js index 6353c16d..74952eeb 100644 --- a/server/test/smoke.test.js +++ b/server/test/smoke.test.js @@ -208,6 +208,75 @@ describe('HTTP API (integration)', () => { assert.equal(staleLook.status, 401); }); + it('restores the last known position after re-login', async () => { + const authMaterial = generateAuthMaterial(); + const created = await request('POST', '/api/profiles/create', { + name: 'ReturnBot', + sprite: 'Samurai', + publicKey: authMaterial.publicJwk.x, + }); + + const firstTimestamp = Date.now(); + const firstLogin = await request('POST', '/api/login', { + handle: created.body.handle, + timestamp: firstTimestamp, + signature: signLogin(created.body.handle, authMaterial.privateJwk, firstTimestamp), + }); + const firstHeaders = { Authorization: `Bearer ${firstLogin.body.token}` }; + + const walk = await request('POST', '/api/walk', { direction: 'E', steps: 3 }, firstHeaders); + assert.equal(walk.status, 200); + assert.equal(walk.body.player.x, 8); + assert.equal(walk.body.player.y, 5); + + const secondTimestamp = Date.now() + 1; + const secondLogin = await request('POST', '/api/login', { + handle: created.body.handle, + timestamp: secondTimestamp, + signature: signLogin(created.body.handle, authMaterial.privateJwk, secondTimestamp), + }); + + assert.equal(secondLogin.status, 200); + assert.equal(secondLogin.body.player.x, 8); + assert.equal(secondLogin.body.player.y, 5); + assert.equal(secondLogin.body.login_mode, 'resume'); + }); + + it('supports explicit respawn back to the starting point', async () => { + const authMaterial = generateAuthMaterial(); + const created = await request('POST', '/api/profiles/create', { + name: 'RespawnBot', + sprite: 'Samurai', + publicKey: authMaterial.publicJwk.x, + }); + + const firstTimestamp = Date.now(); + const firstLogin = await request('POST', '/api/login', { + handle: created.body.handle, + timestamp: firstTimestamp, + signature: signLogin(created.body.handle, authMaterial.privateJwk, firstTimestamp), + }); + const firstHeaders = { Authorization: `Bearer ${firstLogin.body.token}` }; + + const walk = await request('POST', '/api/walk', { direction: 'E', steps: 3 }, firstHeaders); + assert.equal(walk.status, 200); + assert.equal(walk.body.player.x, 8); + assert.equal(walk.body.player.y, 5); + + const secondTimestamp = Date.now() + 1; + const secondLogin = await request('POST', '/api/login', { + handle: created.body.handle, + timestamp: secondTimestamp, + signature: signLogin(created.body.handle, authMaterial.privateJwk, secondTimestamp), + loginMode: 'spawn', + }); + + assert.equal(secondLogin.status, 200); + assert.equal(secondLogin.body.login_mode, 'spawn'); + assert.equal(secondLogin.body.player.x, 5); + assert.equal(secondLogin.body.player.y, 5); + }); + it('marks online players idle and then offline based on timers', async () => { const authMaterial = generateAuthMaterial(); const created = await request('POST', '/api/profiles/create', { diff --git a/shared/town-client/auth-client.js b/shared/town-client/auth-client.js index 9a81f258..fb7de412 100644 --- a/shared/town-client/auth-client.js +++ b/shared/town-client/auth-client.js @@ -141,10 +141,76 @@ async function loginWithProfile(profile) { }; } +function normalizeLoginMode(options = {}) { + if (options.respawn === true) return 'spawn'; + if (options.loginMode === 'spawn') return 'spawn'; + return 'resume'; +} + +async function loginWithProfileMode(profile, options = {}) { + const keystore = loadKeystore(profile.handle); + if (!keystore || !keystore.jwk) { + return { + status: 'reauth_required', + profile: profile.profile, + handle: profile.handle, + name: profile.name, + sprite: profile.sprite, + server: profile.server || null, + lease_expires_at: null, + message: '本地 profile 缺少可用认证材料,请重新创建角色。', + }; + } + + const server = await discoverServer(profile.server); + const timestamp = Date.now(); + const signature = signLoginProof(profile.handle, timestamp, keystore.jwk); + const loginMode = normalizeLoginMode(options); + const response = await requestJson(server, 'POST', '/api/login', { + body: { + handle: profile.handle, + timestamp, + signature, + deviceId: keystore.deviceId, + loginMode, + respawn: loginMode === 'spawn', + }, + }); + + const nextProfile = { + ...profile, + name: response.name || profile.name, + sprite: response.sprite || profile.sprite, + handle: response.handle || profile.handle, + server, + token: response.token, + expiresAt: response.expires_at || new Date(Date.now() + TOKEN_TTL_MS).toISOString(), + leaseExpiresAt: response.lease_expires_at || null, + lastUsedAt: new Date().toISOString(), + }; + + saveProfile(nextProfile); + setDefaultProfileName(nextProfile.profile); + + return { + status: response.status, + profile: nextProfile.profile, + handle: nextProfile.handle, + name: nextProfile.name, + sprite: nextProfile.sprite, + server: nextProfile.server, + lease_expires_at: nextProfile.leaseExpiresAt, + message: response.message, + token: nextProfile.token, + login_mode: response.login_mode || loginMode, + }; +} + module.exports = { createProfile, - loginWithProfile, + loginWithProfile: loginWithProfileMode, generateKeyMaterial, signLoginProof, normalizeProfileName, + normalizeLoginMode, }; diff --git a/shared/town-client/formatters.js b/shared/town-client/formatters.js index ea8d7e8d..c40de57b 100644 --- a/shared/town-client/formatters.js +++ b/shared/town-client/formatters.js @@ -61,8 +61,14 @@ function formatLook(result) { return info.trimEnd(); } -function formatWalk(direction, steps) { - return `你试图向 ${direction} 走 ${steps} 步。请用 look 确认是否到达,或是否撞墙。`; +function formatWalk(direction, steps, result = null) { + if (!result || !result.player) { + return `你试图向 ${direction} 走 ${steps} 步。请用 look 确认是否到达,或是否撞墙。`; + } + + const actual = Number.isFinite(result.actualSteps) ? result.actualSteps : 0; + const blockedText = result.blocked ? ',前方受阻' : ''; + return `你向 ${direction} 走了 ${steps} 步,实际移动 ${actual} 步${blockedText}。当前位置: (${result.player.x}, ${result.player.y}),地点: ${result.player.zone}。`; } function formatSay(text) { diff --git a/shared/town-client/session-handle.js b/shared/town-client/session-handle.js index c41a9942..6177f6e2 100644 --- a/shared/town-client/session-handle.js +++ b/shared/town-client/session-handle.js @@ -72,7 +72,7 @@ class SessionHandle { sprite: options.sprite, server: options.server, }); - const createdLogin = await loginWithProfile(createdProfile); + const createdLogin = await loginWithProfile(createdProfile, options); this.profileName = createdProfile.profile; return { ...createdLogin, @@ -97,7 +97,7 @@ class SessionHandle { const profile = this.getProfileOrThrow(resolvedProfile); this.profileName = resolvedProfile; - return loginWithProfile(profile); + return loginWithProfile(profile, options); } async heartbeat() {