Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 68 additions & 43 deletions packages/mcp-bridge/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

Expand All @@ -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() {
Expand All @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp-bridge/src/tools/character.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-bridge/src/tools/movement.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
79 changes: 79 additions & 0 deletions packages/mcp-bridge/test/client-serialization.test.js
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
30 changes: 27 additions & 3 deletions packages/mcp-bridge/test/smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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,
}));
Expand Down
2 changes: 1 addition & 1 deletion packages/town-cli/src/lib/act.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/town-cli/src/lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
2 changes: 1 addition & 1 deletion packages/town-cli/src/town.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function main() {
用法: node town <command> [args...]

身份:
login [--profile <PROFILE>] [--create --name <NAME> --sprite <SPRITE>]
login [--profile <PROFILE>] [--create --name <NAME> --sprite <SPRITE>] [--login-mode <resume|spawn>] [--respawn]
list-profile

查询:
Expand Down
Loading