576 lines
22 KiB
JavaScript
576 lines
22 KiB
JavaScript
const { createClient } = require('redis');
|
|
const { FarmBot } = require('./src/core/FarmBot');
|
|
const { loadProto, types } = require('./src/proto');
|
|
const { log, toLong, toNum } = require('./src/utils');
|
|
const { getLevelExpProgress, getPlantNameBySeedId } = require('./src/gameConfig');
|
|
|
|
const redisUrl = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
|
|
const redisPublisher = createClient({ url: redisUrl });
|
|
const redisSubscriber = createClient({ url: redisUrl });
|
|
|
|
const bots = new Map();
|
|
const locks = new Map();
|
|
|
|
function safeParseJson(input) {
|
|
try {
|
|
return JSON.parse(input);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function publish(channel, payload) {
|
|
if (!redisPublisher.isOpen) return;
|
|
const body = JSON.stringify(payload || {});
|
|
redisPublisher.publish(channel, body).catch(() => {});
|
|
}
|
|
|
|
function getTodayKey() {
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = `${now.getMonth() + 1}`.padStart(2, '0');
|
|
const d = `${now.getDate()}`.padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
async function withEmailLock(email, task) {
|
|
if (!email) return task();
|
|
const prev = locks.get(email) || Promise.resolve();
|
|
let release;
|
|
const next = new Promise((resolve) => { release = resolve; });
|
|
locks.set(email, prev.then(() => next));
|
|
await prev;
|
|
try {
|
|
return await task();
|
|
} finally {
|
|
release();
|
|
if (locks.get(email) === next) locks.delete(email);
|
|
}
|
|
}
|
|
|
|
async function fetchTaskInfo(bot) {
|
|
const body = types.TaskInfoRequest.encode(types.TaskInfoRequest.create({})).finish();
|
|
const { body: replyBody } = await bot.network.sendMsgAsync('gamepb.taskpb.TaskService', 'TaskInfo', body);
|
|
const reply = types.TaskInfoReply.decode(replyBody);
|
|
return reply.task_info || null;
|
|
}
|
|
|
|
function buildTaskList(taskInfo) {
|
|
const tasks = [];
|
|
if (!taskInfo) return tasks;
|
|
const seen = new Set();
|
|
const seenContent = new Set();
|
|
const addTasks = (list, category) => {
|
|
if (!list) return;
|
|
for (const task of list) {
|
|
const id = toNum(task.id);
|
|
if (seen.has(id)) continue;
|
|
const progress = toNum(task.progress);
|
|
const totalProgress = toNum(task.total_progress);
|
|
const isClaimed = Boolean(task.is_claimed);
|
|
const isUnlocked = Boolean(task.is_unlocked);
|
|
const shareMultiple = toNum(task.share_multiple);
|
|
const rewards = [];
|
|
const rewardList = task.rewards || [];
|
|
for (const reward of rewardList) {
|
|
rewards.push({
|
|
id: toNum(reward.id),
|
|
count: toNum(reward.count)
|
|
});
|
|
}
|
|
const desc = task.desc || `任务#${id}`;
|
|
const contentKey = `${desc.trim()}|${progress}|${totalProgress}`;
|
|
if (seenContent.has(contentKey)) continue;
|
|
tasks.push({
|
|
id,
|
|
desc,
|
|
progress,
|
|
totalProgress,
|
|
isClaimed,
|
|
isUnlocked,
|
|
shareMultiple,
|
|
rewards,
|
|
category
|
|
});
|
|
seen.add(id);
|
|
seenContent.add(contentKey);
|
|
}
|
|
};
|
|
|
|
addTasks(taskInfo.growth_tasks, 'growth');
|
|
addTasks(taskInfo.daily_tasks, 'daily');
|
|
addTasks(taskInfo.tasks, 'normal');
|
|
return tasks;
|
|
}
|
|
|
|
function isTaskClaimable(task) {
|
|
if (!task.isUnlocked || task.isClaimed) return false;
|
|
if (task.totalProgress <= 0) return false;
|
|
return task.progress >= task.totalProgress;
|
|
}
|
|
|
|
async function claimTaskReward(bot, taskId, doShared) {
|
|
const body = types.ClaimTaskRewardRequest.encode(types.ClaimTaskRewardRequest.create({
|
|
id: toLong(taskId),
|
|
do_shared: Boolean(doShared)
|
|
})).finish();
|
|
const { body: replyBody } = await bot.network.sendMsgAsync('gamepb.taskpb.TaskService', 'ClaimTaskReward', body);
|
|
return types.ClaimTaskRewardReply.decode(replyBody);
|
|
}
|
|
|
|
function attachBotEvents(email, bot) {
|
|
bot.on('started', () => {
|
|
publish(`bot-status-${email}`, { status: 'running' });
|
|
});
|
|
bot.on('stopped', () => {
|
|
publish(`bot-status-${email}`, { status: 'stopped' });
|
|
bots.delete(email);
|
|
});
|
|
bot.on('error', (err) => {
|
|
publish(`bot-error-${email}`, { message: err.message });
|
|
});
|
|
bot.on('log', (logData) => {
|
|
publish(`bot-log-${email}`, logData);
|
|
});
|
|
bot.on('stealRecord', (data) => {
|
|
publish(`bot-steal-${email}`, data || {});
|
|
});
|
|
}
|
|
|
|
async function buildStatus(bot) {
|
|
const user = bot.user || {};
|
|
const todayKey = getTodayKey();
|
|
const freeMallClaimed = user.freeMallClaimDate === todayKey;
|
|
const levelProgress = getLevelExpProgress(user.level || 0, user.exp || 0);
|
|
const tickets = user.tickets || 0;
|
|
const fertilizerContainer = Number(user.fertilizerContainer) || 0;
|
|
const fallbackHours = fertilizerContainer >= 3600 ? fertilizerContainer / 3600 : fertilizerContainer;
|
|
const organicFertilizerContainer = Number(user.organicFertilizerContainer) || 0;
|
|
const fallbackOrganicHours = organicFertilizerContainer >= 3600 ? organicFertilizerContainer / 3600 : organicFertilizerContainer;
|
|
let fertilizerHours = Number.isFinite(user.fertilizerHours) ? user.fertilizerHours : fallbackHours;
|
|
let organicFertilizerHours = Number.isFinite(user.organicFertilizerHours) ? user.organicFertilizerHours : fallbackOrganicHours;
|
|
if (bot.warehouseManager) {
|
|
try {
|
|
const bagReply = await bot.warehouseManager.getBag();
|
|
const items = bot.warehouseManager.getBagItems(bagReply);
|
|
let normalContainer = 0;
|
|
let organicContainer = 0;
|
|
for (const item of items) {
|
|
const id = toNum(item.id);
|
|
const count = toNum(item.count);
|
|
if (id === 1011) normalContainer = count;
|
|
if (id === 1012) organicContainer = count;
|
|
}
|
|
const normalContainerHours = normalContainer >= 3600 ? normalContainer / 3600 : normalContainer;
|
|
const organicContainerHours = organicContainer >= 3600 ? organicContainer / 3600 : organicContainer;
|
|
const normalHours = normalContainerHours;
|
|
const organicHours = organicContainerHours;
|
|
fertilizerHours = normalHours;
|
|
user.normalFertilizerHours = normalHours;
|
|
user.organicFertilizerHours = organicHours;
|
|
organicFertilizerHours = organicHours;
|
|
} catch (error) {
|
|
log('状态', `获取背包失败: ${error.message}`);
|
|
}
|
|
}
|
|
return {
|
|
status: 'running',
|
|
user: {
|
|
nickname: user.name || 'Farmer',
|
|
level: user.level || 0,
|
|
exp: levelProgress.current || 0,
|
|
nextLevelExp: levelProgress.needed || 100,
|
|
gold: user.gold || 0,
|
|
tickets,
|
|
fertilizerHours,
|
|
normalFertilizerHours: Number.isFinite(user.normalFertilizerHours) ? user.normalFertilizerHours : fertilizerHours,
|
|
organicFertilizerHours,
|
|
avatarUrl: user.avatarUrl || '',
|
|
freeMallClaimed
|
|
}
|
|
};
|
|
}
|
|
|
|
function rpcOk(id, data) {
|
|
return { id, ok: true, data };
|
|
}
|
|
|
|
function rpcErr(id, error, status = 500) {
|
|
return { id, ok: false, error, status };
|
|
}
|
|
|
|
async function handleRpcRequest(req) {
|
|
const id = req && req.id;
|
|
const action = req && req.action;
|
|
const email = req && req.email;
|
|
const payload = (req && req.payload) || {};
|
|
if (!id || !action) return null;
|
|
try {
|
|
if (action === 'worker.ping') return rpcOk(id, { ok: true });
|
|
if (action === 'worker.stats') {
|
|
const memory = process.memoryUsage();
|
|
return rpcOk(id, {
|
|
activeBots: bots.size,
|
|
memory: {
|
|
rss: Math.round(memory.rss / 1024 / 1024),
|
|
heapTotal: Math.round(memory.heapTotal / 1024 / 1024),
|
|
heapUsed: Math.round(memory.heapUsed / 1024 / 1024)
|
|
},
|
|
uptime: Math.floor(process.uptime())
|
|
});
|
|
}
|
|
|
|
if (action === 'worker.stopAll') {
|
|
let stopped = 0;
|
|
for (const bot of bots.values()) {
|
|
try {
|
|
bot.stop();
|
|
stopped += 1;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
bots.clear();
|
|
return rpcOk(id, { success: true, stopped });
|
|
}
|
|
|
|
if (action === 'worker.shutdown') {
|
|
for (const bot of bots.values()) {
|
|
try {
|
|
bot.stop();
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
bots.clear();
|
|
setTimeout(() => process.exit(0), 200);
|
|
return rpcOk(id, { success: true });
|
|
}
|
|
|
|
if (action === 'bot.isRunning') {
|
|
return rpcOk(id, { running: bots.has(email) });
|
|
}
|
|
|
|
if (action === 'bot.start') {
|
|
if (!email) return rpcErr(id, 'Email is required', 400);
|
|
const rawCode = typeof payload.code === 'string' ? payload.code.trim() : '';
|
|
payload.code = rawCode;
|
|
const code = rawCode;
|
|
if (!code) return rpcErr(id, 'Game code is required. Please provide it.', 400);
|
|
const response = await withEmailLock(email, async () => {
|
|
const existing = bots.get(email);
|
|
if (existing) {
|
|
existing.stop();
|
|
bots.delete(email);
|
|
}
|
|
const bot = new FarmBot(payload);
|
|
attachBotEvents(email, bot);
|
|
const started = await bot.start();
|
|
if (!started) return rpcErr(id, bot.lastStartError || '启动失败', 503);
|
|
bots.set(email, bot);
|
|
return rpcOk(id, { success: true });
|
|
});
|
|
return response;
|
|
}
|
|
|
|
if (action === 'bot.stop') {
|
|
if (!email) return rpcErr(id, 'Email is required', 400);
|
|
const response = await withEmailLock(email, async () => {
|
|
const bot = bots.get(email);
|
|
if (!bot) return rpcErr(id, 'Bot is not running', 404);
|
|
bot.stop();
|
|
bots.delete(email);
|
|
return rpcOk(id, { success: true });
|
|
});
|
|
return response;
|
|
}
|
|
|
|
if (action === 'bot.status') {
|
|
if (!email) return rpcErr(id, 'Email is required', 400);
|
|
const bot = bots.get(email);
|
|
if (!bot) return rpcOk(id, { status: 'stopped', user: null });
|
|
const data = await buildStatus(bot);
|
|
return rpcOk(id, data);
|
|
}
|
|
|
|
if (action === 'bot.updateConfig') {
|
|
if (!email) return rpcErr(id, 'Email is required', 400);
|
|
const response = await withEmailLock(email, async () => {
|
|
const bot = bots.get(email);
|
|
if (!bot) return rpcErr(id, 'Bot not running', 404);
|
|
const settings = payload && typeof payload === 'object'
|
|
? (payload.settings && typeof payload.settings === 'object' ? payload.settings : payload)
|
|
: {};
|
|
bot.applyConfig(settings);
|
|
return rpcOk(id, { success: true });
|
|
});
|
|
return response;
|
|
}
|
|
|
|
if (!email) return rpcErr(id, 'Email is required', 400);
|
|
const bot = bots.get(email);
|
|
if (!bot) return rpcErr(id, 'Bot not running', 404);
|
|
|
|
if (action === 'friends.list') {
|
|
if (bot.config && bot.config.enableFriendOps === false) {
|
|
return rpcErr(id, 'Friend operations disabled', 403);
|
|
}
|
|
const reply = await bot.friendManager.getAllFriends();
|
|
const friends = (reply.friends || []).map(f => ({
|
|
uin: f.uin ? f.uin.toString() : '',
|
|
userName: f.userName,
|
|
headPic: f.headPic,
|
|
yellowLevel: f.yellowLevel,
|
|
exp: f.exp,
|
|
money: f.money
|
|
}));
|
|
return rpcOk(id, { friends });
|
|
}
|
|
|
|
if (action === 'friends.visit') {
|
|
if (bot.config && bot.config.enableFriendOps === false) {
|
|
return rpcErr(id, 'Friend operations disabled', 403);
|
|
}
|
|
const friendUid = payload.friendUid;
|
|
const reply = await bot.friendManager.enterFriendFarm(friendUid);
|
|
const lands = reply && reply.farm && reply.farm.lands ? reply.farm.lands : [];
|
|
if (lands.length === 0) return rpcOk(id, { lands: [] });
|
|
const analysis = bot.friendManager.analyzeFriendLands(lands, bot.user.gid);
|
|
const formattedLands = lands.map(land => {
|
|
const landId = Number(land.id);
|
|
if (!land.unlocked) return { id: landId, type: 'locked' };
|
|
const plant = land.plant;
|
|
if (!plant || !plant.phases || plant.phases.length === 0) {
|
|
return { id: landId, type: 'empty' };
|
|
}
|
|
const currentPhase = bot.farmManager.getCurrentPhase(plant.phases, false);
|
|
const phaseVal = currentPhase ? currentPhase.phase : 0;
|
|
let status = 'growing';
|
|
if (phaseVal === 7) status = 'dead';
|
|
else if (phaseVal === 6) status = 'mature';
|
|
return {
|
|
id: landId,
|
|
type: 'planted',
|
|
status,
|
|
plantId: Number(plant.id),
|
|
plantName: getPlantNameBySeedId(Number(plant.id)) || `作物${plant.id}`,
|
|
phase: phaseVal,
|
|
needs: {
|
|
water: analysis.needWater.includes(landId),
|
|
weed: analysis.needWeed.includes(landId),
|
|
bug: analysis.needBug.includes(landId)
|
|
},
|
|
canSteal: analysis.stealable.includes(landId)
|
|
};
|
|
});
|
|
return rpcOk(id, { lands: formattedLands, farmUser: reply.farm.user });
|
|
}
|
|
|
|
if (action === 'friends.action') {
|
|
if (bot.config && bot.config.enableFriendOps === false) {
|
|
return rpcErr(id, 'Friend operations disabled', 403);
|
|
}
|
|
if (payload.actionType === 'steal' && bot.config && bot.config.enableSteal === false) {
|
|
return rpcErr(id, 'Steal disabled', 403);
|
|
}
|
|
let result;
|
|
let message = '';
|
|
if (payload.actionType === 'water') {
|
|
result = await bot.friendManager.helpWater(payload.friendUid, payload.landIds);
|
|
message = '浇水成功';
|
|
} else if (payload.actionType === 'weed') {
|
|
result = await bot.friendManager.helpWeed(payload.friendUid, payload.landIds);
|
|
message = '除草成功';
|
|
} else if (payload.actionType === 'insecticide') {
|
|
result = await bot.friendManager.helpInsecticide(payload.friendUid, payload.landIds);
|
|
message = '除虫成功';
|
|
} else if (payload.actionType === 'steal') {
|
|
result = await bot.friendManager.stealHarvest(payload.friendUid, payload.landIds);
|
|
message = '偷菜成功';
|
|
} else {
|
|
return rpcErr(id, 'Unknown action type', 400);
|
|
}
|
|
return rpcOk(id, { success: true, message, result });
|
|
}
|
|
|
|
if (action === 'warehouse.list') {
|
|
const data = await bot.warehouseManager.getFormattedBag();
|
|
return rpcOk(id, data);
|
|
}
|
|
|
|
if (action === 'warehouse.sell') {
|
|
const reply = await bot.warehouseManager.sellItems(payload.items);
|
|
const gold = bot.warehouseManager.extractGold(reply);
|
|
return rpcOk(id, { success: true, gold });
|
|
}
|
|
|
|
if (action === 'warehouse.use') {
|
|
const result = await bot.warehouseManager.useItems(payload.items);
|
|
return rpcOk(id, result);
|
|
}
|
|
|
|
if (action === 'tasks.list') {
|
|
const taskInfo = await fetchTaskInfo(bot);
|
|
const tasks = buildTaskList(taskInfo);
|
|
return rpcOk(id, { tasks });
|
|
}
|
|
|
|
if (action === 'tasks.claimAll') {
|
|
const taskInfo = await fetchTaskInfo(bot);
|
|
const tasks = buildTaskList(taskInfo);
|
|
const claimable = [];
|
|
for (const task of tasks) {
|
|
if (isTaskClaimable(task)) claimable.push(task);
|
|
}
|
|
let claimedCount = 0;
|
|
const failed = [];
|
|
for (const task of claimable) {
|
|
try {
|
|
await claimTaskReward(bot, task.id, task.shareMultiple > 1);
|
|
claimedCount += 1;
|
|
} catch (err) {
|
|
failed.push({ id: task.id, message: err.message });
|
|
}
|
|
}
|
|
const latestTaskInfo = await fetchTaskInfo(bot);
|
|
const latestTasks = buildTaskList(latestTaskInfo);
|
|
return rpcOk(id, { claimedCount, failed, tasks: latestTasks });
|
|
}
|
|
|
|
if (action === 'tasks.claimOne') {
|
|
const idNum = Number(payload.taskId);
|
|
if (!Number.isFinite(idNum) || idNum <= 0) return rpcErr(id, 'Invalid taskId', 400);
|
|
await claimTaskReward(bot, idNum, Boolean(payload.doShared));
|
|
const latestTaskInfo = await fetchTaskInfo(bot);
|
|
const latestTasks = buildTaskList(latestTaskInfo);
|
|
return rpcOk(id, { claimedCount: 1, failedCount: 0, tasks: latestTasks });
|
|
}
|
|
|
|
if (action === 'lands.list') {
|
|
const lands = await bot.farmManager.getFormattedLands();
|
|
return rpcOk(id, { lands });
|
|
}
|
|
|
|
if (action === 'lands.remove') {
|
|
const ids = Array.isArray(payload.landIds) ? payload.landIds.map(Number).filter(idNum => Number.isFinite(idNum) && idNum > 0) : [];
|
|
if (ids.length === 0) return rpcErr(id, 'Invalid landIds', 400);
|
|
await bot.farmManager.removePlant(ids);
|
|
return rpcOk(id, { success: true, removed: ids.length });
|
|
}
|
|
|
|
if (action === 'lands.unlock') {
|
|
const landId = Number(payload.landId);
|
|
if (!Number.isFinite(landId) || landId <= 0) return rpcErr(id, 'Invalid landId', 400);
|
|
const reply = await bot.farmManager.unlockLand(landId, Boolean(payload.doShared));
|
|
let land = reply && reply.land ? reply.land : null;
|
|
if (!land || !land.unlocked) {
|
|
const landsReply = await bot.farmManager.getAllLands();
|
|
const lands = landsReply && landsReply.lands ? landsReply.lands : [];
|
|
land = lands.find(item => Number(item.id) === landId) || null;
|
|
if (!land || !land.unlocked) {
|
|
return rpcErr(id, 'Land not unlocked', 409);
|
|
}
|
|
}
|
|
return rpcOk(id, { success: true, landId, land });
|
|
}
|
|
|
|
if (action === 'shop.list') {
|
|
const goods = await bot.shopManager.getSeedShopList();
|
|
return rpcOk(id, { goods });
|
|
}
|
|
|
|
if (action === 'shop.buy') {
|
|
await bot.shopManager.buyGoods(payload.goodsId, payload.count, payload.price);
|
|
return rpcOk(id, { success: true });
|
|
}
|
|
|
|
if (action === 'shop.paymallBuy') {
|
|
const itemId = Number(payload.itemId);
|
|
const buyCount = Number(payload.count ?? 1);
|
|
if (!Number.isFinite(itemId) || itemId <= 0) return rpcErr(id, 'Invalid itemId', 400);
|
|
if (!Number.isFinite(buyCount) || buyCount <= 0) return rpcErr(id, 'Invalid count', 400);
|
|
await bot.shopManager.purchaseMallItem(itemId, buyCount);
|
|
if (itemId === 1001) {
|
|
bot.user.freeMallClaimDate = getTodayKey();
|
|
bot.emit('userUpdate', bot.user);
|
|
}
|
|
return rpcOk(id, { success: true });
|
|
}
|
|
|
|
if (action === 'debug.fertilizer') {
|
|
const bagReply = await bot.warehouseManager.getBag();
|
|
const items = bot.warehouseManager.getBagItems(bagReply);
|
|
let normalContainer = 0;
|
|
let normal1 = 0;
|
|
let normal4 = 0;
|
|
let normal8 = 0;
|
|
let normal12 = 0;
|
|
let organicContainer = 0;
|
|
let organic1 = 0;
|
|
let organic4 = 0;
|
|
let organic8 = 0;
|
|
let organic12 = 0;
|
|
for (const item of items) {
|
|
const idNum = toNum(item.id);
|
|
const countNum = toNum(item.count);
|
|
if (idNum === 1011) normalContainer = countNum;
|
|
if (idNum === 80001) normal1 = countNum;
|
|
if (idNum === 80002) normal4 = countNum;
|
|
if (idNum === 80003) normal8 = countNum;
|
|
if (idNum === 80004) normal12 = countNum;
|
|
if (idNum === 1012) organicContainer = countNum;
|
|
if (idNum === 80011) organic1 = countNum;
|
|
if (idNum === 80012) organic4 = countNum;
|
|
if (idNum === 80013) organic8 = countNum;
|
|
if (idNum === 80014) organic12 = countNum;
|
|
}
|
|
const normalContainerHours = normalContainer >= 3600 ? normalContainer / 3600 : normalContainer;
|
|
const organicContainerHours = organicContainer >= 3600 ? organicContainer / 3600 : organicContainer;
|
|
const normalHours = normalContainerHours + normal1 + normal4 * 4 + normal8 * 8 + normal12 * 12;
|
|
const organicHours = organicContainerHours + organic1 + organic4 * 4 + organic8 * 8 + organic12 * 12;
|
|
return rpcOk(id, {
|
|
normal: {
|
|
container: normalContainer,
|
|
hour1: normal1,
|
|
hour4: normal4,
|
|
hour8: normal8,
|
|
hour12: normal12,
|
|
hours: normalHours
|
|
},
|
|
organic: {
|
|
container: organicContainer,
|
|
hour1: organic1,
|
|
hour4: organic4,
|
|
hour8: organic8,
|
|
hour12: organic12,
|
|
hours: organicHours
|
|
}
|
|
});
|
|
}
|
|
|
|
return rpcErr(id, 'Unknown action', 400);
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : 'Worker error';
|
|
return rpcErr(id, message, 500);
|
|
}
|
|
}
|
|
|
|
async function startWorker() {
|
|
await loadProto();
|
|
await redisPublisher.connect();
|
|
await redisSubscriber.connect();
|
|
await redisSubscriber.subscribe('bot:rpc:req', async (message) => {
|
|
const req = safeParseJson(message);
|
|
const response = await handleRpcRequest(req);
|
|
if (!response) return;
|
|
const body = JSON.stringify(response);
|
|
redisPublisher.publish('bot:rpc:res', body).catch(() => {});
|
|
});
|
|
log('Worker', 'Worker ready');
|
|
}
|
|
|
|
startWorker().catch((err) => {
|
|
log('Worker', `Worker failed: ${err.message}`);
|
|
process.exit(1);
|
|
});
|