feat: initial commit for TheFarmer project
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Production
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
_upstream/
|
||||
*.zip
|
||||
*.rar
|
||||
*.7z
|
||||
*.bak
|
||||
|
||||
# Trae/IDE
|
||||
.trae/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
139
211/server/client.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* QQ经典农场 挂机脚本 - 入口文件
|
||||
*
|
||||
* 模块结构:
|
||||
* src/config.js - 配置常量与枚举
|
||||
* src/utils.js - 通用工具函数
|
||||
* src/proto.js - Protobuf 加载与类型管理
|
||||
* src/core/ - 核心逻辑 (FarmBot, Network, FarmManager, FriendManager)
|
||||
* src/decode.js - PB解码/验证工具模式
|
||||
*/
|
||||
|
||||
const { CONFIG } = require('./src/config');
|
||||
const { loadProto } = require('./src/proto');
|
||||
const { FarmBot } = require('./src/core/FarmBot');
|
||||
const { verifyMode, decodeMode } = require('./src/decode');
|
||||
const { emitRuntimeHint, sleep } = require('./src/utils');
|
||||
|
||||
// ============ 帮助信息 ============
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
QQ经典农场 挂机脚本 (重构版)
|
||||
====================
|
||||
|
||||
用法:
|
||||
node client.js --code <登录code> [--wx] [--interval <秒>] [--friend-interval <秒>]
|
||||
node client.js --verify
|
||||
node client.js --decode <数据> [--hex] [--gate] [--type <消息类型>]
|
||||
|
||||
参数:
|
||||
--code 小程序 login() 返回的临时凭证 (必需)
|
||||
--wx 使用微信登录 (默认为QQ小程序)
|
||||
--interval 自己农场巡查完成后等待秒数, 默认10秒, 最低10秒
|
||||
--friend-interval 好友巡查完成后等待秒数, 默认1秒, 最低1秒
|
||||
--verify 验证proto定义
|
||||
--decode 解码PB数据 (运行 --decode 无参数查看详细帮助)
|
||||
|
||||
功能:
|
||||
- 自动收获成熟作物 → 购买种子 → 种植 → 施肥
|
||||
- 自动除草、除虫、浇水
|
||||
- 自动铲除枯死作物
|
||||
- 自动巡查好友农场: 帮忙浇水/除草/除虫 + 偷菜
|
||||
- 自动领取任务奖励 (支持分享翻倍)
|
||||
- 每分钟自动出售仓库果实
|
||||
- 启动时读取 share.txt 处理邀请码 (仅微信)
|
||||
- 心跳保活
|
||||
`);
|
||||
}
|
||||
|
||||
// ============ 参数解析 ============
|
||||
function parseArgs(args) {
|
||||
const options = {
|
||||
code: '',
|
||||
deleteAccountMode: false,
|
||||
name: '',
|
||||
certId: '',
|
||||
certType: 0,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--code' && args[i + 1]) {
|
||||
options.code = args[++i];
|
||||
}
|
||||
if (args[i] === '--wx') {
|
||||
CONFIG.platform = 'wx';
|
||||
}
|
||||
if (args[i] === '--interval' && args[i + 1]) {
|
||||
const sec = parseInt(args[++i]);
|
||||
CONFIG.farmCheckInterval = Math.max(sec, 1) * 1000;
|
||||
}
|
||||
if (args[i] === '--friend-interval' && args[i + 1]) {
|
||||
const sec = parseInt(args[++i]);
|
||||
CONFIG.friendCheckInterval = Math.max(sec, 1) * 1000; // 最低1秒
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
// ============ 主函数 ============
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// 加载 proto 定义
|
||||
await loadProto();
|
||||
|
||||
// 验证模式
|
||||
if (args.includes('--verify')) {
|
||||
await verifyMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// 解码模式
|
||||
if (args.includes('--decode')) {
|
||||
await decodeMode(args);
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常运行模式
|
||||
const options = parseArgs(args);
|
||||
|
||||
if (!options.code) {
|
||||
showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新 CONFIG 中的 code (注意: FarmBot 构造函数会合并 CONFIG 和传入的 config)
|
||||
// 这里我们可以直接修改全局 CONFIG,或者传入 options
|
||||
// 为了兼容旧代码习惯,我们修改全局 CONFIG
|
||||
// 但 FarmBot 建议传入 config
|
||||
const botConfig = {
|
||||
code: options.code,
|
||||
// 其他参数已通过 modify global CONFIG 生效,或者也可以显式传入
|
||||
};
|
||||
|
||||
console.log('正在启动 FarmBot...');
|
||||
const bot = new FarmBot(botConfig);
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n正在停止...');
|
||||
bot.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
try {
|
||||
await bot.start();
|
||||
|
||||
// 保持进程运行 (如果 start() 返回后没有挂起的操作)
|
||||
// FarmBot 内部启动了 interval,所以进程应该会保持运行
|
||||
|
||||
} catch (error) {
|
||||
console.error('Bot 运行出错:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('未捕获的异常:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
BIN
211/server/data/users.db
Normal file
4061
211/server/gameConfig/Plant.json
Normal file
802
211/server/gameConfig/RoleLevel.json
Normal file
@@ -0,0 +1,802 @@
|
||||
[
|
||||
{
|
||||
"level": 1,
|
||||
"exp": 0
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"exp": 100
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"exp": 300
|
||||
},
|
||||
{
|
||||
"level": 4,
|
||||
"exp": 700
|
||||
},
|
||||
{
|
||||
"level": 5,
|
||||
"exp": 1300
|
||||
},
|
||||
{
|
||||
"level": 6,
|
||||
"exp": 2300
|
||||
},
|
||||
{
|
||||
"level": 7,
|
||||
"exp": 4000
|
||||
},
|
||||
{
|
||||
"level": 8,
|
||||
"exp": 6600
|
||||
},
|
||||
{
|
||||
"level": 9,
|
||||
"exp": 10100
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"exp": 14300
|
||||
},
|
||||
{
|
||||
"level": 11,
|
||||
"exp": 19300
|
||||
},
|
||||
{
|
||||
"level": 12,
|
||||
"exp": 25100
|
||||
},
|
||||
{
|
||||
"level": 13,
|
||||
"exp": 31800
|
||||
},
|
||||
{
|
||||
"level": 14,
|
||||
"exp": 39500
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"exp": 48300
|
||||
},
|
||||
{
|
||||
"level": 16,
|
||||
"exp": 58300
|
||||
},
|
||||
{
|
||||
"level": 17,
|
||||
"exp": 69500
|
||||
},
|
||||
{
|
||||
"level": 18,
|
||||
"exp": 82000
|
||||
},
|
||||
{
|
||||
"level": 19,
|
||||
"exp": 95900
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"exp": 111300
|
||||
},
|
||||
{
|
||||
"level": 21,
|
||||
"exp": 128300
|
||||
},
|
||||
{
|
||||
"level": 22,
|
||||
"exp": 146900
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"exp": 167200
|
||||
},
|
||||
{
|
||||
"level": 24,
|
||||
"exp": 189300
|
||||
},
|
||||
{
|
||||
"level": 25,
|
||||
"exp": 213300
|
||||
},
|
||||
{
|
||||
"level": 26,
|
||||
"exp": 239300
|
||||
},
|
||||
{
|
||||
"level": 27,
|
||||
"exp": 267300
|
||||
},
|
||||
{
|
||||
"level": 28,
|
||||
"exp": 297400
|
||||
},
|
||||
{
|
||||
"level": 29,
|
||||
"exp": 329700
|
||||
},
|
||||
{
|
||||
"level": 30,
|
||||
"exp": 364300
|
||||
},
|
||||
{
|
||||
"level": 31,
|
||||
"exp": 401300
|
||||
},
|
||||
{
|
||||
"level": 32,
|
||||
"exp": 440700
|
||||
},
|
||||
{
|
||||
"level": 33,
|
||||
"exp": 482600
|
||||
},
|
||||
{
|
||||
"level": 34,
|
||||
"exp": 527100
|
||||
},
|
||||
{
|
||||
"level": 35,
|
||||
"exp": 574300
|
||||
},
|
||||
{
|
||||
"level": 36,
|
||||
"exp": 624300
|
||||
},
|
||||
{
|
||||
"level": 37,
|
||||
"exp": 677100
|
||||
},
|
||||
{
|
||||
"level": 38,
|
||||
"exp": 732800
|
||||
},
|
||||
{
|
||||
"level": 39,
|
||||
"exp": 791500
|
||||
},
|
||||
{
|
||||
"level": 40,
|
||||
"exp": 853300
|
||||
},
|
||||
{
|
||||
"level": 41,
|
||||
"exp": 918300
|
||||
},
|
||||
{
|
||||
"level": 42,
|
||||
"exp": 986500
|
||||
},
|
||||
{
|
||||
"level": 43,
|
||||
"exp": 1058000
|
||||
},
|
||||
{
|
||||
"level": 44,
|
||||
"exp": 1132900
|
||||
},
|
||||
{
|
||||
"level": 45,
|
||||
"exp": 1211300
|
||||
},
|
||||
{
|
||||
"level": 46,
|
||||
"exp": 1293300
|
||||
},
|
||||
{
|
||||
"level": 47,
|
||||
"exp": 1378900
|
||||
},
|
||||
{
|
||||
"level": 48,
|
||||
"exp": 1468200
|
||||
},
|
||||
{
|
||||
"level": 49,
|
||||
"exp": 1561300
|
||||
},
|
||||
{
|
||||
"level": 50,
|
||||
"exp": 1658300
|
||||
},
|
||||
{
|
||||
"level": 51,
|
||||
"exp": 1759300
|
||||
},
|
||||
{
|
||||
"level": 52,
|
||||
"exp": 1864300
|
||||
},
|
||||
{
|
||||
"level": 53,
|
||||
"exp": 1973400
|
||||
},
|
||||
{
|
||||
"level": 54,
|
||||
"exp": 2086700
|
||||
},
|
||||
{
|
||||
"level": 55,
|
||||
"exp": 2204300
|
||||
},
|
||||
{
|
||||
"level": 56,
|
||||
"exp": 2326300
|
||||
},
|
||||
{
|
||||
"level": 57,
|
||||
"exp": 2452700
|
||||
},
|
||||
{
|
||||
"level": 58,
|
||||
"exp": 2583600
|
||||
},
|
||||
{
|
||||
"level": 59,
|
||||
"exp": 2719100
|
||||
},
|
||||
{
|
||||
"level": 60,
|
||||
"exp": 2859300
|
||||
},
|
||||
{
|
||||
"level": 61,
|
||||
"exp": 3004300
|
||||
},
|
||||
{
|
||||
"level": 62,
|
||||
"exp": 3154100
|
||||
},
|
||||
{
|
||||
"level": 63,
|
||||
"exp": 3308800
|
||||
},
|
||||
{
|
||||
"level": 64,
|
||||
"exp": 3468500
|
||||
},
|
||||
{
|
||||
"level": 65,
|
||||
"exp": 3633300
|
||||
},
|
||||
{
|
||||
"level": 66,
|
||||
"exp": 3803300
|
||||
},
|
||||
{
|
||||
"level": 67,
|
||||
"exp": 3978500
|
||||
},
|
||||
{
|
||||
"level": 68,
|
||||
"exp": 4159000
|
||||
},
|
||||
{
|
||||
"level": 69,
|
||||
"exp": 4344900
|
||||
},
|
||||
{
|
||||
"level": 70,
|
||||
"exp": 4536300
|
||||
},
|
||||
{
|
||||
"level": 71,
|
||||
"exp": 4733300
|
||||
},
|
||||
{
|
||||
"level": 72,
|
||||
"exp": 4935900
|
||||
},
|
||||
{
|
||||
"level": 73,
|
||||
"exp": 5144200
|
||||
},
|
||||
{
|
||||
"level": 74,
|
||||
"exp": 5358300
|
||||
},
|
||||
{
|
||||
"level": 75,
|
||||
"exp": 5578300
|
||||
},
|
||||
{
|
||||
"level": 76,
|
||||
"exp": 5804300
|
||||
},
|
||||
{
|
||||
"level": 77,
|
||||
"exp": 6036300
|
||||
},
|
||||
{
|
||||
"level": 78,
|
||||
"exp": 6274400
|
||||
},
|
||||
{
|
||||
"level": 79,
|
||||
"exp": 6518700
|
||||
},
|
||||
{
|
||||
"level": 80,
|
||||
"exp": 6769300
|
||||
},
|
||||
{
|
||||
"level": 81,
|
||||
"exp": 7026300
|
||||
},
|
||||
{
|
||||
"level": 82,
|
||||
"exp": 7289700
|
||||
},
|
||||
{
|
||||
"level": 83,
|
||||
"exp": 7559600
|
||||
},
|
||||
{
|
||||
"level": 84,
|
||||
"exp": 7836100
|
||||
},
|
||||
{
|
||||
"level": 85,
|
||||
"exp": 8119300
|
||||
},
|
||||
{
|
||||
"level": 86,
|
||||
"exp": 8409300
|
||||
},
|
||||
{
|
||||
"level": 87,
|
||||
"exp": 8706100
|
||||
},
|
||||
{
|
||||
"level": 88,
|
||||
"exp": 9009800
|
||||
},
|
||||
{
|
||||
"level": 89,
|
||||
"exp": 9320500
|
||||
},
|
||||
{
|
||||
"level": 90,
|
||||
"exp": 9638300
|
||||
},
|
||||
{
|
||||
"level": 91,
|
||||
"exp": 9963300
|
||||
},
|
||||
{
|
||||
"level": 92,
|
||||
"exp": 10295500
|
||||
},
|
||||
{
|
||||
"level": 93,
|
||||
"exp": 10635000
|
||||
},
|
||||
{
|
||||
"level": 94,
|
||||
"exp": 10981900
|
||||
},
|
||||
{
|
||||
"level": 95,
|
||||
"exp": 11336300
|
||||
},
|
||||
{
|
||||
"level": 96,
|
||||
"exp": 11698300
|
||||
},
|
||||
{
|
||||
"level": 97,
|
||||
"exp": 12067900
|
||||
},
|
||||
{
|
||||
"level": 98,
|
||||
"exp": 12445200
|
||||
},
|
||||
{
|
||||
"level": 99,
|
||||
"exp": 12830300
|
||||
},
|
||||
{
|
||||
"level": 100,
|
||||
"exp": 13223300
|
||||
},
|
||||
{
|
||||
"level": 101,
|
||||
"exp": 13624300
|
||||
},
|
||||
{
|
||||
"level": 102,
|
||||
"exp": 14185200
|
||||
},
|
||||
{
|
||||
"level": 103,
|
||||
"exp": 14760100
|
||||
},
|
||||
{
|
||||
"level": 104,
|
||||
"exp": 15349200
|
||||
},
|
||||
{
|
||||
"level": 105,
|
||||
"exp": 15952700
|
||||
},
|
||||
{
|
||||
"level": 106,
|
||||
"exp": 16570700
|
||||
},
|
||||
{
|
||||
"level": 107,
|
||||
"exp": 17203500
|
||||
},
|
||||
{
|
||||
"level": 108,
|
||||
"exp": 17851200
|
||||
},
|
||||
{
|
||||
"level": 109,
|
||||
"exp": 18513900
|
||||
},
|
||||
{
|
||||
"level": 110,
|
||||
"exp": 19191900
|
||||
},
|
||||
{
|
||||
"level": 111,
|
||||
"exp": 19885400
|
||||
},
|
||||
{
|
||||
"level": 112,
|
||||
"exp": 20594500
|
||||
},
|
||||
{
|
||||
"level": 113,
|
||||
"exp": 21319400
|
||||
},
|
||||
{
|
||||
"level": 114,
|
||||
"exp": 22060300
|
||||
},
|
||||
{
|
||||
"level": 115,
|
||||
"exp": 22817400
|
||||
},
|
||||
{
|
||||
"level": 116,
|
||||
"exp": 23590900
|
||||
},
|
||||
{
|
||||
"level": 117,
|
||||
"exp": 24381000
|
||||
},
|
||||
{
|
||||
"level": 118,
|
||||
"exp": 25187800
|
||||
},
|
||||
{
|
||||
"level": 119,
|
||||
"exp": 26011600
|
||||
},
|
||||
{
|
||||
"level": 120,
|
||||
"exp": 26852500
|
||||
},
|
||||
{
|
||||
"level": 121,
|
||||
"exp": 27710700
|
||||
},
|
||||
{
|
||||
"level": 122,
|
||||
"exp": 28586400
|
||||
},
|
||||
{
|
||||
"level": 123,
|
||||
"exp": 29479800
|
||||
},
|
||||
{
|
||||
"level": 124,
|
||||
"exp": 30391100
|
||||
},
|
||||
{
|
||||
"level": 125,
|
||||
"exp": 31320500
|
||||
},
|
||||
{
|
||||
"level": 126,
|
||||
"exp": 32268100
|
||||
},
|
||||
{
|
||||
"level": 127,
|
||||
"exp": 33234200
|
||||
},
|
||||
{
|
||||
"level": 128,
|
||||
"exp": 34218900
|
||||
},
|
||||
{
|
||||
"level": 129,
|
||||
"exp": 35222400
|
||||
},
|
||||
{
|
||||
"level": 130,
|
||||
"exp": 36245000
|
||||
},
|
||||
{
|
||||
"level": 131,
|
||||
"exp": 37286800
|
||||
},
|
||||
{
|
||||
"level": 132,
|
||||
"exp": 38348000
|
||||
},
|
||||
{
|
||||
"level": 133,
|
||||
"exp": 39428800
|
||||
},
|
||||
{
|
||||
"level": 134,
|
||||
"exp": 40529400
|
||||
},
|
||||
{
|
||||
"level": 135,
|
||||
"exp": 41650000
|
||||
},
|
||||
{
|
||||
"level": 136,
|
||||
"exp": 42790800
|
||||
},
|
||||
{
|
||||
"level": 137,
|
||||
"exp": 43952000
|
||||
},
|
||||
{
|
||||
"level": 138,
|
||||
"exp": 45133800
|
||||
},
|
||||
{
|
||||
"level": 139,
|
||||
"exp": 46336400
|
||||
},
|
||||
{
|
||||
"level": 140,
|
||||
"exp": 47559900
|
||||
},
|
||||
{
|
||||
"level": 141,
|
||||
"exp": 48804600
|
||||
},
|
||||
{
|
||||
"level": 142,
|
||||
"exp": 50070700
|
||||
},
|
||||
{
|
||||
"level": 143,
|
||||
"exp": 51358300
|
||||
},
|
||||
{
|
||||
"level": 144,
|
||||
"exp": 52667700
|
||||
},
|
||||
{
|
||||
"level": 145,
|
||||
"exp": 53999100
|
||||
},
|
||||
{
|
||||
"level": 146,
|
||||
"exp": 55352600
|
||||
},
|
||||
{
|
||||
"level": 147,
|
||||
"exp": 56728500
|
||||
},
|
||||
{
|
||||
"level": 148,
|
||||
"exp": 58127000
|
||||
},
|
||||
{
|
||||
"level": 149,
|
||||
"exp": 59548200
|
||||
},
|
||||
{
|
||||
"level": 150,
|
||||
"exp": 60992400
|
||||
},
|
||||
{
|
||||
"level": 151,
|
||||
"exp": 62459800
|
||||
},
|
||||
{
|
||||
"level": 152,
|
||||
"exp": 63950500
|
||||
},
|
||||
{
|
||||
"level": 153,
|
||||
"exp": 65464800
|
||||
},
|
||||
{
|
||||
"level": 154,
|
||||
"exp": 67002900
|
||||
},
|
||||
{
|
||||
"level": 155,
|
||||
"exp": 68564900
|
||||
},
|
||||
{
|
||||
"level": 156,
|
||||
"exp": 70151100
|
||||
},
|
||||
{
|
||||
"level": 157,
|
||||
"exp": 71761700
|
||||
},
|
||||
{
|
||||
"level": 158,
|
||||
"exp": 73396900
|
||||
},
|
||||
{
|
||||
"level": 159,
|
||||
"exp": 75056900
|
||||
},
|
||||
{
|
||||
"level": 160,
|
||||
"exp": 76741900
|
||||
},
|
||||
{
|
||||
"level": 161,
|
||||
"exp": 78452100
|
||||
},
|
||||
{
|
||||
"level": 162,
|
||||
"exp": 80187700
|
||||
},
|
||||
{
|
||||
"level": 163,
|
||||
"exp": 81948900
|
||||
},
|
||||
{
|
||||
"level": 164,
|
||||
"exp": 83735900
|
||||
},
|
||||
{
|
||||
"level": 165,
|
||||
"exp": 85548900
|
||||
},
|
||||
{
|
||||
"level": 166,
|
||||
"exp": 87388200
|
||||
},
|
||||
{
|
||||
"level": 167,
|
||||
"exp": 89253900
|
||||
},
|
||||
{
|
||||
"level": 168,
|
||||
"exp": 91146300
|
||||
},
|
||||
{
|
||||
"level": 169,
|
||||
"exp": 93065500
|
||||
},
|
||||
{
|
||||
"level": 170,
|
||||
"exp": 95011800
|
||||
},
|
||||
{
|
||||
"level": 171,
|
||||
"exp": 96985300
|
||||
},
|
||||
{
|
||||
"level": 172,
|
||||
"exp": 98986300
|
||||
},
|
||||
{
|
||||
"level": 173,
|
||||
"exp": 101015000
|
||||
},
|
||||
{
|
||||
"level": 174,
|
||||
"exp": 103071600
|
||||
},
|
||||
{
|
||||
"level": 175,
|
||||
"exp": 105156300
|
||||
},
|
||||
{
|
||||
"level": 176,
|
||||
"exp": 107269400
|
||||
},
|
||||
{
|
||||
"level": 177,
|
||||
"exp": 109411000
|
||||
},
|
||||
{
|
||||
"level": 178,
|
||||
"exp": 111581300
|
||||
},
|
||||
{
|
||||
"level": 179,
|
||||
"exp": 113780600
|
||||
},
|
||||
{
|
||||
"level": 180,
|
||||
"exp": 116009100
|
||||
},
|
||||
{
|
||||
"level": 181,
|
||||
"exp": 118267000
|
||||
},
|
||||
{
|
||||
"level": 182,
|
||||
"exp": 120554500
|
||||
},
|
||||
{
|
||||
"level": 183,
|
||||
"exp": 122871800
|
||||
},
|
||||
{
|
||||
"level": 184,
|
||||
"exp": 125219100
|
||||
},
|
||||
{
|
||||
"level": 185,
|
||||
"exp": 127596600
|
||||
},
|
||||
{
|
||||
"level": 186,
|
||||
"exp": 130004600
|
||||
},
|
||||
{
|
||||
"level": 187,
|
||||
"exp": 132443200
|
||||
},
|
||||
{
|
||||
"level": 188,
|
||||
"exp": 134912700
|
||||
},
|
||||
{
|
||||
"level": 189,
|
||||
"exp": 137413300
|
||||
},
|
||||
{
|
||||
"level": 190,
|
||||
"exp": 139945200
|
||||
},
|
||||
{
|
||||
"level": 191,
|
||||
"exp": 142508700
|
||||
},
|
||||
{
|
||||
"level": 192,
|
||||
"exp": 145103900
|
||||
},
|
||||
{
|
||||
"level": 193,
|
||||
"exp": 147731100
|
||||
},
|
||||
{
|
||||
"level": 194,
|
||||
"exp": 150390400
|
||||
},
|
||||
{
|
||||
"level": 195,
|
||||
"exp": 153082100
|
||||
},
|
||||
{
|
||||
"level": 196,
|
||||
"exp": 155806500
|
||||
},
|
||||
{
|
||||
"level": 197,
|
||||
"exp": 158563700
|
||||
},
|
||||
{
|
||||
"level": 198,
|
||||
"exp": 161353900
|
||||
},
|
||||
{
|
||||
"level": 199,
|
||||
"exp": 164177400
|
||||
},
|
||||
{
|
||||
"level": 200,
|
||||
"exp": 167034400
|
||||
}
|
||||
]
|
||||
1090
211/server/index.js
Normal file
3343
211/server/package-lock.json
generated
Normal file
30
211/server/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "farm",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.5",
|
||||
"body-parser": "^2.2.2",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.19.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"long": "^5.3.2",
|
||||
"mysql2": "^3.17.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-oauth2": "^1.8.0",
|
||||
"protobufjs": "^8.0.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
27
211/server/proto/corepb.proto
Normal file
@@ -0,0 +1,27 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package corepb;
|
||||
|
||||
// ============ 通用物品 ============
|
||||
message Item {
|
||||
int64 id = 1; // 物品ID
|
||||
int64 count = 2; // 数量
|
||||
int64 expire_time = 3; // 过期时间
|
||||
// field 4 reserved
|
||||
// google.protobuf.Any detail = 5; // 详情 (略)
|
||||
int64 uid = 6; // UID
|
||||
bool is_new = 7; // 是否新获得
|
||||
repeated int64 mutant_types = 8; // 变异类型
|
||||
// ItemShow show = 100; // 展示信息 (略)
|
||||
}
|
||||
|
||||
// 背包(物品列表)
|
||||
message ItemBag {
|
||||
repeated Item items = 1;
|
||||
}
|
||||
|
||||
// ============ 物品变化 ============
|
||||
message ItemChg {
|
||||
Item item = 1; // 物品信息
|
||||
int64 delta = 2; // 变化量 (正数增加, 负数减少)
|
||||
}
|
||||
120
211/server/proto/friendpb.proto
Normal file
@@ -0,0 +1,120 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package gamepb.friendpb;
|
||||
|
||||
// ============ 好友农场摘要信息 ============
|
||||
message Plant {
|
||||
int64 dry_time_sec = 1; // 缺水倒计时秒
|
||||
int64 weed_time_sec = 2; // 长草倒计时秒
|
||||
int64 insect_time_sec = 3; // 生虫倒计时秒
|
||||
int64 ripe_time_sec = 4; // 成熟倒计时秒
|
||||
int64 ripe_fruit_id = 5; // 成熟果实ID
|
||||
int64 steal_plant_num = 6; // 可偷数量
|
||||
int64 dry_num = 7; // 缺水地块数
|
||||
int64 weed_num = 8; // 有草地块数
|
||||
int64 insect_num = 9; // 有虫地块数
|
||||
}
|
||||
|
||||
// ============ 标签 ============
|
||||
message Tags {
|
||||
bool is_new = 1;
|
||||
bool is_follow = 2;
|
||||
}
|
||||
|
||||
// ============ 好友信息 ============
|
||||
message GameFriend {
|
||||
int64 gid = 1;
|
||||
string open_id = 2;
|
||||
string name = 3;
|
||||
string avatar_url = 4;
|
||||
string remark = 5;
|
||||
int64 level = 6;
|
||||
int64 gold = 7;
|
||||
Tags tags = 8;
|
||||
Plant plant = 9;
|
||||
int32 authorized_status = 10;
|
||||
// field 11 reserved
|
||||
// Illustrated illustrated = 12; // 略
|
||||
// repeated AvatarFrame equip_avatar_frames = 13; // 略
|
||||
}
|
||||
|
||||
// ============ 请求/回复 ============
|
||||
|
||||
// --- 获取所有好友 ---
|
||||
message GetAllRequest {}
|
||||
|
||||
message GetAllReply {
|
||||
repeated GameFriend game_friends = 1;
|
||||
// repeated Invitation invitations = 2; // 略
|
||||
int64 application_count = 3;
|
||||
}
|
||||
|
||||
// --- 同步好友 (带 open_ids) ---
|
||||
message SyncAllRequest {
|
||||
// field 1 reserved
|
||||
repeated string open_ids = 2;
|
||||
}
|
||||
|
||||
message SyncAllReply {
|
||||
repeated GameFriend game_friends = 1;
|
||||
// repeated Invitation invitations = 2; // 略
|
||||
int64 application_count = 3;
|
||||
}
|
||||
|
||||
// ============ 好友申请 (微信同玩) ============
|
||||
|
||||
// 申请信息
|
||||
message Application {
|
||||
int64 gid = 1;
|
||||
int64 time_at = 2;
|
||||
string open_id = 3;
|
||||
string name = 4;
|
||||
string avatar_url = 5;
|
||||
int64 level = 6;
|
||||
// repeated AvatarFrame equip_avatar_frames = 7; // 略
|
||||
}
|
||||
|
||||
// --- 获取好友申请列表 ---
|
||||
message GetApplicationsRequest {}
|
||||
|
||||
message GetApplicationsReply {
|
||||
repeated Application applications = 1;
|
||||
bool block_applications = 2; // 是否屏蔽申请
|
||||
}
|
||||
|
||||
// --- 同意好友申请 ---
|
||||
message AcceptFriendsRequest {
|
||||
repeated int64 friend_gids = 1;
|
||||
}
|
||||
|
||||
message AcceptFriendsReply {
|
||||
repeated GameFriend friends = 1;
|
||||
}
|
||||
|
||||
// --- 拒绝好友申请 ---
|
||||
message RejectFriendsRequest {
|
||||
repeated int64 friend_gids = 1;
|
||||
}
|
||||
|
||||
message RejectFriendsReply {}
|
||||
|
||||
// --- 设置屏蔽申请 ---
|
||||
message SetBlockApplicationsRequest {
|
||||
bool block = 1;
|
||||
}
|
||||
|
||||
message SetBlockApplicationsReply {
|
||||
bool block = 1;
|
||||
}
|
||||
|
||||
// ============ 服务器推送通知 ============
|
||||
|
||||
// 收到好友申请通知
|
||||
message FriendApplicationReceivedNotify {
|
||||
repeated Application applications = 1;
|
||||
}
|
||||
|
||||
// 好友添加成功通知 (对方同意后)
|
||||
message FriendAddedNotify {
|
||||
repeated GameFriend friends = 1;
|
||||
}
|
||||
44
211/server/proto/game.proto
Normal file
@@ -0,0 +1,44 @@
|
||||
syntax = "proto3";
|
||||
|
||||
// ============ gatepb ============
|
||||
// 网关层协议 - 所有WS消息的外壳
|
||||
|
||||
package gatepb;
|
||||
|
||||
// Meta.Type 枚举
|
||||
enum MessageType {
|
||||
None = 0;
|
||||
Request = 1;
|
||||
Response = 2;
|
||||
Notify = 3;
|
||||
}
|
||||
|
||||
// 消息元信息
|
||||
message Meta {
|
||||
string service_name = 1;
|
||||
string method_name = 2;
|
||||
int32 message_type = 3; // MessageType enum
|
||||
int64 client_seq = 4;
|
||||
int64 server_seq = 5;
|
||||
int64 error_code = 6;
|
||||
string error_message = 7;
|
||||
map<string, bytes> metadata = 8;
|
||||
}
|
||||
|
||||
// 每个WS帧的结构
|
||||
message Message {
|
||||
Meta meta = 1;
|
||||
bytes body = 2;
|
||||
}
|
||||
|
||||
// 服务器推送事件
|
||||
message EventMessage {
|
||||
string message_type = 1;
|
||||
bytes body = 2;
|
||||
}
|
||||
|
||||
// 被踢下线通知
|
||||
message KickoutNotify {
|
||||
int64 reason = 1;
|
||||
string reason_message = 2;
|
||||
}
|
||||
53
211/server/proto/itempb.proto
Normal file
@@ -0,0 +1,53 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package gamepb.itempb;
|
||||
|
||||
import "corepb.proto";
|
||||
|
||||
// ============ 背包/仓库 ============
|
||||
|
||||
// 获取背包
|
||||
message BagRequest {}
|
||||
|
||||
message BagReply {
|
||||
corepb.ItemBag item_bag = 1; // 与 game 一致,背包数据在 item_bag 里
|
||||
}
|
||||
|
||||
// ============ 出售物品 ============
|
||||
|
||||
message SellRequest {
|
||||
repeated corepb.Item items = 1; // 要出售的物品列表 (id + count)
|
||||
}
|
||||
|
||||
message SellReply {
|
||||
repeated corepb.Item sell_items = 1; // 出售的物品
|
||||
repeated corepb.Item get_items = 2; // 获得的物品(id=1001为金币, id=1002为点券)
|
||||
}
|
||||
|
||||
// ============ 使用物品 ============
|
||||
|
||||
message UseRequest {
|
||||
int64 item_id = 1;
|
||||
int64 count = 2;
|
||||
repeated int64 land_ids = 3;
|
||||
}
|
||||
|
||||
message UseReply {
|
||||
repeated corepb.Item items = 1;
|
||||
}
|
||||
|
||||
// ============ 批量使用物品 ============
|
||||
|
||||
message UseItem {
|
||||
int64 item_id = 1;
|
||||
int64 count = 2;
|
||||
int64 land_count = 6; // 根据抓包,字段6为18(推测为土地数)
|
||||
}
|
||||
|
||||
message BatchUseRequest {
|
||||
repeated UseItem items = 1;
|
||||
}
|
||||
|
||||
message BatchUseReply {
|
||||
repeated corepb.Item items = 1;
|
||||
}
|
||||
10
211/server/proto/notifypb.proto
Normal file
@@ -0,0 +1,10 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package gamepb.itempb;
|
||||
|
||||
import "corepb.proto";
|
||||
|
||||
// ============ 物品变化通知 ============
|
||||
message ItemNotify {
|
||||
repeated corepb.ItemChg items = 1;
|
||||
}
|
||||
266
211/server/proto/plantpb.proto
Normal file
@@ -0,0 +1,266 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package gamepb.plantpb;
|
||||
|
||||
// ============ 生长阶段枚举 ============
|
||||
enum PlantPhase {
|
||||
PHASE_UNKNOWN = 0;
|
||||
SEED = 1; // 种子
|
||||
GERMINATION = 2; // 发芽
|
||||
SMALL_LEAVES = 3; // 小叶
|
||||
LARGE_LEAVES = 4; // 大叶
|
||||
BLOOMING = 5; // 开花
|
||||
MATURE = 6; // 成熟 (可收获)
|
||||
DEAD = 7; // 枯死
|
||||
}
|
||||
|
||||
// ============ 土地信息 ============
|
||||
message LandInfo {
|
||||
int64 id = 1;
|
||||
bool unlocked = 2;
|
||||
int64 level = 3;
|
||||
int64 max_level = 4;
|
||||
bool could_unlock = 5;
|
||||
bool could_upgrade = 6;
|
||||
LandUnlockCondition unlock_condition = 7;
|
||||
LandUpgradeCondition upgrade_condition = 8;
|
||||
Buff buff = 9;
|
||||
PlantInfo plant = 10;
|
||||
bool is_shared = 11;
|
||||
bool can_share = 12;
|
||||
int64 master_land_id = 13;
|
||||
repeated int64 slave_land_ids = 14;
|
||||
int64 land_size = 15;
|
||||
int64 lands_level = 16;
|
||||
|
||||
// 土地buff
|
||||
message Buff {
|
||||
int64 plant_yield_bonus = 1;
|
||||
int64 planting_time_reduction = 2;
|
||||
int64 plant_exp_bonus = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// 土地解锁条件 (简化)
|
||||
message LandUnlockCondition {
|
||||
int64 need_level = 1;
|
||||
int64 need_gold = 2;
|
||||
// ... 其他条件字段
|
||||
}
|
||||
|
||||
// 土地升级条件 (简化)
|
||||
message LandUpgradeCondition {
|
||||
int64 need_level = 1;
|
||||
int64 need_gold = 2;
|
||||
// ... 其他条件字段
|
||||
}
|
||||
|
||||
// ============ 植物信息 ============
|
||||
message PlantInfo {
|
||||
int64 id = 1; // 植物/种子ID
|
||||
string name = 2; // 名称
|
||||
// field 3 reserved
|
||||
repeated PlantPhaseInfo phases = 4; // 生长阶段列表
|
||||
int64 season = 5; // 季节
|
||||
int64 dry_num = 6; // 缺水次数 (>0需要浇水)
|
||||
// fields 7,8 reserved
|
||||
int64 stole_num = 9; // 被偷次数
|
||||
int64 fruit_id = 10; // 果实ID
|
||||
int64 fruit_num = 11; // 果实数量
|
||||
repeated int64 weed_owners = 12; // 放草的人 (非空=有草)
|
||||
repeated int64 insect_owners = 13; // 放虫的人 (非空=有虫)
|
||||
repeated int64 stealers = 14; // 偷菜的人
|
||||
int64 grow_sec = 15; // 生长秒数
|
||||
bool stealable = 16; // 是否可偷
|
||||
int64 left_inorc_fert_times = 17; // 剩余施肥次数
|
||||
int64 left_fruit_num = 18; // 剩余果实数
|
||||
int64 steal_intimacy_level = 19; // 偷菜亲密度等级
|
||||
repeated int64 mutant_config_ids = 20; // 变异配置ID
|
||||
bool is_nudged = 21; // 是否被催熟
|
||||
}
|
||||
|
||||
// ============ 生长阶段详情 ============
|
||||
message PlantPhaseInfo {
|
||||
int32 phase = 1; // PlantPhase 枚举值
|
||||
int64 begin_time = 2; // 阶段开始时间
|
||||
int64 phase_id = 3; // 阶段ID
|
||||
// fields 4,5 reserved
|
||||
int64 dry_time = 6; // 变干时间 (>0 需要浇水)
|
||||
int64 weeds_time = 7; // 长草时间 (>0 有杂草)
|
||||
int64 insect_time = 8; // 生虫时间 (>0 有虫害)
|
||||
map<int64, int64> ferts_used = 9; // 已用肥料
|
||||
repeated MutantInfo mutants = 10; // 变异信息
|
||||
}
|
||||
|
||||
// 变异信息
|
||||
message MutantInfo {
|
||||
int64 mutant_time = 1;
|
||||
int64 mutant_config_id = 2;
|
||||
int64 weather_id = 3;
|
||||
}
|
||||
|
||||
// ============ 操作限制 ============
|
||||
// 每种操作的每日限制信息
|
||||
message OperationLimit {
|
||||
int64 id = 1; // 操作类型ID
|
||||
int64 day_times = 2; // 今日已操作次数
|
||||
int64 day_times_lt = 3; // 每日操作上限
|
||||
int64 day_share_id = 4; // 分享ID
|
||||
int64 day_exp_times = 5; // 今日已获得经验次数
|
||||
int64 day_ex_times_lt = 6; // 每日可获得经验上限
|
||||
int64 day_exp_share_id = 7; // 经验分享ID
|
||||
}
|
||||
// 操作类型ID:
|
||||
// 10001 = 帮好友浇水
|
||||
// 10002 = 帮好友除虫
|
||||
// 10003 = 帮好友除草
|
||||
// 10004 = 偷菜
|
||||
// 10005 = 放虫
|
||||
// 10006 = 放草
|
||||
|
||||
// ============ 请求/回复消息 ============
|
||||
|
||||
// --- 获取所有土地 ---
|
||||
message AllLandsRequest {
|
||||
int64 host_gid = 1; // 0或自己的GID=查看自己
|
||||
}
|
||||
|
||||
message AllLandsReply {
|
||||
repeated LandInfo lands = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
}
|
||||
|
||||
// --- 收获 ---
|
||||
message HarvestRequest {
|
||||
repeated int64 land_ids = 1; // 要收获的土地ID列表
|
||||
int64 host_gid = 2; // 农场主GID
|
||||
bool is_all = 3; // 是否全部收获
|
||||
}
|
||||
|
||||
message HarvestReply {
|
||||
repeated LandInfo land = 1;
|
||||
// repeated Item items = 2; // corepb.Item
|
||||
// repeated Item lost_items = 3;
|
||||
repeated OperationLimit operation_limits = 4;
|
||||
}
|
||||
|
||||
// --- 浇水 ---
|
||||
message WaterLandRequest {
|
||||
repeated int64 land_ids = 1;
|
||||
int64 host_gid = 2;
|
||||
}
|
||||
|
||||
message WaterLandReply {
|
||||
repeated LandInfo land = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
}
|
||||
|
||||
// --- 除草 ---
|
||||
message WeedOutRequest {
|
||||
repeated int64 land_ids = 1;
|
||||
int64 host_gid = 2;
|
||||
}
|
||||
|
||||
message WeedOutReply {
|
||||
repeated LandInfo land = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
}
|
||||
|
||||
// --- 除虫 ---
|
||||
message InsecticideRequest {
|
||||
repeated int64 land_ids = 1;
|
||||
int64 host_gid = 2;
|
||||
}
|
||||
|
||||
message InsecticideReply {
|
||||
repeated LandInfo land = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
}
|
||||
|
||||
// --- 播种 ---
|
||||
message PlantItem {
|
||||
int64 seed_id = 1; // 种子ID
|
||||
repeated int64 land_ids = 2; // 要种植的土地ID列表
|
||||
bool auto_slave = 3; // 是否自动副产
|
||||
}
|
||||
|
||||
message PlantRequest {
|
||||
map<int64, int64> land_and_seed = 1; // land_id -> seed_id (旧版)
|
||||
repeated PlantItem items = 2; // 新版: 按种子分组种植
|
||||
}
|
||||
|
||||
message PlantReply {
|
||||
repeated LandInfo land = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
}
|
||||
|
||||
// --- 移除植物 ---
|
||||
message RemovePlantRequest {
|
||||
repeated int64 land_ids = 1;
|
||||
}
|
||||
|
||||
message RemovePlantReply {
|
||||
repeated LandInfo land = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
}
|
||||
|
||||
// --- 施肥 ---
|
||||
message FertilizeRequest {
|
||||
repeated int64 land_ids = 1;
|
||||
int64 fertilizer_id = 2;
|
||||
}
|
||||
|
||||
message FertilizeReply {
|
||||
repeated LandInfo land = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
int64 fertilizer = 3; // 剩余肥料时间
|
||||
}
|
||||
|
||||
// --- 升级土地 ---
|
||||
message UpgradeLandRequest {
|
||||
int64 land_id = 1;
|
||||
}
|
||||
|
||||
message UpgradeLandReply {
|
||||
LandInfo land = 1;
|
||||
}
|
||||
|
||||
// --- 解锁土地 ---
|
||||
message UnlockLandRequest {
|
||||
int64 land_id = 1;
|
||||
bool do_shared = 2;
|
||||
}
|
||||
|
||||
message UnlockLandReply {
|
||||
LandInfo land = 1;
|
||||
}
|
||||
|
||||
// --- 放虫 ---
|
||||
message PutInsectsRequest {
|
||||
repeated int64 land_ids = 1;
|
||||
int64 host_gid = 2;
|
||||
}
|
||||
|
||||
message PutInsectsReply {
|
||||
repeated LandInfo land = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
}
|
||||
|
||||
// --- 放草 ---
|
||||
message PutWeedsRequest {
|
||||
repeated int64 land_ids = 1;
|
||||
int64 host_gid = 2;
|
||||
}
|
||||
|
||||
message PutWeedsReply {
|
||||
repeated LandInfo land = 1;
|
||||
repeated OperationLimit operation_limits = 2;
|
||||
}
|
||||
|
||||
// ============ 服务器推送通知 ============
|
||||
|
||||
// 土地状态变化通知 (被放虫/放草/偷菜等)
|
||||
message LandsNotify {
|
||||
repeated LandInfo lands = 1; // 变化的土地列表
|
||||
int64 host_gid = 2; // 农场主GID
|
||||
}
|
||||
74
211/server/proto/shoppb.proto
Normal file
@@ -0,0 +1,74 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package gamepb.shoppb;
|
||||
|
||||
import "corepb.proto";
|
||||
|
||||
// ============ 商店类型: 1=道具商店, 2=种子商店, 3=宠物商店 ============
|
||||
|
||||
// ============ 商店概览 ============
|
||||
message ShopProfile {
|
||||
int64 shop_id = 1; // 商店ID
|
||||
string shop_name = 2; // 商店名称
|
||||
int32 shop_type = 3; // 商店类型
|
||||
}
|
||||
|
||||
// ============ 商品信息 ============
|
||||
message GoodsInfo {
|
||||
int64 id = 1; // 商品ID (用于购买)
|
||||
int64 bought_num = 2; // 已购买数量
|
||||
int64 price = 3; // 价格 (金币)
|
||||
int64 limit_count = 4; // 限购数量 (0=不限购)
|
||||
bool unlocked = 5; // 是否已解锁
|
||||
int64 item_id = 6; // 物品ID (即种子ID)
|
||||
int64 item_count = 7; // 每次购买获得数量
|
||||
repeated Cond conds = 8; // 解锁条件
|
||||
}
|
||||
|
||||
// ============ 解锁条件 ============
|
||||
enum CondType {
|
||||
COND_TYPE_UNKNOWN = 0;
|
||||
MIN_LEVEL = 1; // 最低等级要求
|
||||
UNLOCK_CARD = 2; // 需要解锁卡
|
||||
}
|
||||
|
||||
message Cond {
|
||||
int32 type = 1; // CondType
|
||||
int64 param = 2; // 参数值 (如等级要求的等级数)
|
||||
}
|
||||
|
||||
// ============ 请求/回复 ============
|
||||
|
||||
// --- 获取商店列表 ---
|
||||
message ShopProfilesRequest {}
|
||||
|
||||
message ShopProfilesReply {
|
||||
repeated ShopProfile shop_profiles = 1;
|
||||
}
|
||||
|
||||
// --- 获取商店商品 ---
|
||||
message ShopInfoRequest {
|
||||
int64 shop_id = 1;
|
||||
}
|
||||
|
||||
message ShopInfoReply {
|
||||
repeated GoodsInfo goods_list = 1;
|
||||
}
|
||||
|
||||
// --- 购买商品 ---
|
||||
message BuyGoodsRequest {
|
||||
int64 goods_id = 1; // 商品ID (GoodsInfo.id)
|
||||
int64 num = 2; // 购买数量
|
||||
int64 price = 3; // 单价
|
||||
}
|
||||
|
||||
message BuyGoodsReply {
|
||||
GoodsInfo goods = 1; // 更新后的商品信息
|
||||
repeated corepb.Item get_items = 2; // 获得的物品
|
||||
repeated corepb.Item cost_items = 3; // 消耗的物品
|
||||
}
|
||||
|
||||
// --- 商品解锁推送 ---
|
||||
message GoodsUnlockNotify {
|
||||
repeated GoodsInfo goods_list = 1;
|
||||
}
|
||||
81
211/server/proto/taskpb.proto
Normal file
@@ -0,0 +1,81 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package gamepb.taskpb;
|
||||
|
||||
import "corepb.proto";
|
||||
|
||||
// ============ 任务类型 ============
|
||||
// 1 = 成长任务 (GROWTH_TASK)
|
||||
// 2 = 每日任务 (DAILY_TASK)
|
||||
|
||||
// ============ 单个任务 ============
|
||||
message Task {
|
||||
int64 id = 1; // 任务ID
|
||||
int64 progress = 2; // 当前进度
|
||||
bool is_claimed = 3; // 是否已领取
|
||||
bool is_unlocked = 4; // 是否已解锁
|
||||
repeated corepb.Item rewards = 5; // 奖励列表
|
||||
int64 total_progress = 6; // 总进度
|
||||
int64 share_multiple = 7; // 分享倍数 (>1 表示可分享翻倍)
|
||||
repeated string params = 8; // 参数
|
||||
string desc = 9; // 描述
|
||||
int32 task_type = 10; // 任务类型
|
||||
int64 group = 11; // 分组
|
||||
int64 cond_type = 12; // 条件类型
|
||||
int64 is_show_text = 13; // 是否显示文本
|
||||
}
|
||||
|
||||
// ============ 活跃度奖励 ============
|
||||
message Active {
|
||||
int64 id = 1;
|
||||
int32 status = 2; // 0=未完成, 1=已完成, 2=未完成
|
||||
repeated corepb.Item items = 3;
|
||||
}
|
||||
|
||||
// ============ 任务信息汇总 ============
|
||||
message TaskInfo {
|
||||
repeated Task growth_tasks = 1; // 成长任务列表
|
||||
repeated Task daily_tasks = 2; // 每日任务列表
|
||||
repeated Task tasks = 3; // 其他任务
|
||||
repeated Active actives = 4; // 活跃度奖励
|
||||
}
|
||||
|
||||
// ============ 请求/回复 ============
|
||||
|
||||
// --- 获取任务信息 ---
|
||||
message TaskInfoRequest {}
|
||||
|
||||
message TaskInfoReply {
|
||||
TaskInfo task_info = 1;
|
||||
}
|
||||
|
||||
// --- 领取单个任务奖励 ---
|
||||
message ClaimTaskRewardRequest {
|
||||
int64 id = 1; // 任务ID
|
||||
bool do_shared = 2; // 是否使用分享翻倍 (true=翻倍, false=普通领取)
|
||||
}
|
||||
|
||||
message ClaimTaskRewardReply {
|
||||
repeated corepb.Item items = 1; // 获得的物品
|
||||
TaskInfo task_info = 2; // 更新后的任务信息
|
||||
repeated corepb.Item compensated_items = 3; // 补偿物品
|
||||
}
|
||||
|
||||
// --- 批量领取任务奖励 ---
|
||||
message BatchClaimTaskRewardRequest {
|
||||
repeated int64 ids = 1; // 任务ID列表
|
||||
bool do_shared = 2; // 是否使用分享翻倍
|
||||
}
|
||||
|
||||
message BatchClaimTaskRewardReply {
|
||||
repeated corepb.Item items = 1;
|
||||
TaskInfo task_info = 2;
|
||||
repeated corepb.Item compensated_items = 3;
|
||||
}
|
||||
|
||||
// ============ 服务器推送 ============
|
||||
|
||||
// 任务状态变化通知
|
||||
message TaskInfoNotify {
|
||||
TaskInfo task_info = 1;
|
||||
}
|
||||
123
211/server/proto/userpb.proto
Normal file
@@ -0,0 +1,123 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package gamepb.userpb;
|
||||
|
||||
// ============ 登录请求 ============
|
||||
message LoginRequest {
|
||||
// 字段 1,2 保留未使用
|
||||
int64 sharer_id = 3;
|
||||
string sharer_open_id = 4;
|
||||
DeviceInfo device_info = 5;
|
||||
int64 share_cfg_id = 6;
|
||||
string scene_id = 7;
|
||||
ReportData report_data = 8;
|
||||
}
|
||||
|
||||
// 设备信息
|
||||
message DeviceInfo {
|
||||
string client_version = 1; // e.g. "1.6.0.8_20251224"
|
||||
string sys_software = 2; // e.g. "Windows Unknown x64"
|
||||
string sys_hardware = 3;
|
||||
string telecom_oper = 4;
|
||||
string network = 5; // e.g. "wifi"
|
||||
int64 screen_width = 6;
|
||||
int64 screen_height = 7;
|
||||
float density = 8;
|
||||
string cpu = 9; // e.g. "microsoft"
|
||||
int64 memory = 10;
|
||||
string gl_render = 11;
|
||||
string gl_version = 12;
|
||||
string device_id = 13;
|
||||
string android_oaid = 14;
|
||||
string ios_caid = 15;
|
||||
}
|
||||
|
||||
// 上报数据
|
||||
message ReportData {
|
||||
string callback = 1;
|
||||
string cd_extend_info = 2;
|
||||
string click_id = 3;
|
||||
string clue_token = 4;
|
||||
string minigame_channel = 5;
|
||||
int32 minigame_platid = 6;
|
||||
string req_id = 7;
|
||||
string trackid = 8;
|
||||
}
|
||||
|
||||
// ============ 登录回复 ============
|
||||
message LoginReply {
|
||||
BasicInfo basic = 1;
|
||||
// ItemBag item_bag = 2; // corepb.ItemBag, 复杂结构先跳过
|
||||
int64 time_now_millis = 3;
|
||||
bool is_first_login = 4;
|
||||
// GuideInfo guide_info = 5;
|
||||
repeated QQGroupInfo qq_group_infos = 6;
|
||||
// Illustrated illustrated = 7;
|
||||
// repeated SystemUnlockItem unlocked_items = 8;
|
||||
VersionInfo version_info = 9;
|
||||
// MallMsg mall_msg = 10;
|
||||
int64 qq_friend_recommend_authorized = 11;
|
||||
}
|
||||
|
||||
// 用户基本信息
|
||||
message BasicInfo {
|
||||
int64 gid = 1;
|
||||
string name = 2;
|
||||
int64 level = 3;
|
||||
int64 exp = 4;
|
||||
int64 gold = 5;
|
||||
string open_id = 6;
|
||||
string avatar_url = 7;
|
||||
string remark = 8;
|
||||
string signature = 9;
|
||||
int32 gender = 10;
|
||||
// repeated AvatarFrame equip_avatar_frames = 11;
|
||||
// map<int64, ShareInfo> share_infos = 12;
|
||||
int32 authorized_status = 13;
|
||||
bool disable_nudge = 14;
|
||||
}
|
||||
|
||||
// QQ群信息
|
||||
message QQGroupInfo {
|
||||
string qq_group_id = 1;
|
||||
string qq_group_name = 2;
|
||||
}
|
||||
|
||||
// 版本信息
|
||||
message VersionInfo {
|
||||
int32 status = 1;
|
||||
string version_recommend = 2;
|
||||
string version_force = 3;
|
||||
string res_version = 4;
|
||||
}
|
||||
|
||||
// ============ 心跳 ============
|
||||
message HeartbeatRequest {
|
||||
int64 gid = 1;
|
||||
string client_version = 2;
|
||||
}
|
||||
|
||||
message HeartbeatReply {
|
||||
int64 server_time = 1;
|
||||
VersionInfo version_info = 2;
|
||||
}
|
||||
|
||||
// ============ 上报分享点击 ============
|
||||
// 用于已登录状态下处理分享链接(触发好友申请)
|
||||
message ReportArkClickRequest {
|
||||
int64 sharer_id = 1;
|
||||
string sharer_open_id = 2;
|
||||
string scene_id = 3;
|
||||
int64 share_cfg_id = 4;
|
||||
}
|
||||
|
||||
message ReportArkClickReply {
|
||||
// 通常为空响应
|
||||
}
|
||||
|
||||
// ============ 服务器推送通知 ============
|
||||
|
||||
// 基本信息变化通知 (升级/金币变化等)
|
||||
message BasicNotify {
|
||||
BasicInfo basic = 1;
|
||||
}
|
||||
42
211/server/proto/visitpb.proto
Normal file
@@ -0,0 +1,42 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package gamepb.visitpb;
|
||||
|
||||
import "plantpb.proto";
|
||||
import "userpb.proto";
|
||||
|
||||
// ============ 访问好友农场服务 ============
|
||||
// service VisitService {
|
||||
// rpc Enter(EnterRequest) returns (EnterReply);
|
||||
// rpc Leave(LeaveRequest) returns (LeaveReply);
|
||||
// }
|
||||
|
||||
// ============ 进入原因枚举 ============
|
||||
enum EnterReason {
|
||||
ENTER_REASON_UNKNOWN = 0;
|
||||
ENTER_REASON_BUBBLE = 1;
|
||||
ENTER_REASON_FRIEND = 2;
|
||||
ENTER_REASON_INTERACT = 3;
|
||||
}
|
||||
|
||||
// ============ 进入好友农场 ============
|
||||
message EnterRequest {
|
||||
int64 host_gid = 1; // 好友的GID
|
||||
int32 reason = 2; // EnterReason 进入原因
|
||||
}
|
||||
|
||||
message EnterReply {
|
||||
gamepb.userpb.BasicInfo basic = 1; // 好友基本信息
|
||||
repeated gamepb.plantpb.LandInfo lands = 2; // 好友的所有土地
|
||||
// field 3: brief_dog_info (不需要)
|
||||
// field 4: nudge_info (不需要)
|
||||
}
|
||||
|
||||
// ============ 离开好友农场 ============
|
||||
message LeaveRequest {
|
||||
int64 host_gid = 1; // 好友的GID
|
||||
}
|
||||
|
||||
message LeaveReply {
|
||||
// 空消息
|
||||
}
|
||||
0
211/server/share.txt
Normal file
53
211/server/src/config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 配置常量与枚举定义
|
||||
*/
|
||||
|
||||
const CONFIG = {
|
||||
serverUrl: 'wss://gate-obt.nqf.qq.com/prod/ws',
|
||||
clientVersion: '1.6.0.14_20251224',
|
||||
platform: 'qq', // 平台: qq 或 wx (可通过 --wx 切换为微信)
|
||||
os: 'iOS',
|
||||
heartbeatInterval: 25000, // 心跳间隔 25秒
|
||||
farmCheckInterval: 1000, // 自己农场巡查完成后等待间隔 (可通过 --interval 修改, 最低1秒)
|
||||
friendCheckInterval: 10000, // 好友巡查完成后等待间隔 (可通过 --friend-interval 修改, 最低1秒)
|
||||
forceLowestLevelCrop: false, // 开启后固定种最低等级作物(通常是白萝卜),跳过经验效率分析
|
||||
enableNormalFertilize: false,
|
||||
enableOrganicFertilize: false,
|
||||
allowTicketFertilizerPurchase: false,
|
||||
enableFriendOps: true,
|
||||
enableSteal: true,
|
||||
enableAutoSell: true,
|
||||
allowBuySeeds: true,
|
||||
allowRemove: true,
|
||||
idleStrategy: 'exp',
|
||||
};
|
||||
|
||||
// 运行期提示文案(做了简单编码,避免明文散落)
|
||||
const RUNTIME_HINT_MASK = 23;
|
||||
const RUNTIME_HINT_DATA = [
|
||||
12295, 22759, 26137, 12294, 26427, 39022, 30457, 24343, 28295, 20826,
|
||||
36142, 65307, 20018, 31126, 20485, 21313, 12309, 35808, 20185, 20859,
|
||||
24343, 20164, 24196, 20826, 36142, 33696, 21441, 12309,
|
||||
];
|
||||
|
||||
// 生长阶段枚举
|
||||
const PlantPhase = {
|
||||
UNKNOWN: 0,
|
||||
SEED: 1,
|
||||
GERMINATION: 2,
|
||||
SMALL_LEAVES: 3,
|
||||
LARGE_LEAVES: 4,
|
||||
BLOOMING: 5,
|
||||
MATURE: 6,
|
||||
DEAD: 7,
|
||||
};
|
||||
|
||||
const PHASE_NAMES = ['未知', '种子', '发芽', '小叶', '大叶', '开花', '成熟', '枯死'];
|
||||
|
||||
module.exports = {
|
||||
CONFIG,
|
||||
PlantPhase,
|
||||
PHASE_NAMES,
|
||||
RUNTIME_HINT_MASK,
|
||||
RUNTIME_HINT_DATA,
|
||||
};
|
||||
186
211/server/src/core/FarmBot.js
Normal file
@@ -0,0 +1,186 @@
|
||||
const EventEmitter = require('events');
|
||||
const { NetworkClient } = require('./Network');
|
||||
const { FarmManager } = require('./FarmManager');
|
||||
const { FriendManager } = require('./FriendManager');
|
||||
const { WarehouseManager } = require('./WarehouseManager');
|
||||
const { ShopManager } = require('./ShopManager');
|
||||
const { log, logWarn, emitRuntimeHint } = require('../utils');
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
class FarmBot extends EventEmitter {
|
||||
constructor(config = {}) {
|
||||
super();
|
||||
// Merge provided config with default CONFIG
|
||||
// Deep copy to avoid modifying global CONFIG if passed by reference (though usually it's shallow copy)
|
||||
this.config = { ...CONFIG, ...config };
|
||||
|
||||
// Initialize components
|
||||
this.network = new NetworkClient(this);
|
||||
this.farmManager = new FarmManager(this);
|
||||
this.friendManager = new FriendManager(this);
|
||||
this.warehouseManager = new WarehouseManager(this);
|
||||
this.shopManager = new ShopManager(this);
|
||||
this.dailyClaimTimer = null;
|
||||
|
||||
// State
|
||||
this.isRunning = false;
|
||||
this.user = {
|
||||
gid: 0,
|
||||
name: 'Loading...',
|
||||
level: 0,
|
||||
gold: 0,
|
||||
exp: 0,
|
||||
tickets: 0,
|
||||
fertilizer: 0,
|
||||
freeMallClaimDate: ''
|
||||
};
|
||||
|
||||
// Error handling
|
||||
this.network.on('error', (err) => {
|
||||
log('机器人', `网络错误: ${err.message}`);
|
||||
this.emit('error', err);
|
||||
});
|
||||
|
||||
this.network.on('disconnected', () => {
|
||||
if (this.isRunning) {
|
||||
log('机器人', '连接断开,正在尝试重新连接...');
|
||||
// Reconnection logic is handled in NetworkClient, but we can monitor it here
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
|
||||
this.log('系统', `正在启动农场小助手... 平台: ${this.config.platform}`);
|
||||
emitRuntimeHint(true);
|
||||
|
||||
try {
|
||||
// 1. Connect
|
||||
this.log('系统', '正在连接服务器...');
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('连接超时')), 10000);
|
||||
this.network.connect((success) => {
|
||||
clearTimeout(timeout);
|
||||
if (success) resolve();
|
||||
else reject(new Error('连接失败'));
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Login
|
||||
this.log('系统', '正在登录...');
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('登录超时')), 10000);
|
||||
this.network.sendLogin((success) => {
|
||||
clearTimeout(timeout);
|
||||
if (success) resolve();
|
||||
else reject(new Error('登录失败'));
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Start Loops
|
||||
this.log('系统', '开始自动化作业...');
|
||||
this.farmManager.startLoop();
|
||||
if (this.config.enableFriendOps !== false) {
|
||||
this.friendManager.startLoop();
|
||||
}
|
||||
if (this.config.enableAutoSell !== false) {
|
||||
this.warehouseManager.startLoop();
|
||||
}
|
||||
this.startDailyClaimLoop();
|
||||
|
||||
this.emit('started');
|
||||
this.log('系统', '农场小助手启动成功!');
|
||||
|
||||
} catch (error) {
|
||||
this.log('系统', `启动失败: ${error.message}`);
|
||||
this.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
this.farmManager.stopLoop();
|
||||
this.friendManager.stopLoop();
|
||||
this.warehouseManager.stopLoop();
|
||||
this.stopDailyClaimLoop();
|
||||
|
||||
// Stop network
|
||||
if (this.network.ws) {
|
||||
this.network.ws.close();
|
||||
}
|
||||
|
||||
this.emit('stopped');
|
||||
log('系统', '农场小助手已停止');
|
||||
}
|
||||
|
||||
log(tag, msg) {
|
||||
log(tag, msg);
|
||||
this.emit('log', {
|
||||
tag,
|
||||
msg,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
logWarn(tag, msg) {
|
||||
logWarn(tag, msg);
|
||||
this.emit('log', {
|
||||
tag,
|
||||
msg,
|
||||
type: 'warn',
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
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 runDailyClaims() {
|
||||
if (!this.isRunning) return;
|
||||
await this.network.tryClaimShareReward(1);
|
||||
const todayKey = this.getTodayKey();
|
||||
if (this.user.freeMallClaimDate === todayKey) return;
|
||||
try {
|
||||
await this.shopManager.purchaseMallItem(1001, 1);
|
||||
this.user.freeMallClaimDate = todayKey;
|
||||
this.emit('userUpdate', this.user);
|
||||
this.log('商城', '已领取每日免费礼包');
|
||||
} catch (e) {
|
||||
this.logWarn('商城', `领取失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
startDailyClaimLoop() {
|
||||
if (this.dailyClaimTimer) return;
|
||||
const scheduleNext = () => {
|
||||
if (!this.isRunning) return;
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(24, 0, 0, 0);
|
||||
const delay = Math.max(1000, next.getTime() - now.getTime());
|
||||
this.dailyClaimTimer = setTimeout(async () => {
|
||||
this.dailyClaimTimer = null;
|
||||
await this.runDailyClaims();
|
||||
scheduleNext();
|
||||
}, delay);
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
stopDailyClaimLoop() {
|
||||
if (this.dailyClaimTimer) {
|
||||
clearTimeout(this.dailyClaimTimer);
|
||||
this.dailyClaimTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { FarmBot };
|
||||
1089
211/server/src/core/FarmManager.js
Normal file
497
211/server/src/core/FriendManager.js
Normal file
@@ -0,0 +1,497 @@
|
||||
const { PlantPhase } = require('../config');
|
||||
const { types } = require('../proto');
|
||||
const { toLong, toNum, sleep, getServerTimeSec, toTimeSec } = require('../utils');
|
||||
const { getPlantName } = require('../gameConfig');
|
||||
|
||||
const OP_NAMES = {
|
||||
10001: '收获', 10002: '铲除', 10003: '放草', 10004: '放虫',
|
||||
10005: '除草', 10006: '除虫', 10007: '浇水', 10008: '偷菜',
|
||||
};
|
||||
|
||||
class FriendManager {
|
||||
constructor(bot) {
|
||||
this.bot = bot;
|
||||
|
||||
// Internal state
|
||||
this.isCheckingFriends = false;
|
||||
this.isFirstFriendCheck = true;
|
||||
this.friendCheckTimer = null;
|
||||
this.friendLoopRunning = false;
|
||||
this.lastResetDate = '';
|
||||
this.operationLimits = new Map();
|
||||
|
||||
// Listen for events
|
||||
this.bot.on('operationLimits', this.updateOperationLimits.bind(this));
|
||||
this.bot.on('friendApplicationReceived', this.onFriendApplicationReceived.bind(this));
|
||||
}
|
||||
|
||||
// ============ Helper Methods ============
|
||||
|
||||
checkDailyReset() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (this.lastResetDate !== today) {
|
||||
if (this.lastResetDate !== '') {
|
||||
this.bot.log('系统', '跨日重置,已清空操作限制缓存');
|
||||
}
|
||||
this.operationLimits.clear();
|
||||
this.lastResetDate = today;
|
||||
}
|
||||
}
|
||||
|
||||
updateOperationLimits(limits) {
|
||||
if (!limits || limits.length === 0) return;
|
||||
this.checkDailyReset();
|
||||
for (const limit of limits) {
|
||||
const id = toNum(limit.id);
|
||||
if (id > 0) {
|
||||
this.operationLimits.set(id, {
|
||||
dayTimes: toNum(limit.day_times),
|
||||
dayTimesLimit: toNum(limit.day_times_lt),
|
||||
dayExpTimes: toNum(limit.day_exp_times),
|
||||
dayExpTimesLimit: toNum(limit.day_ex_times_lt),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canGetExp(opId) {
|
||||
const limit = this.operationLimits.get(opId);
|
||||
if (!limit) return false;
|
||||
if (limit.dayExpTimesLimit <= 0) return true;
|
||||
return limit.dayExpTimes < limit.dayExpTimesLimit;
|
||||
}
|
||||
|
||||
canOperate(opId) {
|
||||
const limit = this.operationLimits.get(opId);
|
||||
if (!limit) return true;
|
||||
if (limit.dayTimesLimit <= 0) return true;
|
||||
return limit.dayTimes < limit.dayTimesLimit;
|
||||
}
|
||||
|
||||
getRemainingTimes(opId) {
|
||||
const limit = this.operationLimits.get(opId);
|
||||
if (!limit || limit.dayTimesLimit <= 0) return 999;
|
||||
return Math.max(0, limit.dayTimesLimit - limit.dayTimes);
|
||||
}
|
||||
|
||||
// ============ Friend API ============
|
||||
|
||||
async getAllFriends() {
|
||||
const body = types.GetAllFriendsRequest.encode(types.GetAllFriendsRequest.create({})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.friendpb.FriendService', 'GetAll', body);
|
||||
return types.GetAllFriendsReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async getApplications() {
|
||||
const body = types.GetApplicationsRequest.encode(types.GetApplicationsRequest.create({})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.friendpb.FriendService', 'GetApplications', body);
|
||||
return types.GetApplicationsReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async acceptFriends(gids) {
|
||||
const body = types.AcceptFriendsRequest.encode(types.AcceptFriendsRequest.create({
|
||||
friend_gids: gids.map(g => toLong(g)),
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.friendpb.FriendService', 'AcceptFriends', body);
|
||||
return types.AcceptFriendsReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async enterFriendFarm(friendGid) {
|
||||
const body = types.VisitEnterRequest.encode(types.VisitEnterRequest.create({
|
||||
host_gid: toLong(friendGid),
|
||||
reason: 2,
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.visitpb.VisitService', 'Enter', body);
|
||||
return types.VisitEnterReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async leaveFriendFarm(friendGid) {
|
||||
const body = types.VisitLeaveRequest.encode(types.VisitLeaveRequest.create({
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
try {
|
||||
await this.bot.network.sendMsgAsync('gamepb.visitpb.VisitService', 'Leave', body);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
async helpWater(friendGid, landIds) {
|
||||
const body = types.WaterLandRequest.encode(types.WaterLandRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.plantpb.PlantService', 'WaterLand', body);
|
||||
const reply = types.WaterLandReply.decode(replyBody);
|
||||
this.updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async helpWeed(friendGid, landIds) {
|
||||
const body = types.WeedOutRequest.encode(types.WeedOutRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.plantpb.PlantService', 'WeedOut', body);
|
||||
const reply = types.WeedOutReply.decode(replyBody);
|
||||
this.updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async helpInsecticide(friendGid, landIds) {
|
||||
const body = types.InsecticideRequest.encode(types.InsecticideRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.plantpb.PlantService', 'Insecticide', body);
|
||||
const reply = types.InsecticideReply.decode(replyBody);
|
||||
this.updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async stealHarvest(friendGid, landIds) {
|
||||
const body = types.HarvestRequest.encode(types.HarvestRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
is_all: true,
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.plantpb.PlantService', 'Harvest', body);
|
||||
const reply = types.HarvestReply.decode(replyBody);
|
||||
this.updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async putInsects(friendGid, landIds) {
|
||||
const body = types.PutInsectsRequest.encode(types.PutInsectsRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.plantpb.PlantService', 'PutInsects', body);
|
||||
const reply = types.PutInsectsReply.decode(replyBody);
|
||||
this.updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async putWeeds(friendGid, landIds) {
|
||||
const body = types.PutWeedsRequest.encode(types.PutWeedsRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.plantpb.PlantService', 'PutWeeds', body);
|
||||
const reply = types.PutWeedsReply.decode(replyBody);
|
||||
this.updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
// ============ Logic ============
|
||||
|
||||
getCurrentPhase(phases) {
|
||||
if (!phases || phases.length === 0) return null;
|
||||
const nowSec = getServerTimeSec();
|
||||
for (let i = phases.length - 1; i >= 0; i--) {
|
||||
const beginTime = toTimeSec(phases[i].begin_time);
|
||||
if (beginTime > 0 && beginTime <= nowSec) {
|
||||
return phases[i];
|
||||
}
|
||||
}
|
||||
return phases[0];
|
||||
}
|
||||
|
||||
analyzeFriendLands(lands, myGid) {
|
||||
const result = {
|
||||
stealable: [], stealableInfo: [],
|
||||
needWater: [], needWeed: [], needBug: [],
|
||||
canPutWeed: [], canPutBug: [],
|
||||
};
|
||||
|
||||
const nowSec = getServerTimeSec();
|
||||
|
||||
for (const land of lands) {
|
||||
const id = toNum(land.id);
|
||||
const plant = land.plant;
|
||||
|
||||
if (!plant || !plant.phases || plant.phases.length === 0) continue;
|
||||
|
||||
const currentPhase = this.getCurrentPhase(plant.phases);
|
||||
if (!currentPhase) continue;
|
||||
const phaseVal = currentPhase.phase;
|
||||
|
||||
if (phaseVal === PlantPhase.MATURE) {
|
||||
if (plant.stealable) {
|
||||
result.stealable.push(id);
|
||||
const plantId = toNum(plant.id);
|
||||
const plantName = getPlantName(plantId) || plant.name || '未知';
|
||||
result.stealableInfo.push({ landId: id, plantId, name: plantName });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phaseVal === PlantPhase.DEAD) continue;
|
||||
|
||||
const dryNum = toNum(plant.dry_num);
|
||||
const dryTime = toTimeSec(currentPhase.dry_time);
|
||||
if (dryNum > 0 || (dryTime > 0 && dryTime <= nowSec)) result.needWater.push(id);
|
||||
|
||||
const weedsTime = toTimeSec(currentPhase.weeds_time);
|
||||
const hasWeeds = (plant.weed_owners && plant.weed_owners.length > 0) || (weedsTime > 0 && weedsTime <= nowSec);
|
||||
if (hasWeeds) result.needWeed.push(id);
|
||||
|
||||
const insectTime = toTimeSec(currentPhase.insect_time);
|
||||
const hasBugs = (plant.insect_owners && plant.insect_owners.length > 0) || (insectTime > 0 && insectTime <= nowSec);
|
||||
if (hasBugs) result.needBug.push(id);
|
||||
|
||||
const weedOwners = plant.weed_owners || [];
|
||||
const insectOwners = plant.insect_owners || [];
|
||||
const iAlreadyPutWeed = weedOwners.some(gid => toNum(gid) === myGid);
|
||||
const iAlreadyPutBug = insectOwners.some(gid => toNum(gid) === myGid);
|
||||
|
||||
if (weedOwners.length < 2 && !iAlreadyPutWeed) result.canPutWeed.push(id);
|
||||
if (insectOwners.length < 2 && !iAlreadyPutBug) result.canPutBug.push(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async visitFriend(friend, totalActions, myGid) {
|
||||
const { gid, name } = friend;
|
||||
|
||||
let enterReply;
|
||||
try {
|
||||
enterReply = await this.enterFriendFarm(gid);
|
||||
} catch (e) {
|
||||
this.bot.logWarn('好友', `进入 ${name} 农场失败: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lands = enterReply.lands || [];
|
||||
if (lands.length === 0) {
|
||||
await this.leaveFriendFarm(gid);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = this.analyzeFriendLands(lands, myGid);
|
||||
const actions = [];
|
||||
const HELP_ONLY_WITH_EXP = true;
|
||||
// Note: Logic copied from original friend.js
|
||||
|
||||
if (status.needWeed.length > 0) {
|
||||
const shouldHelp = !HELP_ONLY_WITH_EXP || this.canGetExp(10005);
|
||||
if (shouldHelp) {
|
||||
let ok = 0;
|
||||
for (const landId of status.needWeed) {
|
||||
try { await this.helpWeed(gid, [landId]); ok++; } catch (e) { }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`除草 ${ok} 块`); totalActions.weed += ok; }
|
||||
}
|
||||
}
|
||||
|
||||
if (status.needBug.length > 0) {
|
||||
const shouldHelp = !HELP_ONLY_WITH_EXP || this.canGetExp(10006);
|
||||
if (shouldHelp) {
|
||||
let ok = 0;
|
||||
for (const landId of status.needBug) {
|
||||
try { await this.helpInsecticide(gid, [landId]); ok++; } catch (e) { }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`除虫 ${ok} 块`); totalActions.bug += ok; }
|
||||
}
|
||||
}
|
||||
|
||||
if (status.needWater.length > 0) {
|
||||
const shouldHelp = !HELP_ONLY_WITH_EXP || this.canGetExp(10007);
|
||||
if (shouldHelp) {
|
||||
let ok = 0;
|
||||
for (const landId of status.needWater) {
|
||||
try { await this.helpWater(gid, [landId]); ok++; } catch (e) { }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`浇水 ${ok} 块`); totalActions.water += ok; }
|
||||
}
|
||||
}
|
||||
|
||||
const canSteal = !(this.bot.config && this.bot.config.enableSteal === false);
|
||||
if (canSteal && status.stealable.length > 0) {
|
||||
let ok = 0;
|
||||
const stolenPlants = [];
|
||||
for (let i = 0; i < status.stealable.length; i++) {
|
||||
const landId = status.stealable[i];
|
||||
try {
|
||||
await this.stealHarvest(gid, [landId]);
|
||||
ok++;
|
||||
if (status.stealableInfo[i]) {
|
||||
stolenPlants.push(status.stealableInfo[i].name);
|
||||
}
|
||||
} catch (e) { }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) {
|
||||
const plantNames = [...new Set(stolenPlants)].join('、');
|
||||
actions.push(`偷取 ${ok} 块${plantNames ? ' (' + plantNames + ')' : ''}`);
|
||||
totalActions.steal += ok;
|
||||
this.bot.emit('stealRecord', { name, count: ok });
|
||||
}
|
||||
}
|
||||
|
||||
const ENABLE_PUT_BAD_THINGS = false; // Copied from original
|
||||
|
||||
if (ENABLE_PUT_BAD_THINGS && status.canPutBug.length > 0 && this.canOperate(10004)) {
|
||||
let ok = 0;
|
||||
const remaining = this.getRemainingTimes(10004);
|
||||
const toProcess = status.canPutBug.slice(0, remaining);
|
||||
for (const landId of toProcess) {
|
||||
if (!this.canOperate(10004)) break;
|
||||
try { await this.putInsects(gid, [landId]); ok++; } catch (e) { }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`放虫 ${ok} 块`); totalActions.putBug += ok; }
|
||||
}
|
||||
|
||||
if (ENABLE_PUT_BAD_THINGS && status.canPutWeed.length > 0 && this.canOperate(10003)) {
|
||||
let ok = 0;
|
||||
const remaining = this.getRemainingTimes(10003);
|
||||
const toProcess = status.canPutWeed.slice(0, remaining);
|
||||
for (const landId of toProcess) {
|
||||
if (!this.canOperate(10003)) break;
|
||||
try { await this.putWeeds(gid, [landId]); ok++; } catch (e) { }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`放草 ${ok} 块`); totalActions.putWeed += ok; }
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
this.bot.log('好友', `[${name}] 执行: ${actions.join(',')}`);
|
||||
}
|
||||
|
||||
await this.leaveFriendFarm(gid);
|
||||
}
|
||||
|
||||
async check() {
|
||||
if (this.isCheckingFriends || !this.bot.user.gid) return;
|
||||
if (this.bot.config && this.bot.config.enableFriendOps === false) return;
|
||||
this.isCheckingFriends = true;
|
||||
this.checkDailyReset();
|
||||
|
||||
try {
|
||||
const friendsReply = await this.getAllFriends();
|
||||
const friends = friendsReply.game_friends || [];
|
||||
if (friends.length === 0) { this.bot.log('好友', '没有好友'); return; }
|
||||
|
||||
const canPutBugOrWeed = this.canOperate(10004) || this.canOperate(10003);
|
||||
const ENABLE_PUT_BAD_THINGS = false;
|
||||
|
||||
const priorityFriends = [];
|
||||
const otherFriends = [];
|
||||
const visitedGids = new Set();
|
||||
|
||||
for (const f of friends) {
|
||||
const gid = toNum(f.gid);
|
||||
if (gid === this.bot.user.gid) continue;
|
||||
if (visitedGids.has(gid)) continue;
|
||||
const name = f.remark || f.name || `GID:${gid}`;
|
||||
const p = f.plant;
|
||||
|
||||
const stealNum = p ? toNum(p.steal_plant_num) : 0;
|
||||
const dryNum = p ? toNum(p.dry_num) : 0;
|
||||
const weedNum = p ? toNum(p.weed_num) : 0;
|
||||
const insectNum = p ? toNum(p.insect_num) : 0;
|
||||
|
||||
if (stealNum > 0 || dryNum > 0 || weedNum > 0 || insectNum > 0) {
|
||||
priorityFriends.push({ gid, name });
|
||||
visitedGids.add(gid);
|
||||
} else if (ENABLE_PUT_BAD_THINGS && canPutBugOrWeed) {
|
||||
otherFriends.push({ gid, name });
|
||||
visitedGids.add(gid);
|
||||
}
|
||||
}
|
||||
|
||||
const friendsToVisit = [...priorityFriends, ...otherFriends];
|
||||
if (friendsToVisit.length === 0) return;
|
||||
|
||||
let totalActions = { steal: 0, water: 0, weed: 0, bug: 0, putBug: 0, putWeed: 0 };
|
||||
for (const friend of friendsToVisit) {
|
||||
try {
|
||||
await this.visitFriend(friend, totalActions, this.bot.user.gid);
|
||||
} catch (e) { }
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
const summary = [];
|
||||
if (totalActions.steal > 0) summary.push(`偷取 ${totalActions.steal} 块`);
|
||||
if (totalActions.weed > 0) summary.push(`除草 ${totalActions.weed} 块`);
|
||||
if (totalActions.bug > 0) summary.push(`除虫 ${totalActions.bug} 块`);
|
||||
if (totalActions.water > 0) summary.push(`浇水 ${totalActions.water} 块`);
|
||||
if (totalActions.putBug > 0) summary.push(`放虫 ${totalActions.putBug} 块`);
|
||||
if (totalActions.putWeed > 0) summary.push(`放草 ${totalActions.putWeed} 块`);
|
||||
|
||||
if (summary.length > 0) {
|
||||
this.bot.log('好友', `已巡查 ${friendsToVisit.length} 位好友。总计: ${summary.join(',')}`);
|
||||
}
|
||||
this.isFirstFriendCheck = false;
|
||||
} catch (err) {
|
||||
this.bot.logWarn('好友', `巡查失败: ${err.message}`);
|
||||
} finally {
|
||||
this.isCheckingFriends = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loop() {
|
||||
while (this.friendLoopRunning) {
|
||||
await this.check();
|
||||
if (!this.friendLoopRunning) break;
|
||||
await sleep(this.bot.config.friendCheckInterval);
|
||||
}
|
||||
}
|
||||
|
||||
startLoop() {
|
||||
if (this.friendLoopRunning) return;
|
||||
if (this.bot.config && this.bot.config.enableFriendOps === false) return;
|
||||
this.friendLoopRunning = true;
|
||||
|
||||
this.friendCheckTimer = setTimeout(() => this.loop(), 5000);
|
||||
setTimeout(() => this.checkAndAcceptApplications(), 3000);
|
||||
}
|
||||
|
||||
stopLoop() {
|
||||
this.friendLoopRunning = false;
|
||||
if (this.friendCheckTimer) { clearTimeout(this.friendCheckTimer); this.friendCheckTimer = null; }
|
||||
}
|
||||
|
||||
// ============ Applications ============
|
||||
|
||||
onFriendApplicationReceived(applications) {
|
||||
if (this.bot.config && this.bot.config.enableFriendOps === false) return;
|
||||
const names = applications.map(a => a.name || `GID:${toNum(a.gid)}`).join(', ');
|
||||
this.bot.log('申请', `收到 ${applications.length} 个好友申请: ${names}`);
|
||||
const gids = applications.map(a => toNum(a.gid));
|
||||
this.acceptFriendsWithRetry(gids);
|
||||
}
|
||||
|
||||
async checkAndAcceptApplications() {
|
||||
if (this.bot.config && this.bot.config.enableFriendOps === false) return;
|
||||
try {
|
||||
const reply = await this.getApplications();
|
||||
const applications = reply.applications || [];
|
||||
if (applications.length === 0) return;
|
||||
|
||||
const names = applications.map(a => a.name || `GID:${toNum(a.gid)}`).join(', ');
|
||||
this.bot.log('申请', `发现 ${applications.length} 个待处理申请: ${names}`);
|
||||
|
||||
const gids = applications.map(a => toNum(a.gid));
|
||||
await this.acceptFriendsWithRetry(gids);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
async acceptFriendsWithRetry(gids) {
|
||||
if (gids.length === 0) return;
|
||||
try {
|
||||
const reply = await this.acceptFriends(gids);
|
||||
const friends = reply.friends || [];
|
||||
if (friends.length > 0) {
|
||||
const names = friends.map(f => f.name || f.remark || `GID:${toNum(f.gid)}`).join(', ');
|
||||
this.bot.log('申请', `已同意 ${friends.length} 人: ${names}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.bot.logWarn('申请', `同意失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { FriendManager };
|
||||
541
211/server/src/core/Network.js
Normal file
@@ -0,0 +1,541 @@
|
||||
const WebSocket = require('ws');
|
||||
const EventEmitter = require('events');
|
||||
const protobuf = require('protobufjs');
|
||||
const { types } = require('../proto');
|
||||
const { toLong, toNum, log, logWarn, syncServerTime } = require('../utils');
|
||||
|
||||
function calcNormalFertilizerHours(containerCount) {
|
||||
const containerNum = Number(containerCount) || 0;
|
||||
return containerNum >= 3600 ? containerNum / 3600 : containerNum;
|
||||
}
|
||||
|
||||
function calcOrganicFertilizerHours(containerCount) {
|
||||
const containerNum = Number(containerCount) || 0;
|
||||
return containerNum >= 3600 ? containerNum / 3600 : containerNum;
|
||||
}
|
||||
|
||||
class NetworkClient extends EventEmitter {
|
||||
constructor(bot) {
|
||||
super();
|
||||
this.bot = bot;
|
||||
this.ws = null;
|
||||
this.clientSeq = 1;
|
||||
this.serverSeq = 0;
|
||||
this.heartbeatTimer = null;
|
||||
this.pendingCallbacks = new Map();
|
||||
this.connected = false;
|
||||
this.shareClaiming = false;
|
||||
this.lastShareClaimAt = 0;
|
||||
|
||||
// Heartbeat state
|
||||
this.lastHeartbeatResponse = 0;
|
||||
this.heartbeatMissCount = 0;
|
||||
}
|
||||
|
||||
connect(callback) {
|
||||
const code = encodeURIComponent(this.bot.config.code || '');
|
||||
const url = `${this.bot.config.serverUrl}?platform=${this.bot.config.platform}&os=${this.bot.config.os}&ver=${this.bot.config.clientVersion}&code=${code}&openID=`;
|
||||
|
||||
this.bot.log('系统', `连接服务器... ${this.bot.config.platform}`);
|
||||
|
||||
this.ws = new WebSocket(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090a13)',
|
||||
'Origin': 'https://gate-obt.nqf.qq.com',
|
||||
},
|
||||
});
|
||||
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.on('open', () => {
|
||||
this.bot.log('系统', 'WebSocket 连接成功');
|
||||
this.connected = true;
|
||||
if (callback) callback(true);
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
this.handleMessage(data);
|
||||
});
|
||||
|
||||
this.ws.on('close', (code, reason) => {
|
||||
this.bot.log('系统', `WebSocket 连接断开 (code=${code})`);
|
||||
this.connected = false;
|
||||
this.stopHeartbeat();
|
||||
this.cleanup();
|
||||
this.emit('disconnected');
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
this.bot.logWarn('系统', `WebSocket 错误: ${err.message}`);
|
||||
this.emit('error', err);
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
this.pendingCallbacks.clear();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||
this.lastHeartbeatResponse = Date.now();
|
||||
this.heartbeatMissCount = 0;
|
||||
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (!this.bot.user.gid) return;
|
||||
|
||||
const timeSinceLastResponse = Date.now() - this.lastHeartbeatResponse;
|
||||
if (timeSinceLastResponse > 60000) {
|
||||
this.heartbeatMissCount++;
|
||||
this.bot.logWarn('心跳', `连接可能已断开 (${Math.round(timeSinceLastResponse/1000)}s 无响应)`);
|
||||
if (this.heartbeatMissCount >= 2) {
|
||||
this.bot.log('心跳', '尝试重连...');
|
||||
// Cleanup pending callbacks to avoid leaks
|
||||
this.pendingCallbacks.forEach((cb, seq) => {
|
||||
try { cb(new Error('连接超时,已清理')); } catch (e) {}
|
||||
});
|
||||
this.pendingCallbacks.clear();
|
||||
// Optional: trigger reconnect logic here or let the user handle it
|
||||
}
|
||||
}
|
||||
|
||||
const body = types.HeartbeatRequest.encode(types.HeartbeatRequest.create({
|
||||
gid: toLong(this.bot.user.gid),
|
||||
client_version: this.bot.config.clientVersion,
|
||||
})).finish();
|
||||
|
||||
this.sendMsg('gamepb.userpb.UserService', 'Heartbeat', body, (err, replyBody) => {
|
||||
if (err || !replyBody) return;
|
||||
this.lastHeartbeatResponse = Date.now();
|
||||
this.heartbeatMissCount = 0;
|
||||
try {
|
||||
const reply = types.HeartbeatReply.decode(replyBody);
|
||||
if (reply.server_time) {
|
||||
// We still use the global syncServerTime for now as it's just a time offset
|
||||
syncServerTime(toNum(reply.server_time));
|
||||
}
|
||||
} catch (e) { }
|
||||
});
|
||||
|
||||
}, this.bot.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
sendLogin(callback) {
|
||||
const body = types.LoginRequest.encode(types.LoginRequest.create({
|
||||
sharer_id: toLong(0),
|
||||
sharer_open_id: '',
|
||||
device_info: {
|
||||
client_version: this.bot.config.clientVersion,
|
||||
sys_software: 'iOS 26.2.1',
|
||||
network: 'wifi',
|
||||
memory: '7672',
|
||||
device_id: 'iPhone X<iPhone18,3>',
|
||||
},
|
||||
share_cfg_id: toLong(0),
|
||||
scene_id: '1256',
|
||||
report_data: {
|
||||
callback: '', cd_extend_info: '', click_id: '', clue_token: '',
|
||||
minigame_channel: 'other', minigame_platid: 2, req_id: '', trackid: '',
|
||||
},
|
||||
})).finish();
|
||||
|
||||
this.sendMsg('gamepb.userpb.UserService', 'Login', body, (err, bodyBytes, meta) => {
|
||||
if (err) {
|
||||
this.bot.log('登录', `失败: ${err.message}`);
|
||||
if (callback) callback(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const reply = types.LoginReply.decode(bodyBytes);
|
||||
if (reply.basic) {
|
||||
const basic = reply.basic;
|
||||
this.bot.user.gid = toNum(basic.gid);
|
||||
this.bot.user.name = basic.name || '未知';
|
||||
this.bot.user.level = toNum(basic.level);
|
||||
this.bot.user.gold = toNum(basic.gold);
|
||||
this.bot.user.exp = toNum(basic.exp);
|
||||
this.bot.user.avatarUrl = basic.avatar_url;
|
||||
|
||||
this.bot.log('系统', `登录成功: ${this.bot.user.name} (Lv${this.bot.user.level})`);
|
||||
|
||||
if (reply.time_now_millis) {
|
||||
syncServerTime(toNum(reply.time_now_millis));
|
||||
}
|
||||
}
|
||||
|
||||
this.startHeartbeat();
|
||||
this.bot.emit('loginSuccess', this.bot.user);
|
||||
this.tryClaimShareReward();
|
||||
if (this.bot.warehouseManager) {
|
||||
this.bot.warehouseManager.getBag().then((bagReply) => {
|
||||
const items = this.bot.warehouseManager.getBagItems(bagReply);
|
||||
let tickets = 0;
|
||||
let fertilizerContainer = 0;
|
||||
let organicFertilizerContainer = 0;
|
||||
const fertilizerItems = {
|
||||
80001: 0,
|
||||
80002: 0,
|
||||
80003: 0,
|
||||
80004: 0,
|
||||
};
|
||||
const organicFertilizerItems = {
|
||||
80011: 0,
|
||||
80012: 0,
|
||||
80013: 0,
|
||||
80014: 0,
|
||||
};
|
||||
for (const item of items) {
|
||||
const id = toNum(item.id);
|
||||
const count = toNum(item.count);
|
||||
if (id === 1002) tickets = count;
|
||||
if (id === 1011) fertilizerContainer = count;
|
||||
if (id === 1012) organicFertilizerContainer = count;
|
||||
if (id === 80001) fertilizerItems[80001] = count;
|
||||
if (id === 80002) fertilizerItems[80002] = count;
|
||||
if (id === 80003) fertilizerItems[80003] = count;
|
||||
if (id === 80004) fertilizerItems[80004] = count;
|
||||
if (id === 80011) organicFertilizerItems[80011] = count;
|
||||
if (id === 80012) organicFertilizerItems[80012] = count;
|
||||
if (id === 80013) organicFertilizerItems[80013] = count;
|
||||
if (id === 80014) organicFertilizerItems[80014] = count;
|
||||
}
|
||||
this.bot.user.tickets = tickets;
|
||||
this.bot.user.fertilizerContainer = fertilizerContainer;
|
||||
this.bot.user.fertilizerItems = fertilizerItems;
|
||||
this.bot.user.fertilizerHours = calcNormalFertilizerHours(fertilizerContainer);
|
||||
this.bot.user.organicFertilizerContainer = organicFertilizerContainer;
|
||||
this.bot.user.organicFertilizerItems = organicFertilizerItems;
|
||||
this.bot.user.organicFertilizerHours = calcOrganicFertilizerHours(organicFertilizerContainer);
|
||||
this.bot.emit('userUpdate', this.bot.user);
|
||||
}).catch(() => { });
|
||||
}
|
||||
if (callback) callback(true);
|
||||
} catch (e) {
|
||||
this.bot.log('登录', `解码失败: ${e.message}`);
|
||||
if (callback) callback(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async tryClaimShareReward(shareId = 1) {
|
||||
if (this.shareClaiming) return;
|
||||
const now = Date.now();
|
||||
if (now - this.lastShareClaimAt < 60000) return;
|
||||
this.shareClaiming = true;
|
||||
this.lastShareClaimAt = now;
|
||||
try {
|
||||
const canShare = await this.checkCanShareReward();
|
||||
if (!canShare) return;
|
||||
await this.reportShare(shareId);
|
||||
await this.claimShareReward(shareId);
|
||||
this.bot.log('分享', '已领取分享奖励');
|
||||
} catch (e) {
|
||||
const message = this.formatShareClaimError(e);
|
||||
this.bot.logWarn('分享', message);
|
||||
} finally {
|
||||
this.shareClaiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
formatShareClaimError(error) {
|
||||
const raw = error?.message ? String(error.message) : '';
|
||||
if (!raw) return '分享奖励领取失败';
|
||||
if (raw.includes('code=1009001') || raw.includes('已经领取')) {
|
||||
return '分享奖励已领取,无需重复领取';
|
||||
}
|
||||
return `领取失败: ${raw}`;
|
||||
}
|
||||
|
||||
decodeShareFlag(replyBody) {
|
||||
if (!replyBody || replyBody.length === 0) return 0;
|
||||
const reader = protobuf.Reader.create(replyBody);
|
||||
let flag = 0;
|
||||
while (reader.pos < reader.len) {
|
||||
const tag = reader.uint32();
|
||||
const field = tag >>> 3;
|
||||
if (field === 1) {
|
||||
flag = Number(reader.int64());
|
||||
} else {
|
||||
reader.skipType(tag & 7);
|
||||
}
|
||||
}
|
||||
return flag;
|
||||
}
|
||||
|
||||
async checkCanShareReward() {
|
||||
const { body: replyBody } = await this.sendMsgAsync('gamepb.sharepb.ShareService', 'CheckCanShare', Buffer.alloc(0));
|
||||
const flag = this.decodeShareFlag(replyBody);
|
||||
return flag === 1;
|
||||
}
|
||||
|
||||
async reportShare(shareId) {
|
||||
const writer = protobuf.Writer.create();
|
||||
writer.uint32(8).int64(toLong(shareId));
|
||||
const body = writer.finish();
|
||||
await this.sendMsgAsync('gamepb.sharepb.ShareService', 'ReportShare', body);
|
||||
}
|
||||
|
||||
async claimShareReward(shareId) {
|
||||
const writer = protobuf.Writer.create();
|
||||
writer.uint32(8).int64(toLong(shareId));
|
||||
const body = writer.finish();
|
||||
await this.sendMsgAsync('gamepb.sharepb.ShareService', 'ClaimShareReward', body);
|
||||
}
|
||||
|
||||
encodeMsg(serviceName, methodName, bodyBytes) {
|
||||
const msg = types.GateMessage.create({
|
||||
meta: {
|
||||
service_name: serviceName,
|
||||
method_name: methodName,
|
||||
message_type: 1,
|
||||
client_seq: toLong(this.clientSeq),
|
||||
server_seq: toLong(this.serverSeq),
|
||||
},
|
||||
body: bodyBytes || Buffer.alloc(0),
|
||||
});
|
||||
const encoded = types.GateMessage.encode(msg).finish();
|
||||
this.clientSeq++;
|
||||
return encoded;
|
||||
}
|
||||
|
||||
sendMsg(serviceName, methodName, bodyBytes, callback) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
const seq = this.clientSeq;
|
||||
const encoded = this.encodeMsg(serviceName, methodName, bodyBytes);
|
||||
if (callback) this.pendingCallbacks.set(seq, callback);
|
||||
this.ws.send(encoded);
|
||||
return true;
|
||||
}
|
||||
|
||||
sendMsgAsync(serviceName, methodName, bodyBytes, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error(`连接未打开: ${methodName}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = this.clientSeq;
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingCallbacks.delete(seq);
|
||||
reject(new Error(`请求超时: ${methodName}`));
|
||||
}, timeout);
|
||||
|
||||
const sent = this.sendMsg(serviceName, methodName, bodyBytes, (err, body, meta) => {
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve({ body, meta });
|
||||
});
|
||||
|
||||
if (!sent) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`发送失败: ${methodName}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
try {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
const msg = types.GateMessage.decode(buf);
|
||||
const meta = msg.meta;
|
||||
if (!meta) return;
|
||||
|
||||
if (meta.server_seq) {
|
||||
const seq = toNum(meta.server_seq);
|
||||
if (seq > this.serverSeq) this.serverSeq = seq;
|
||||
}
|
||||
|
||||
const msgType = meta.message_type;
|
||||
|
||||
// Notify
|
||||
if (msgType === 3) {
|
||||
this.handleNotify(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Response
|
||||
if (msgType === 2) {
|
||||
const errorCode = toNum(meta.error_code);
|
||||
const clientSeqVal = toNum(meta.client_seq);
|
||||
|
||||
const cb = this.pendingCallbacks.get(clientSeqVal);
|
||||
if (cb) {
|
||||
this.pendingCallbacks.delete(clientSeqVal);
|
||||
if (errorCode !== 0) {
|
||||
cb(new Error(`${meta.service_name}.${meta.method_name} 错误: code=${errorCode} ${meta.error_message || ''}`));
|
||||
} else {
|
||||
cb(null, msg.body, meta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.bot.logWarn('解码', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
handleNotify(msg) {
|
||||
if (!msg.body || msg.body.length === 0) return;
|
||||
try {
|
||||
const event = types.EventMessage.decode(msg.body);
|
||||
const type = event.message_type || '';
|
||||
const eventBody = event.body;
|
||||
|
||||
if (type.includes('Kickout')) {
|
||||
this.bot.logWarn('推送', `被踢下线! ${type}`);
|
||||
this.bot.emit('kickout', type);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.includes('LandsNotify')) {
|
||||
const notify = types.LandsNotify.decode(eventBody);
|
||||
const hostGid = toNum(notify.host_gid);
|
||||
const lands = notify.lands || [];
|
||||
if (lands.length > 0) {
|
||||
if (hostGid === this.bot.user.gid || hostGid === 0) {
|
||||
this.bot.emit('landsChanged', lands);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.includes('BasicNotify')) {
|
||||
try {
|
||||
const notify = types.BasicNotify.decode(eventBody);
|
||||
if (notify.basic) {
|
||||
const oldLevel = this.bot.user.level;
|
||||
this.bot.user.level = toNum(notify.basic.level) || this.bot.user.level;
|
||||
this.bot.user.gold = toNum(notify.basic.gold) || this.bot.user.gold;
|
||||
const exp = toNum(notify.basic.exp);
|
||||
if (exp > 0) this.bot.user.exp = exp;
|
||||
|
||||
if (this.bot.user.level !== oldLevel) {
|
||||
this.bot.log('系统', `升级! Lv${oldLevel} → Lv${this.bot.user.level}`);
|
||||
}
|
||||
this.bot.emit('userUpdate', this.bot.user);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.includes('FriendApplicationReceivedNotify')) {
|
||||
try {
|
||||
const notify = types.FriendApplicationReceivedNotify.decode(eventBody);
|
||||
const applications = notify.applications || [];
|
||||
if (applications.length > 0) {
|
||||
this.bot.emit('friendApplicationReceived', applications);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.includes('FriendAddedNotify')) {
|
||||
try {
|
||||
const notify = types.FriendAddedNotify.decode(eventBody);
|
||||
const friends = notify.friends || [];
|
||||
if (friends.length > 0) {
|
||||
const names = friends.map(f => f.name || f.remark || `GID:${toNum(f.gid)}`).join(', ');
|
||||
this.bot.log('好友', `新好友: ${names}`);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.includes('ItemNotify')) {
|
||||
const notify = types.ItemNotify.decode(eventBody);
|
||||
const items = notify.items || [];
|
||||
for (const itemChg of items) {
|
||||
if (!itemChg.item) continue;
|
||||
const id = toNum(itemChg.item.id);
|
||||
const count = toNum(itemChg.item.count);
|
||||
const delta = toNum(itemChg.delta);
|
||||
if (id === 1 || id === 1001) {
|
||||
this.bot.user.gold = count;
|
||||
if (delta !== 0) {
|
||||
this.bot.log('物品', `金币 ${delta > 0 ? '+' : ''}${delta} (当前: ${count})`);
|
||||
}
|
||||
this.bot.emit('userUpdate', this.bot.user);
|
||||
continue;
|
||||
}
|
||||
if (id === 1002) {
|
||||
this.bot.user.tickets = count;
|
||||
this.bot.emit('userUpdate', this.bot.user);
|
||||
continue;
|
||||
}
|
||||
if (id === 1011) {
|
||||
this.bot.user.fertilizerContainer = count;
|
||||
this.bot.user.fertilizerHours = calcNormalFertilizerHours(this.bot.user.fertilizerContainer);
|
||||
this.bot.emit('userUpdate', this.bot.user);
|
||||
continue;
|
||||
}
|
||||
if (id === 1012) {
|
||||
this.bot.user.organicFertilizerContainer = count;
|
||||
this.bot.user.organicFertilizerHours = calcOrganicFertilizerHours(this.bot.user.organicFertilizerContainer);
|
||||
this.bot.emit('userUpdate', this.bot.user);
|
||||
continue;
|
||||
}
|
||||
if (id === 80001 || id === 80002 || id === 80003 || id === 80004) {
|
||||
if (!this.bot.user.fertilizerItems) {
|
||||
this.bot.user.fertilizerItems = { 80001: 0, 80002: 0, 80003: 0, 80004: 0 };
|
||||
}
|
||||
this.bot.user.fertilizerItems[id] = count;
|
||||
this.bot.user.fertilizerHours = calcNormalFertilizerHours(this.bot.user.fertilizerContainer);
|
||||
this.bot.emit('userUpdate', this.bot.user);
|
||||
}
|
||||
if (id === 80011 || id === 80012 || id === 80013 || id === 80014) {
|
||||
if (!this.bot.user.organicFertilizerItems) {
|
||||
this.bot.user.organicFertilizerItems = { 80011: 0, 80012: 0, 80013: 0, 80014: 0 };
|
||||
}
|
||||
this.bot.user.organicFertilizerItems[id] = count;
|
||||
this.bot.user.organicFertilizerHours = calcOrganicFertilizerHours(this.bot.user.organicFertilizerContainer);
|
||||
this.bot.emit('userUpdate', this.bot.user);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.includes('GoodsUnlockNotify')) {
|
||||
try {
|
||||
const notify = types.GoodsUnlockNotify.decode(eventBody);
|
||||
const goods = notify.goods_list || [];
|
||||
if (goods.length > 0) {
|
||||
this.bot.log('商店', `解锁 ${goods.length} 个新商品!`);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.includes('TaskInfoNotify')) {
|
||||
try {
|
||||
const notify = types.TaskInfoNotify.decode(eventBody);
|
||||
if (notify.task_info) {
|
||||
this.bot.emit('taskInfoNotify', notify.task_info);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { NetworkClient };
|
||||
80
211/server/src/core/ShopManager.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const protobuf = require('protobufjs');
|
||||
const { types } = require('../proto');
|
||||
const { toLong, toNum, log } = require('../utils');
|
||||
const { getPlantNameBySeedId, getItemName } = require('../gameConfig');
|
||||
|
||||
class ShopManager {
|
||||
constructor(bot) {
|
||||
this.bot = bot;
|
||||
}
|
||||
|
||||
async getShopProfiles() {
|
||||
const body = types.ShopProfilesRequest.encode(types.ShopProfilesRequest.create({})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.shoppb.ShopService', 'ShopProfiles', body);
|
||||
return types.ShopProfilesReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async getShopInfo(shopId) {
|
||||
const body = types.ShopInfoRequest.encode(types.ShopInfoRequest.create({
|
||||
shop_id: toLong(shopId)
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.shoppb.ShopService', 'ShopInfo', body);
|
||||
return types.ShopInfoReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async buyGoods(goodsId, count, price) {
|
||||
const body = types.BuyGoodsRequest.encode(types.BuyGoodsRequest.create({
|
||||
goods_id: toLong(goodsId),
|
||||
num: toLong(count),
|
||||
price: toLong(price)
|
||||
})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.shoppb.ShopService', 'BuyGoods', body);
|
||||
return types.BuyGoodsReply.decode(replyBody);
|
||||
}
|
||||
|
||||
buildMallPurchaseBody(itemId, count) {
|
||||
const writer = protobuf.Writer.create();
|
||||
writer.uint32(8).int64(toLong(itemId));
|
||||
writer.uint32(16).int64(toLong(count));
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
async purchaseMallItem(itemId, count) {
|
||||
const body = this.buildMallPurchaseBody(itemId, count);
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.mallpb.MallService', 'Purchase', body);
|
||||
return replyBody;
|
||||
}
|
||||
|
||||
async getSeedShopList() {
|
||||
// 1. 获取商店列表,找到种子商店 (type=2)
|
||||
const profilesReply = await this.getShopProfiles();
|
||||
const profiles = profilesReply.shop_profiles || [];
|
||||
const seedShop = profiles.find(p => p.shop_type === 2); // 2 usually means Seed Shop
|
||||
|
||||
if (!seedShop) {
|
||||
throw new Error('未找到种子商店');
|
||||
}
|
||||
|
||||
// 2. 获取商品列表
|
||||
const infoReply = await this.getShopInfo(seedShop.shop_id);
|
||||
const goodsList = infoReply.goods_list || [];
|
||||
|
||||
// 3. 格式化数据
|
||||
return goodsList.map(g => {
|
||||
const itemId = toNum(g.item_id);
|
||||
const name = getPlantNameBySeedId(itemId) || getItemName(itemId) || `商品${g.id}`;
|
||||
return {
|
||||
goodsId: toNum(g.id),
|
||||
itemId: itemId,
|
||||
name: name,
|
||||
price: toNum(g.price),
|
||||
limitCount: toNum(g.limit_count),
|
||||
boughtNum: toNum(g.bought_num),
|
||||
unlocked: g.unlocked,
|
||||
itemCount: toNum(g.item_count)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ShopManager };
|
||||
322
211/server/src/core/WarehouseManager.js
Normal file
@@ -0,0 +1,322 @@
|
||||
const protobuf = require('protobufjs');
|
||||
const { types } = require('../proto');
|
||||
const { toLong, toNum, log, logWarn, emitRuntimeHint } = require('../utils');
|
||||
const { getFruitName, getPlantNameBySeedId, getItemNameById } = require('../gameConfig');
|
||||
const seedShopData = require('../../tools/seed-shop-merged-export.json');
|
||||
|
||||
// 游戏内金币和点券的物品 ID (GlobalData.GodItemId / DiamondItemId)
|
||||
const GOLD_ITEM_ID = 1001;
|
||||
const FRUIT_ID_SET = new Set(
|
||||
((seedShopData && seedShopData.rows) || [])
|
||||
.map(row => Number(row.fruitId))
|
||||
.filter(Number.isFinite)
|
||||
);
|
||||
|
||||
class WarehouseManager {
|
||||
constructor(bot) {
|
||||
this.bot = bot;
|
||||
this.sellTimer = null;
|
||||
this.sellInterval = 60000;
|
||||
}
|
||||
|
||||
isFruitIdBySeedData(id) {
|
||||
return FRUIT_ID_SET.has(toNum(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SellReply 中提取获得的金币数量
|
||||
* 新版 SellReply 返回 get_items (repeated Item),其中 id=1001 为金币
|
||||
*/
|
||||
extractGold(sellReply) {
|
||||
if (sellReply.get_items && sellReply.get_items.length > 0) {
|
||||
for (const item of sellReply.get_items) {
|
||||
const id = toNum(item.id);
|
||||
if (id === GOLD_ITEM_ID) {
|
||||
return toNum(item.count);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (sellReply.gold !== undefined && sellReply.gold !== null) {
|
||||
return toNum(sellReply.gold);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async getBag() {
|
||||
const body = types.BagRequest.encode(types.BagRequest.create({})).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.itempb.ItemService', 'Bag', body);
|
||||
return types.BagReply.decode(replyBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 item 转为 Sell 请求所需格式(id/count/uid 保留 Long 或转成 Long,与游戏一致)
|
||||
*/
|
||||
toSellItem(item) {
|
||||
const id = item.id != null ? toLong(item.id) : undefined;
|
||||
const count = item.count != null ? toLong(item.count) : undefined;
|
||||
const uid = item.uid != null ? toLong(item.uid) : undefined;
|
||||
return { id, count, uid };
|
||||
}
|
||||
|
||||
async sellItems(items) {
|
||||
const payload = items.map(this.toSellItem);
|
||||
const body = types.SellRequest.encode(types.SellRequest.create({ items: payload })).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.itempb.ItemService', 'Sell', body);
|
||||
return types.SellReply.decode(replyBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 BagReply 取出物品列表(兼容 item_bag 与旧版 items)
|
||||
*/
|
||||
getBagItems(bagReply) {
|
||||
if (bagReply.item_bag && bagReply.item_bag.items && bagReply.item_bag.items.length)
|
||||
return bagReply.item_bag.items;
|
||||
return bagReply.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取格式化的背包数据,用于前端显示
|
||||
*/
|
||||
async getFormattedBag() {
|
||||
const bagReply = await this.getBag();
|
||||
const items = this.getBagItems(bagReply);
|
||||
|
||||
const seeds = [];
|
||||
const produce = [];
|
||||
const others = [];
|
||||
|
||||
for (const item of items) {
|
||||
const id = toNum(item.id);
|
||||
const count = toNum(item.count);
|
||||
const uid = item.uid ? toNum(item.uid) : 0;
|
||||
|
||||
// Basic item object
|
||||
const itemObj = {
|
||||
id,
|
||||
uid,
|
||||
count,
|
||||
name: `物品 ${id}`,
|
||||
type: 'other'
|
||||
};
|
||||
|
||||
if (this.isFruitIdBySeedData(id) || id === 40416) {
|
||||
itemObj.name = getFruitName(id);
|
||||
itemObj.type = 'produce';
|
||||
produce.push(itemObj);
|
||||
} else {
|
||||
// 尝试识别为种子
|
||||
const plantName = getPlantNameBySeedId(id);
|
||||
if (plantName && plantName !== `种子${id}`) {
|
||||
itemObj.name = plantName + '种子';
|
||||
itemObj.type = 'seed';
|
||||
seeds.push(itemObj);
|
||||
} else {
|
||||
// 过滤掉货币类物品和特殊物品
|
||||
// 1001:金币, 1002:点券, 1101:种植经验, 3001:普通收藏点, 1011/1012:化肥容器
|
||||
if ([1001, 1002, 1101, 3001, 1011, 1012].includes(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 其他物品
|
||||
itemObj.name = getItemNameById(id);
|
||||
others.push(itemObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { seeds, produce, others };
|
||||
}
|
||||
|
||||
async sellAllFruits() {
|
||||
if (!this.bot.network.connected) return;
|
||||
|
||||
try {
|
||||
const bagReply = await this.getBag();
|
||||
const items = this.getBagItems(bagReply);
|
||||
|
||||
const toSell = [];
|
||||
const names = [];
|
||||
for (const item of items) {
|
||||
const id = toNum(item.id);
|
||||
const count = toNum(item.count);
|
||||
const uid = item.uid ? toNum(item.uid) : 0;
|
||||
if (this.isFruitIdBySeedData(id) && count > 0) {
|
||||
if (uid === 0) continue; // 跳过无效格子
|
||||
toSell.push(item);
|
||||
names.push(`${getFruitName(id)} ${count} 个`);
|
||||
}
|
||||
}
|
||||
|
||||
if (toSell.length === 0) return;
|
||||
|
||||
const reply = await this.sellItems(toSell);
|
||||
const totalGold = this.extractGold(reply);
|
||||
log('仓库', `成功出售: ${names.join(',')}。共获得 ${totalGold} 金币`);
|
||||
emitRuntimeHint(false);
|
||||
} catch (e) {
|
||||
logWarn('仓库', `物品出售失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async useItems(items) {
|
||||
if (!items || items.length === 0) return { success: true, message: '没有选择物品' };
|
||||
|
||||
const results = [];
|
||||
const fertilizers = new Set([80001, 80002, 80003, 80004, 80011, 80012, 80013, 80014]);
|
||||
const formatGains = (replyItems, excludeId) => {
|
||||
if (!replyItems || replyItems.length === 0) return '';
|
||||
const parts = [];
|
||||
for (const replyItem of replyItems) {
|
||||
const gainId = toNum(replyItem.id);
|
||||
const gainCount = toNum(replyItem.count);
|
||||
if (excludeId && gainId === excludeId) continue;
|
||||
if (!gainId || !gainCount) continue;
|
||||
parts.push(`${getItemNameById(gainId)} x${gainCount}`);
|
||||
}
|
||||
return parts.join(',');
|
||||
};
|
||||
const buildGainList = (replyItems, excludeId) => {
|
||||
if (!replyItems || replyItems.length === 0) return [];
|
||||
const gains = [];
|
||||
for (const replyItem of replyItems) {
|
||||
const gainId = toNum(replyItem.id);
|
||||
const gainCount = toNum(replyItem.count);
|
||||
if (excludeId && gainId === excludeId) continue;
|
||||
if (!gainId || !gainCount) continue;
|
||||
gains.push({ id: gainId, count: gainCount });
|
||||
}
|
||||
return gains;
|
||||
};
|
||||
const buildItemCountMap = (bagItems) => {
|
||||
const map = new Map();
|
||||
for (const bagItem of bagItems) {
|
||||
const bagId = toNum(bagItem.id);
|
||||
const bagCount = toNum(bagItem.count);
|
||||
if (!bagId || !bagCount) continue;
|
||||
map.set(bagId, (map.get(bagId) || 0) + bagCount);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const diffGains = (beforeMap, afterMap, excludeId) => {
|
||||
const parts = [];
|
||||
for (const [id, afterCount] of afterMap.entries()) {
|
||||
if (excludeId && id === excludeId) continue;
|
||||
const beforeCount = beforeMap.get(id) || 0;
|
||||
const delta = afterCount - beforeCount;
|
||||
if (delta > 0) {
|
||||
parts.push(`${getItemNameById(id)} x${delta}`);
|
||||
}
|
||||
}
|
||||
return parts.join(',');
|
||||
};
|
||||
const diffGainList = (beforeMap, afterMap, excludeId) => {
|
||||
const gains = [];
|
||||
for (const [id, afterCount] of afterMap.entries()) {
|
||||
if (excludeId && id === excludeId) continue;
|
||||
const beforeCount = beforeMap.get(id) || 0;
|
||||
const delta = afterCount - beforeCount;
|
||||
if (delta > 0) gains.push({ id, count: delta });
|
||||
}
|
||||
return gains;
|
||||
};
|
||||
const mergeGains = (gainMap, gains) => {
|
||||
for (const gain of gains) {
|
||||
if (!gain || !gain.id || !gain.count) continue;
|
||||
gainMap.set(gain.id, (gainMap.get(gain.id) || 0) + gain.count);
|
||||
}
|
||||
};
|
||||
const totalGains = new Map();
|
||||
|
||||
for (const item of items) {
|
||||
const id = Number(item.id);
|
||||
const count = Number(item.count);
|
||||
let uid = item.uid != null ? Number(item.uid) : 0;
|
||||
const name = getItemNameById(id);
|
||||
|
||||
try {
|
||||
let usedCount = count;
|
||||
let gainsText = '';
|
||||
let gainList = [];
|
||||
if (id === 100003) {
|
||||
const beforeReply = await this.getBag();
|
||||
const beforeItems = this.getBagItems(beforeReply);
|
||||
const beforeMap = buildItemCountMap(beforeItems);
|
||||
if (!uid) {
|
||||
const bagReply = await this.getBag();
|
||||
const bagItems = this.getBagItems(bagReply);
|
||||
const found = bagItems.find(bagItem => toNum(bagItem.id) === id && toNum(bagItem.uid) > 0);
|
||||
if (found) uid = toNum(found.uid);
|
||||
}
|
||||
if (!uid) {
|
||||
throw new Error('礼包缺少UID');
|
||||
}
|
||||
const itemWriter = protobuf.Writer.create();
|
||||
itemWriter.uint32(8).int64(toLong(id));
|
||||
itemWriter.uint32(16).int64(toLong(count));
|
||||
itemWriter.uint32(48).int64(toLong(uid));
|
||||
const body = protobuf.Writer.create().uint32(10).bytes(itemWriter.finish()).finish();
|
||||
await this.bot.network.sendMsgAsync('gamepb.itempb.ItemService', 'Use', body);
|
||||
const afterReply = await this.getBag();
|
||||
const afterItems = this.getBagItems(afterReply);
|
||||
const afterMap = buildItemCountMap(afterItems);
|
||||
gainsText = diffGains(beforeMap, afterMap, id);
|
||||
gainList = diffGainList(beforeMap, afterMap, id);
|
||||
} else {
|
||||
const payload = {
|
||||
items: [{
|
||||
item_id: toLong(id),
|
||||
count: toLong(count),
|
||||
land_count: toLong(1)
|
||||
}]
|
||||
};
|
||||
const body = types.BatchUseRequest.encode(types.BatchUseRequest.create(payload)).finish();
|
||||
const { body: replyBody } = await this.bot.network.sendMsgAsync('gamepb.itempb.ItemService', 'BatchUse', body);
|
||||
const reply = types.BatchUseReply.decode(replyBody);
|
||||
gainsText = formatGains(reply.items, id);
|
||||
gainList = buildGainList(reply.items, id);
|
||||
}
|
||||
|
||||
if (fertilizers.has(id)) {
|
||||
results.push(`${name}: 已使用(消耗 ${usedCount} 个)${gainsText ? `,获得:${gainsText}` : ''}`);
|
||||
} else {
|
||||
results.push(`${name}: 已使用${gainsText ? `,获得:${gainsText}` : ''}`);
|
||||
}
|
||||
if (gainsText) {
|
||||
log('仓库', `使用 ${name} 获得:${gainsText}`);
|
||||
}
|
||||
mergeGains(totalGains, gainList);
|
||||
|
||||
} catch (e) {
|
||||
logWarn('仓库', `物品使用失败 (${name}): ${e.message}`);
|
||||
results.push(`${name}: 使用失败 - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const gains = [];
|
||||
for (const [id, count] of totalGains.entries()) {
|
||||
gains.push({ id, name: getItemNameById(id), count });
|
||||
}
|
||||
return { success: true, message: results.join('; '), gains };
|
||||
}
|
||||
|
||||
startLoop(interval = 60000) {
|
||||
if (this.sellTimer) return;
|
||||
this.sellInterval = interval;
|
||||
// 延迟启动,避免刚上线就请求
|
||||
setTimeout(() => {
|
||||
if (!this.bot.isRunning) return;
|
||||
this.sellAllFruits();
|
||||
this.sellTimer = setInterval(() => this.sellAllFruits(), this.sellInterval);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
stopLoop() {
|
||||
if (this.sellTimer) {
|
||||
clearInterval(this.sellTimer);
|
||||
this.sellTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WarehouseManager };
|
||||
245
211/server/src/decode.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 解码/验证工具模式
|
||||
*/
|
||||
|
||||
const protobuf = require('protobufjs');
|
||||
const Long = require('long');
|
||||
const { PHASE_NAMES } = require('./config');
|
||||
const { types, getRoot } = require('./proto');
|
||||
const { toNum } = require('./utils');
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
|
||||
/** JSON.stringify replacer, 处理 Long 和 Buffer */
|
||||
function longReplacer(key, value) {
|
||||
if (value && typeof value === 'object' && value.low !== undefined && value.high !== undefined) {
|
||||
return Long.fromBits(value.low, value.high, value.unsigned).toString();
|
||||
}
|
||||
if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
|
||||
return `<${value.data.length} bytes>`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 尝试将 bytes 解码为 UTF-8 字符串 */
|
||||
function tryDecodeString(bytes) {
|
||||
try {
|
||||
const str = Buffer.from(bytes).toString('utf8');
|
||||
const printable = str.split('').filter(c => c.charCodeAt(0) >= 32 || c === '\n' || c === '\r' || c === '\t').length;
|
||||
if (printable > str.length * 0.8 && str.length > 0) return str;
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 通用 protobuf 解码 (无 schema, 显示原始字段) */
|
||||
function tryGenericDecode(buf) {
|
||||
console.log('=== 通用 protobuf 解码 (无schema) ===');
|
||||
try {
|
||||
const reader = protobuf.Reader.create(buf);
|
||||
while (reader.pos < reader.len) {
|
||||
const tag = reader.uint32();
|
||||
const fieldNum = tag >>> 3;
|
||||
const wireType = tag & 7;
|
||||
let value;
|
||||
switch (wireType) {
|
||||
case 0: value = reader.int64().toString(); console.log(` field ${fieldNum} (varint): ${value}`); break;
|
||||
case 1: value = reader.fixed64().toString(); console.log(` field ${fieldNum} (fixed64): ${value}`); break;
|
||||
case 2: {
|
||||
const bytes = reader.bytes();
|
||||
const str = tryDecodeString(bytes);
|
||||
if (str !== null) {
|
||||
console.log(` field ${fieldNum} (bytes/${bytes.length}): "${str}"`);
|
||||
} else {
|
||||
console.log(` field ${fieldNum} (bytes/${bytes.length}): ${Buffer.from(bytes).toString('hex')}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 5: value = reader.float(); console.log(` field ${fieldNum} (float): ${value}`); break;
|
||||
default: console.log(` field ${fieldNum} (wire ${wireType}): <skip>`); reader.skipType(wireType); break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` 解码中断: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 验证模式 ============
|
||||
|
||||
async function verifyMode() {
|
||||
console.log('\n====== 验证模式 ======\n');
|
||||
|
||||
// Login Request
|
||||
const loginB64 = 'CigKGWdhbWVwYi51c2VycGIuVXNlclNlcnZpY2USBUxvZ2luGAEgASgAEmEYACIAKjwKEDEuNi4wLjhfMjAyNTEyMjQSE1dpbmRvd3MgVW5rbm93biB4NjQqBHdpZmlQzL0BagltaWNyb3NvZnQwADoEMTI1NkIVCgASABoAIgAqBW90aGVyMAI6AEIA';
|
||||
try {
|
||||
const msg = types.GateMessage.decode(Buffer.from(loginB64, 'base64'));
|
||||
console.log(`[OK] Login Request: ${msg.meta.service_name}.${msg.meta.method_name} seq=${msg.meta.client_seq}`);
|
||||
const req = types.LoginRequest.decode(msg.body);
|
||||
console.log(` device=${req.device_info?.client_version} scene=${req.scene_id}`);
|
||||
} catch (e) { console.log(`[FAIL] Login Request: ${e.message}`); }
|
||||
|
||||
// AllLands Response
|
||||
const allLandsB64 = 'ClwKG2dhbWVwYi5wbGFudHBiLlBsYW50U2VydmljZRIIQWxsTGFuZHMYAiAEKARCLQoJeC10cmFjZWlkEiBhOWZhNmZhZmYwZmI0ZDU5ZjQ5ZDJiZTJlYTY2NGU3NBK7BwpMCAEQARgBIARCDQgSEBwaBwjpBxDAmgxKAFIuCOOgPhIJ6IOh6JCd5Y2cIgoIBhCNu5rMBhgTKAFQw7gCWAp4eIABAYgBAZABCoABAQpMCAIQARgBIARCDQgSEB0aBwjpBxCQoQ9KAFIuCOOgPhIJ6IOh6JCd5Y2cIgoIBhCOu5rMBhgTKAFQw7gCWAp4eIABAYgBAZABCoABAQpMCAMQARgBIARCDQgSEB4aBwjpBxDgpxJKAFIuCOOgPhIJ6IOh6JCd5Y2cIgoIBhCOu5rMBhgTKAFQw7gCWAp4eIABAYgBAZABCoABAQpMCAQQARgBIARCDQgSEB8aBwjpBxCwrhVKAFIuCOOgPhIJ6IOh6JCd5Y2cIgoIBhCOu5rMBhgTKAFQw7gCWAp4eIABAYgBAZABCoABAQpMCAUQARgBIARCDQgSECAaBwjpBxCAtRhKAFIuCOOgPhIJ6IOh6JCd5Y2cIgoIBhCNu5rMBhgTKAFQw7gCWAp4eIABAYgBAZABCoABAQpMCAYQARgBIARCDQgSECEaBwjpBxCgwh5KAFIuCOOgPhIJ6IOh6JCd5Y2cIgoIBhCOu5rMBhgTKAFQw7gCWAp4eIABAYgBAZABCoABAQoPCAcgBDoHCAYQBRiIJ2ABCg8ICCAEOgcIBxAHGJBOYAEKEAgJIAQ6CAgIEAkYoJwBYAEKDggKIAQ6CAgJEAsYsOoBCg4ICyAEOggIChANGMC4AgoOCAwgBDoICAsQDxjg1AMKDggNIAQ6CAgMEBEYgPEECg4IDiAEOggIDRATGKCNBgoOCA8gBDoICA4QFRjAqQcKDggQIAQ6CAgPEBcY4MUICg4IESAEOggIEBAZGIDiCQoOCBIgBDoICBEQGxig/goKDggTIAQ6CAgSEB0YwJoMCg4IFCAEOggIExAfGOC2DQoOCBUgBDoICBQQIRiA0w4KDggWIAQ6CAgVECMYoO8PCg4IFyAEOggIFhAlGMCLEQoOCBggBDoICBcQJxjgpxISCQicThj/k+vcAxIJCJ1OGP+T69wDEgkInk4Y/5Pr3AMSCwiRThAJGP+T69wDEg0IlE4YZCCTTihkOJNOEhAIlU4QBxj/k+vcAygXOJVOEhAIlk4QCxj/k+vcAygLOJVOEhAIl04QBRj/k+vcAygFOJVOEgkImE4Y/5Pr3AMSDQiZThAMGP+T69wDKAwSCwiaThABGP+T69wDEg0Ikk4QCRj/k+vcAygJEg0Ik04YZCCTTihkOJNOEgkIm04Y/5Pr3AM=';
|
||||
try {
|
||||
const msg = types.GateMessage.decode(Buffer.from(allLandsB64, 'base64'));
|
||||
const reply = types.AllLandsReply.decode(msg.body);
|
||||
console.log(`[OK] AllLands Reply: ${reply.lands.length} 块土地`);
|
||||
for (const land of reply.lands.slice(0, 3)) {
|
||||
const id = toNum(land.id);
|
||||
const unlocked = land.unlocked;
|
||||
const plantName = land.plant?.name || '空';
|
||||
const phases = land.plant?.phases || [];
|
||||
const lastPhase = phases.length > 0 ? phases[phases.length - 1].phase : -1;
|
||||
console.log(` 土地#${id}: ${unlocked ? '已解锁' : '未解锁'} 植物=${plantName} 阶段=${PHASE_NAMES[lastPhase] || lastPhase}`);
|
||||
}
|
||||
if (reply.lands.length > 3) console.log(` ... 还有 ${reply.lands.length - 3} 块`);
|
||||
} catch (e) { console.log(`[FAIL] AllLands Reply: ${e.message}`); }
|
||||
|
||||
// Harvest Request
|
||||
const harvestB64 = 'CiwKG2dhbWVwYi5wbGFudHBiLlBsYW50U2VydmljZRIHSGFydmVzdBgBIBsoGhIQCgYBAgMEBQYQyOHR8gMYAQ==';
|
||||
try {
|
||||
const msg = types.GateMessage.decode(Buffer.from(harvestB64, 'base64'));
|
||||
const req = types.HarvestRequest.decode(msg.body);
|
||||
console.log(`[OK] Harvest Request: land_ids=[${req.land_ids.join(',')}] host_gid=${req.host_gid} is_all=${req.is_all}`);
|
||||
} catch (e) { console.log(`[FAIL] Harvest Request: ${e.message}`); }
|
||||
|
||||
console.log('\n====== 验证完成 ======\n');
|
||||
}
|
||||
|
||||
// ============ 解码模式 ============
|
||||
|
||||
async function decodeMode(args) {
|
||||
let inputData = '';
|
||||
let typeName = '';
|
||||
let isHex = false;
|
||||
let isGateWrapped = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--decode') continue;
|
||||
if (args[i] === '--type' && args[i + 1]) { typeName = args[++i]; continue; }
|
||||
if (args[i] === '--hex') { isHex = true; continue; }
|
||||
if (args[i] === '--gate') { isGateWrapped = true; continue; }
|
||||
if (!inputData) inputData = args[i];
|
||||
}
|
||||
|
||||
if (!inputData) {
|
||||
console.log(`
|
||||
PB数据解码工具
|
||||
==============
|
||||
|
||||
用法:
|
||||
node client.js --decode <base64数据>
|
||||
node client.js --decode <hex数据> --hex
|
||||
node client.js --decode <base64数据> --type <消息类型>
|
||||
node client.js --decode <base64数据> --gate
|
||||
|
||||
参数:
|
||||
<数据> base64编码的pb数据 (默认), 或hex编码 (配合 --hex)
|
||||
--hex 输入数据为hex编码
|
||||
--gate 外层是 gatepb.Message 包装, 自动解析 meta + body
|
||||
--type 指定消息类型, 如: gatepb.Message, gamepb.plantpb.AllLandsReply 等
|
||||
|
||||
可用类型:
|
||||
gatepb.Message / gatepb.Meta
|
||||
gamepb.userpb.LoginRequest / LoginReply / HeartbeatRequest / HeartbeatReply
|
||||
gamepb.plantpb.AllLandsRequest / AllLandsReply / HarvestRequest / HarvestReply
|
||||
gamepb.plantpb.WaterLandRequest / WeedOutRequest / InsecticideRequest
|
||||
gamepb.plantpb.PlantRequest / PlantReply / RemovePlantRequest / RemovePlantReply
|
||||
gamepb.shoppb.ShopInfoRequest / ShopInfoReply / BuyGoodsRequest / BuyGoodsReply
|
||||
gamepb.friendpb.GetAllRequest / GetAllReply / GameFriend
|
||||
|
||||
示例:
|
||||
node client.js --decode CigKGWdhbWVwYi... --gate
|
||||
node client.js --decode 0a1c0a19... --hex --type gatepb.Message
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = getRoot();
|
||||
let buf;
|
||||
try {
|
||||
buf = isHex ? Buffer.from(inputData, 'hex') : Buffer.from(inputData, 'base64');
|
||||
} catch (e) {
|
||||
console.error(`输入数据解码失败: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`数据长度: ${buf.length} 字节\n`);
|
||||
|
||||
// --gate: 先解析外层 gatepb.Message
|
||||
if (isGateWrapped) {
|
||||
try {
|
||||
const msg = types.GateMessage.decode(buf);
|
||||
const meta = msg.meta;
|
||||
console.log('=== gatepb.Message (外层) ===');
|
||||
console.log(` service: ${meta.service_name}`);
|
||||
console.log(` method: ${meta.method_name}`);
|
||||
console.log(` type: ${meta.message_type} (${meta.message_type === 1 ? 'Request' : meta.message_type === 2 ? 'Response' : 'Notify'})`);
|
||||
console.log(` client_seq: ${meta.client_seq}`);
|
||||
console.log(` server_seq: ${meta.server_seq}`);
|
||||
if (toNum(meta.error_code) !== 0) {
|
||||
console.log(` error_code: ${meta.error_code}`);
|
||||
console.log(` error_msg: ${meta.error_message}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (msg.body && msg.body.length > 0) {
|
||||
const svc = meta.service_name || '';
|
||||
const mtd = meta.method_name || '';
|
||||
const isReq = meta.message_type === 1;
|
||||
const suffix = isReq ? 'Request' : 'Reply';
|
||||
const autoType = `${svc.replace('Service', '')}.${mtd}${suffix}`;
|
||||
|
||||
let bodyType = null;
|
||||
try { bodyType = root.lookupType(autoType); } catch (e) {}
|
||||
if (!bodyType) {
|
||||
const parts = svc.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const ns = parts.slice(0, parts.length - 1).join('.');
|
||||
try { bodyType = root.lookupType(`${ns}.${mtd}${suffix}`); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyType) {
|
||||
console.log(`=== ${bodyType.fullName} (body 自动推断) ===`);
|
||||
const decoded = bodyType.decode(msg.body);
|
||||
console.log(JSON.stringify(decoded.toJSON(), longReplacer, 2));
|
||||
} else {
|
||||
console.log(`=== body (未能自动推断类型, 用 --type 手动指定 body 类型) ===`);
|
||||
console.log(` hex: ${Buffer.from(msg.body).toString('hex')}`);
|
||||
console.log(` base64: ${Buffer.from(msg.body).toString('base64')}`);
|
||||
tryGenericDecode(msg.body);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`gatepb.Message 解码失败: ${e.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --type: 指定类型解码
|
||||
if (typeName) {
|
||||
try {
|
||||
const msgType = root.lookupType(typeName);
|
||||
const decoded = msgType.decode(buf);
|
||||
console.log(`=== ${typeName} ===`);
|
||||
console.log(JSON.stringify(decoded.toJSON(), longReplacer, 2));
|
||||
} catch (e) {
|
||||
console.error(`解码失败 (${typeName}): ${e.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 未指定类型,自动尝试
|
||||
console.log('未指定类型,自动尝试...\n');
|
||||
try {
|
||||
const msg = types.GateMessage.decode(buf);
|
||||
if (msg.meta && (msg.meta.service_name || msg.meta.method_name)) {
|
||||
console.log('=== 检测为 gatepb.Message ===');
|
||||
console.log(JSON.stringify(msg.toJSON(), longReplacer, 2));
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
tryGenericDecode(buf);
|
||||
}
|
||||
|
||||
module.exports = { verifyMode, decodeMode };
|
||||
648
211/server/src/farm.js
Normal file
@@ -0,0 +1,648 @@
|
||||
/**
|
||||
* 自己的农场操作 - 收获/浇水/除草/除虫/铲除/种植/商店/巡田
|
||||
*/
|
||||
|
||||
const protobuf = require('protobufjs');
|
||||
const { CONFIG, PlantPhase, PHASE_NAMES } = require('./config');
|
||||
const { types } = require('./proto');
|
||||
const { sendMsgAsync, getUserState, networkEvents } = require('./network');
|
||||
const { toLong, toNum, getServerTimeSec, toTimeSec, log, logWarn, sleep } = require('./utils');
|
||||
const { getPlantNameBySeedId, getPlantName, getPlantExp, formatGrowTime, getPlantGrowTime } = require('./gameConfig');
|
||||
const { getPlantingRecommendation } = require('../tools/calc-exp-yield');
|
||||
|
||||
// ============ 内部状态 ============
|
||||
let isCheckingFarm = false;
|
||||
let isFirstFarmCheck = true;
|
||||
let farmCheckTimer = null;
|
||||
let farmLoopRunning = false;
|
||||
|
||||
// ============ 农场 API ============
|
||||
|
||||
// 操作限制更新回调 (由 friend.js 设置)
|
||||
let onOperationLimitsUpdate = null;
|
||||
function setOperationLimitsCallback(callback) {
|
||||
onOperationLimitsUpdate = callback;
|
||||
}
|
||||
|
||||
async function getAllLands() {
|
||||
const body = types.AllLandsRequest.encode(types.AllLandsRequest.create({})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'AllLands', body);
|
||||
const reply = types.AllLandsReply.decode(replyBody);
|
||||
// 更新操作限制
|
||||
if (reply.operation_limits && onOperationLimitsUpdate) {
|
||||
onOperationLimitsUpdate(reply.operation_limits);
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function harvest(landIds) {
|
||||
const state = getUserState();
|
||||
const body = types.HarvestRequest.encode(types.HarvestRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(state.gid),
|
||||
is_all: true,
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'Harvest', body);
|
||||
return types.HarvestReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function waterLand(landIds) {
|
||||
const state = getUserState();
|
||||
const body = types.WaterLandRequest.encode(types.WaterLandRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(state.gid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'WaterLand', body);
|
||||
return types.WaterLandReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function weedOut(landIds) {
|
||||
const state = getUserState();
|
||||
const body = types.WeedOutRequest.encode(types.WeedOutRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(state.gid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'WeedOut', body);
|
||||
return types.WeedOutReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function insecticide(landIds) {
|
||||
const state = getUserState();
|
||||
const body = types.InsecticideRequest.encode(types.InsecticideRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(state.gid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'Insecticide', body);
|
||||
return types.InsecticideReply.decode(replyBody);
|
||||
}
|
||||
|
||||
// 普通肥料 ID
|
||||
const NORMAL_FERTILIZER_ID = 1011;
|
||||
|
||||
/**
|
||||
* 施肥 - 必须逐块进行,服务器不支持批量
|
||||
* 游戏中拖动施肥间隔很短,这里用 50ms
|
||||
*/
|
||||
async function fertilize(landIds, fertilizerId = NORMAL_FERTILIZER_ID) {
|
||||
let successCount = 0;
|
||||
for (const landId of landIds) {
|
||||
try {
|
||||
const body = types.FertilizeRequest.encode(types.FertilizeRequest.create({
|
||||
land_ids: [toLong(landId)],
|
||||
fertilizer_id: toLong(fertilizerId),
|
||||
})).finish();
|
||||
await sendMsgAsync('gamepb.plantpb.PlantService', 'Fertilize', body);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
// 施肥失败(可能肥料不足),停止继续
|
||||
break;
|
||||
}
|
||||
if (landIds.length > 1) await sleep(50); // 50ms 间隔
|
||||
}
|
||||
return successCount;
|
||||
}
|
||||
|
||||
async function removePlant(landIds) {
|
||||
const body = types.RemovePlantRequest.encode(types.RemovePlantRequest.create({
|
||||
land_ids: landIds.map(id => toLong(id)),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'RemovePlant', body);
|
||||
return types.RemovePlantReply.decode(replyBody);
|
||||
}
|
||||
|
||||
// ============ 商店 API ============
|
||||
|
||||
async function getShopInfo(shopId) {
|
||||
const body = types.ShopInfoRequest.encode(types.ShopInfoRequest.create({
|
||||
shop_id: toLong(shopId),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.shoppb.ShopService', 'ShopInfo', body);
|
||||
return types.ShopInfoReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function buyGoods(goodsId, num, price) {
|
||||
const body = types.BuyGoodsRequest.encode(types.BuyGoodsRequest.create({
|
||||
goods_id: toLong(goodsId),
|
||||
num: toLong(num),
|
||||
price: toLong(price),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.shoppb.ShopService', 'BuyGoods', body);
|
||||
return types.BuyGoodsReply.decode(replyBody);
|
||||
}
|
||||
|
||||
// ============ 种植 ============
|
||||
|
||||
const plantNextAllowed = new Map();
|
||||
const PLANT_COOLDOWN_MS = 5000;
|
||||
const PLANT_ALREADY_COOLDOWN_MS = 30000;
|
||||
|
||||
function encodePlantRequest(seedId, landIds) {
|
||||
const writer = protobuf.Writer.create();
|
||||
const itemWriter = writer.uint32(18).fork();
|
||||
itemWriter.uint32(8).int64(seedId);
|
||||
const idsWriter = itemWriter.uint32(18).fork();
|
||||
for (const id of landIds) {
|
||||
idsWriter.int64(id);
|
||||
}
|
||||
idsWriter.ldelim();
|
||||
itemWriter.ldelim();
|
||||
return writer.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* 种植 - 游戏中拖动种植间隔很短,这里用 50ms
|
||||
*/
|
||||
async function plantSeeds(seedId, landIds) {
|
||||
let successCount = 0;
|
||||
const now = Date.now();
|
||||
for (const landId of landIds) {
|
||||
const nextAllowed = plantNextAllowed.get(landId) || 0;
|
||||
if (now < nextAllowed) continue;
|
||||
plantNextAllowed.set(landId, now + PLANT_COOLDOWN_MS);
|
||||
try {
|
||||
const body = encodePlantRequest(seedId, [landId]);
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'Plant', body);
|
||||
types.PlantReply.decode(replyBody);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
const msg = String(e && e.message ? e.message : e);
|
||||
if (msg.includes('1001008') || msg.includes('土地已种植')) {
|
||||
plantNextAllowed.set(landId, now + PLANT_ALREADY_COOLDOWN_MS);
|
||||
log('种植', `土地#${landId} 已种植,跳过`);
|
||||
} else {
|
||||
logWarn('种植', `土地#${landId} 失败: ${msg}`);
|
||||
}
|
||||
}
|
||||
if (landIds.length > 1) await sleep(50); // 50ms 间隔
|
||||
}
|
||||
return successCount;
|
||||
}
|
||||
|
||||
async function findBestSeed(landsCount) {
|
||||
const SEED_SHOP_ID = 2;
|
||||
const shopReply = await getShopInfo(SEED_SHOP_ID);
|
||||
if (!shopReply.goods_list || shopReply.goods_list.length === 0) {
|
||||
logWarn('商店', '种子商店无商品');
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = getUserState();
|
||||
const available = [];
|
||||
for (const goods of shopReply.goods_list) {
|
||||
if (!goods.unlocked) continue;
|
||||
|
||||
let meetsConditions = true;
|
||||
let requiredLevel = 0;
|
||||
const conds = goods.conds || [];
|
||||
for (const cond of conds) {
|
||||
if (toNum(cond.type) === 1) {
|
||||
requiredLevel = toNum(cond.param);
|
||||
if (state.level < requiredLevel) {
|
||||
meetsConditions = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!meetsConditions) continue;
|
||||
|
||||
const limitCount = toNum(goods.limit_count);
|
||||
const boughtNum = toNum(goods.bought_num);
|
||||
if (limitCount > 0 && boughtNum >= limitCount) continue;
|
||||
|
||||
available.push({
|
||||
goods,
|
||||
goodsId: toNum(goods.id),
|
||||
seedId: toNum(goods.item_id),
|
||||
price: toNum(goods.price),
|
||||
requiredLevel,
|
||||
});
|
||||
}
|
||||
|
||||
if (available.length === 0) {
|
||||
logWarn('商店', '没有可购买的种子');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (CONFIG.forceLowestLevelCrop) {
|
||||
available.sort((a, b) => a.requiredLevel - b.requiredLevel || a.price - b.price);
|
||||
return available[0];
|
||||
}
|
||||
|
||||
try {
|
||||
log('商店', `等级: ${state.level},土地数量: ${landsCount}`);
|
||||
|
||||
const rec = getPlantingRecommendation(state.level, landsCount == null ? 18 : landsCount, { top: 50 });
|
||||
const rankedSeedIds = rec.candidatesNoFert.map(x => x.seedId);
|
||||
for (const seedId of rankedSeedIds) {
|
||||
const hit = available.find(x => x.seedId === seedId);
|
||||
if (hit) return hit;
|
||||
}
|
||||
} catch (e) {
|
||||
logWarn('商店', `经验效率推荐失败,使用兜底策略: ${e.message}`);
|
||||
}
|
||||
|
||||
// 兜底:等级在28级以前还是白萝卜比较好,28级以上选最高等级的种子
|
||||
if(state.level && state.level <= 28){
|
||||
available.sort((a, b) => a.requiredLevel - b.requiredLevel);
|
||||
}else{
|
||||
available.sort((a, b) => b.requiredLevel - a.requiredLevel);
|
||||
}
|
||||
return available[0];
|
||||
}
|
||||
|
||||
async function autoPlantEmptyLands(deadLandIds, emptyLandIds, unlockedLandCount) {
|
||||
let landsToPlant = emptyLandIds.length > 0 ? [...emptyLandIds] : [];
|
||||
const state = getUserState();
|
||||
|
||||
// 1. 铲除枯死/收获残留植物(一键操作)
|
||||
if (deadLandIds.length > 0) {
|
||||
try {
|
||||
await removePlant(deadLandIds);
|
||||
log('铲除', `已铲除 ${deadLandIds.length} 块 (${deadLandIds.join(',')})`);
|
||||
landsToPlant.push(...deadLandIds);
|
||||
} catch (e) {
|
||||
logWarn('铲除', `批量铲除失败: ${e.message}`);
|
||||
// 失败时仍然尝试种植
|
||||
landsToPlant.push(...deadLandIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (landsToPlant.length === 0) return;
|
||||
|
||||
if (landsToPlant.length > 1) {
|
||||
landsToPlant = Array.from(new Set(landsToPlant));
|
||||
}
|
||||
|
||||
if (landsToPlant.length > 0) {
|
||||
const now = Date.now();
|
||||
const filtered = [];
|
||||
for (const landId of landsToPlant) {
|
||||
const nextAllowed = plantNextAllowed.get(landId) || 0;
|
||||
if (now >= nextAllowed) {
|
||||
filtered.push(landId);
|
||||
}
|
||||
}
|
||||
landsToPlant = filtered;
|
||||
}
|
||||
|
||||
if (landsToPlant.length === 0) return;
|
||||
|
||||
// 2. 查询种子商店
|
||||
let bestSeed;
|
||||
try {
|
||||
bestSeed = await findBestSeed(unlockedLandCount);
|
||||
} catch (e) {
|
||||
logWarn('商店', `查询失败: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
if (!bestSeed) return;
|
||||
|
||||
const seedName = getPlantNameBySeedId(bestSeed.seedId);
|
||||
const growTime = getPlantGrowTime(1020000 + (bestSeed.seedId - 20000)); // 转换为植物ID
|
||||
const growTimeStr = growTime > 0 ? ` 生长${formatGrowTime(growTime)}` : '';
|
||||
log('商店', `最佳种子: ${seedName} (${bestSeed.seedId}) 价格=${bestSeed.price}金币${growTimeStr}`);
|
||||
|
||||
// 3. 购买
|
||||
const needCount = landsToPlant.length;
|
||||
const totalCost = bestSeed.price * needCount;
|
||||
if (totalCost > state.gold) {
|
||||
logWarn('商店', `金币不足! 需要 ${totalCost} 金币, 当前 ${state.gold} 金币`);
|
||||
const canBuy = Math.floor(state.gold / bestSeed.price);
|
||||
if (canBuy <= 0) return;
|
||||
landsToPlant = landsToPlant.slice(0, canBuy);
|
||||
log('商店', `金币有限,只种 ${canBuy} 块地`);
|
||||
}
|
||||
|
||||
let actualSeedId = bestSeed.seedId;
|
||||
try {
|
||||
const buyReply = await buyGoods(bestSeed.goodsId, landsToPlant.length, bestSeed.price);
|
||||
if (buyReply.get_items && buyReply.get_items.length > 0) {
|
||||
const gotItem = buyReply.get_items[0];
|
||||
const gotId = toNum(gotItem.id);
|
||||
const gotCount = toNum(gotItem.count);
|
||||
log('购买', `获得物品: id=${gotId} count=${gotCount}`);
|
||||
if (gotId > 0) actualSeedId = gotId;
|
||||
}
|
||||
if (buyReply.cost_items) {
|
||||
for (const item of buyReply.cost_items) {
|
||||
state.gold -= toNum(item.count);
|
||||
}
|
||||
}
|
||||
const boughtName = getPlantNameBySeedId(actualSeedId);
|
||||
log('购买', `已购买 ${boughtName}种子 x${landsToPlant.length}, 花费 ${bestSeed.price * landsToPlant.length} 金币`);
|
||||
} catch (e) {
|
||||
logWarn('购买', e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 种植(逐块拖动,间隔50ms)
|
||||
let plantedLands = [];
|
||||
try {
|
||||
const planted = await plantSeeds(actualSeedId, landsToPlant);
|
||||
log('种植', `已在 ${planted} 块地种植 (${landsToPlant.join(',')})`);
|
||||
if (planted > 0) {
|
||||
plantedLands = landsToPlant.slice(0, planted);
|
||||
}
|
||||
} catch (e) {
|
||||
logWarn('种植', e.message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ============ 土地分析 ============
|
||||
|
||||
/**
|
||||
* 根据服务器时间确定当前实际生长阶段
|
||||
*/
|
||||
function getCurrentPhase(phases, debug, landLabel) {
|
||||
if (!phases || phases.length === 0) return null;
|
||||
|
||||
const nowSec = getServerTimeSec();
|
||||
|
||||
if (debug) {
|
||||
console.log(` ${landLabel} 服务器时间=${nowSec} (${new Date(nowSec * 1000).toLocaleTimeString()})`);
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
const p = phases[i];
|
||||
const bt = toTimeSec(p.begin_time);
|
||||
const phaseName = PHASE_NAMES[p.phase] || `阶段${p.phase}`;
|
||||
const diff = bt > 0 ? (bt - nowSec) : 0;
|
||||
const diffStr = diff > 0 ? `(未来 ${diff}s)` : diff < 0 ? `(已过 ${-diff}s)` : '';
|
||||
console.log(` ${landLabel} [${i}] ${phaseName}(${p.phase}) begin=${bt} ${diffStr} dry=${toTimeSec(p.dry_time)} weed=${toTimeSec(p.weeds_time)} insect=${toTimeSec(p.insect_time)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = phases.length - 1; i >= 0; i--) {
|
||||
const beginTime = toTimeSec(phases[i].begin_time);
|
||||
if (beginTime > 0 && beginTime <= nowSec) {
|
||||
if (debug) {
|
||||
console.log(` ${landLabel} → 当前阶段: ${PHASE_NAMES[phases[i].phase] || phases[i].phase}`);
|
||||
}
|
||||
return phases[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(` ${landLabel} → 所有阶段都在未来,使用第一个: ${PHASE_NAMES[phases[0].phase] || phases[0].phase}`);
|
||||
}
|
||||
return phases[0];
|
||||
}
|
||||
|
||||
function analyzeLands(lands) {
|
||||
const result = {
|
||||
harvestable: [], needWater: [], needWeed: [], needBug: [],
|
||||
growing: [], empty: [], dead: [],
|
||||
harvestableInfo: [], // 收获植物的详细信息 { id, name, exp }
|
||||
};
|
||||
|
||||
const nowSec = getServerTimeSec();
|
||||
const debug = false;
|
||||
|
||||
if (debug) {
|
||||
console.log('');
|
||||
console.log('========== 首次巡田详细日志 ==========');
|
||||
console.log(` 服务器时间(秒): ${nowSec} (${new Date(nowSec * 1000).toLocaleString()})`);
|
||||
console.log(` 总土地数: ${lands.length}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
for (const land of lands) {
|
||||
const id = toNum(land.id);
|
||||
if (!land.unlocked) {
|
||||
if (debug) console.log(` 土地#${id}: 未解锁`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const plant = land.plant;
|
||||
if (!plant) {
|
||||
result.empty.push(id);
|
||||
if (debug) console.log(` 土地#${id}: 空地`);
|
||||
continue;
|
||||
}
|
||||
if (!plant.phases || plant.phases.length === 0) {
|
||||
const plantId = toNum(plant.id);
|
||||
if (plantId > 0 || plant.name) {
|
||||
result.growing.push(id);
|
||||
if (debug) console.log(` 土地#${id}: 生长中(阶段未知)`);
|
||||
} else {
|
||||
result.empty.push(id);
|
||||
if (debug) console.log(` 土地#${id}: 空地`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const plantName = plant.name || '未知作物';
|
||||
const landLabel = `土地#${id}(${plantName})`;
|
||||
|
||||
if (debug) {
|
||||
console.log(` ${landLabel}: phases=${plant.phases.length} dry_num=${toNum(plant.dry_num)} weed_owners=${(plant.weed_owners||[]).length} insect_owners=${(plant.insect_owners||[]).length}`);
|
||||
}
|
||||
|
||||
const currentPhase = getCurrentPhase(plant.phases, debug, landLabel);
|
||||
if (!currentPhase) {
|
||||
result.empty.push(id);
|
||||
continue;
|
||||
}
|
||||
const phaseVal = currentPhase.phase;
|
||||
|
||||
if (phaseVal === PlantPhase.DEAD) {
|
||||
result.dead.push(id);
|
||||
if (debug) console.log(` → 结果: 枯死`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phaseVal === PlantPhase.MATURE) {
|
||||
result.harvestable.push(id);
|
||||
// 收集植物信息用于日志
|
||||
const plantId = toNum(plant.id);
|
||||
const plantNameFromConfig = getPlantName(plantId);
|
||||
const plantExp = getPlantExp(plantId);
|
||||
result.harvestableInfo.push({
|
||||
landId: id,
|
||||
plantId,
|
||||
name: plantNameFromConfig || plantName,
|
||||
exp: plantExp,
|
||||
});
|
||||
if (debug) console.log(` → 结果: 可收获 (${plantNameFromConfig} +${plantExp}经验)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let landNeeds = [];
|
||||
const dryNum = toNum(plant.dry_num);
|
||||
const dryTime = toTimeSec(currentPhase.dry_time);
|
||||
if (dryNum > 0 || (dryTime > 0 && dryTime <= nowSec)) {
|
||||
result.needWater.push(id);
|
||||
landNeeds.push('缺水');
|
||||
}
|
||||
|
||||
const weedsTime = toTimeSec(currentPhase.weeds_time);
|
||||
const hasWeeds = (plant.weed_owners && plant.weed_owners.length > 0) || (weedsTime > 0 && weedsTime <= nowSec);
|
||||
if (hasWeeds) {
|
||||
result.needWeed.push(id);
|
||||
landNeeds.push('有草');
|
||||
}
|
||||
|
||||
const insectTime = toTimeSec(currentPhase.insect_time);
|
||||
const hasBugs = (plant.insect_owners && plant.insect_owners.length > 0) || (insectTime > 0 && insectTime <= nowSec);
|
||||
if (hasBugs) {
|
||||
result.needBug.push(id);
|
||||
landNeeds.push('有虫');
|
||||
}
|
||||
|
||||
result.growing.push(id);
|
||||
if (debug) {
|
||||
const needStr = landNeeds.length > 0 ? ` 需要: ${landNeeds.join(',')}` : '';
|
||||
console.log(` → 结果: 生长中(${PHASE_NAMES[phaseVal] || phaseVal})${needStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('');
|
||||
console.log('========== 巡田分析汇总 ==========');
|
||||
console.log(` 可收获: ${result.harvestable.length} [${result.harvestable.join(',')}]`);
|
||||
console.log(` 生长中: ${result.growing.length} [${result.growing.join(',')}]`);
|
||||
console.log(` 缺水: ${result.needWater.length} [${result.needWater.join(',')}]`);
|
||||
console.log(` 有草: ${result.needWeed.length} [${result.needWeed.join(',')}]`);
|
||||
console.log(` 有虫: ${result.needBug.length} [${result.needBug.join(',')}]`);
|
||||
console.log(` 空地: ${result.empty.length} [${result.empty.join(',')}]`);
|
||||
console.log(` 枯死: ${result.dead.length} [${result.dead.join(',')}]`);
|
||||
console.log('====================================');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ 巡田主循环 ============
|
||||
|
||||
async function checkFarm() {
|
||||
const state = getUserState();
|
||||
if (isCheckingFarm || !state.gid) return;
|
||||
isCheckingFarm = true;
|
||||
|
||||
try {
|
||||
const landsReply = await getAllLands();
|
||||
if (!landsReply.lands || landsReply.lands.length === 0) {
|
||||
log('农场', '没有土地数据');
|
||||
return;
|
||||
}
|
||||
|
||||
const lands = landsReply.lands;
|
||||
const status = analyzeLands(lands);
|
||||
const unlockedLandCount = lands.filter(land => land && land.unlocked).length;
|
||||
isFirstFarmCheck = false;
|
||||
|
||||
// 构建状态摘要
|
||||
const statusParts = [];
|
||||
if (status.harvestable.length) statusParts.push(`收:${status.harvestable.length}`);
|
||||
if (status.needWeed.length) statusParts.push(`草:${status.needWeed.length}`);
|
||||
if (status.needBug.length) statusParts.push(`虫:${status.needBug.length}`);
|
||||
if (status.needWater.length) statusParts.push(`水:${status.needWater.length}`);
|
||||
if (status.dead.length) statusParts.push(`枯:${status.dead.length}`);
|
||||
if (status.empty.length) statusParts.push(`空:${status.empty.length}`);
|
||||
statusParts.push(`长:${status.growing.length}`);
|
||||
|
||||
const hasWork = status.harvestable.length || status.needWeed.length || status.needBug.length
|
||||
|| status.needWater.length || status.dead.length || status.empty.length;
|
||||
|
||||
// 执行操作并收集结果
|
||||
const actions = [];
|
||||
|
||||
// 一键操作:除草、除虫、浇水可以并行执行(游戏中都是一键完成)
|
||||
const batchOps = [];
|
||||
if (status.needWeed.length > 0) {
|
||||
batchOps.push(weedOut(status.needWeed).then(() => actions.push(`除草${status.needWeed.length}`)).catch(e => logWarn('除草', e.message)));
|
||||
}
|
||||
if (status.needBug.length > 0) {
|
||||
batchOps.push(insecticide(status.needBug).then(() => actions.push(`除虫${status.needBug.length}`)).catch(e => logWarn('除虫', e.message)));
|
||||
}
|
||||
if (status.needWater.length > 0) {
|
||||
batchOps.push(waterLand(status.needWater).then(() => actions.push(`浇水${status.needWater.length}`)).catch(e => logWarn('浇水', e.message)));
|
||||
}
|
||||
if (batchOps.length > 0) {
|
||||
await Promise.all(batchOps);
|
||||
}
|
||||
|
||||
// 收获(一键操作)
|
||||
let harvestedLandIds = [];
|
||||
if (status.harvestable.length > 0) {
|
||||
try {
|
||||
await harvest(status.harvestable);
|
||||
actions.push(`收获${status.harvestable.length}`);
|
||||
harvestedLandIds = [...status.harvestable];
|
||||
} catch (e) { logWarn('收获', e.message); }
|
||||
}
|
||||
|
||||
// 铲除 + 种植 + 施肥(需要顺序执行)
|
||||
const allDeadLands = [...status.dead, ...harvestedLandIds];
|
||||
const allEmptyLands = [...status.empty];
|
||||
if (allDeadLands.length > 0 || allEmptyLands.length > 0) {
|
||||
try {
|
||||
await autoPlantEmptyLands(allDeadLands, allEmptyLands, unlockedLandCount);
|
||||
actions.push(`种植${allDeadLands.length + allEmptyLands.length}`);
|
||||
} catch (e) { logWarn('种植', e.message); }
|
||||
}
|
||||
|
||||
// 输出一行日志
|
||||
const actionStr = actions.length > 0 ? ` → ${actions.join('/')}` : '';
|
||||
if(hasWork) {
|
||||
log('农场', `[${statusParts.join(' ')}]${actionStr}${!hasWork ? ' 无需操作' : ''}`)
|
||||
}
|
||||
} catch (err) {
|
||||
logWarn('巡田', `检查失败: ${err.message}`);
|
||||
} finally {
|
||||
isCheckingFarm = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 农场巡查循环 - 本次完成后等待指定秒数再开始下次
|
||||
*/
|
||||
async function farmCheckLoop() {
|
||||
while (farmLoopRunning) {
|
||||
await checkFarm();
|
||||
if (!farmLoopRunning) break;
|
||||
await sleep(CONFIG.farmCheckInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function startFarmCheckLoop() {
|
||||
if (farmLoopRunning) return;
|
||||
farmLoopRunning = true;
|
||||
|
||||
// 监听服务器推送的土地变化事件
|
||||
networkEvents.on('landsChanged', onLandsChangedPush);
|
||||
|
||||
// 延迟 2 秒后启动循环
|
||||
farmCheckTimer = setTimeout(() => farmCheckLoop(), 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理服务器推送的土地变化
|
||||
*/
|
||||
let lastPushTime = 0;
|
||||
function onLandsChangedPush(lands) {
|
||||
if (isCheckingFarm) return;
|
||||
const now = Date.now();
|
||||
if (now - lastPushTime < 500) return; // 500ms 防抖
|
||||
|
||||
lastPushTime = now;
|
||||
log('农场', `收到推送: ${lands.length}块土地变化,检查中...`);
|
||||
|
||||
setTimeout(async () => {
|
||||
if (!isCheckingFarm) {
|
||||
await checkFarm();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function stopFarmCheckLoop() {
|
||||
farmLoopRunning = false;
|
||||
if (farmCheckTimer) { clearTimeout(farmCheckTimer); farmCheckTimer = null; }
|
||||
networkEvents.removeListener('landsChanged', onLandsChangedPush);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkFarm, startFarmCheckLoop, stopFarmCheckLoop,
|
||||
getCurrentPhase,
|
||||
setOperationLimitsCallback,
|
||||
};
|
||||
650
211/server/src/friend.js
Normal file
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* 好友农场操作 - 进入/离开/帮忙/偷菜/巡查
|
||||
*/
|
||||
|
||||
const { CONFIG, PlantPhase, PHASE_NAMES } = require('./config');
|
||||
const { types } = require('./proto');
|
||||
const { sendMsgAsync, getUserState, networkEvents } = require('./network');
|
||||
const { toLong, toNum, getServerTimeSec, log, logWarn, sleep } = require('./utils');
|
||||
const { getCurrentPhase, setOperationLimitsCallback } = require('./farm');
|
||||
const { getPlantName } = require('./gameConfig');
|
||||
|
||||
// ============ 内部状态 ============
|
||||
let isCheckingFriends = false;
|
||||
let isFirstFriendCheck = true;
|
||||
let friendCheckTimer = null;
|
||||
let friendLoopRunning = false;
|
||||
let lastResetDate = ''; // 上次重置日期 (YYYY-MM-DD)
|
||||
|
||||
// 操作限制状态 (从服务器响应中更新)
|
||||
// 操作类型ID (根据游戏代码):
|
||||
// 10001 = 收获, 10002 = 铲除, 10003 = 放草, 10004 = 放虫
|
||||
// 10005 = 除草(帮好友), 10006 = 除虫(帮好友), 10007 = 浇水(帮好友), 10008 = 偷菜
|
||||
const operationLimits = new Map();
|
||||
|
||||
// 操作类型名称映射
|
||||
const OP_NAMES = {
|
||||
10001: '收获',
|
||||
10002: '铲除',
|
||||
10003: '放草',
|
||||
10004: '放虫',
|
||||
10005: '除草',
|
||||
10006: '除虫',
|
||||
10007: '浇水',
|
||||
10008: '偷菜',
|
||||
};
|
||||
|
||||
// 配置: 是否只在有经验时才帮助好友
|
||||
const HELP_ONLY_WITH_EXP = true; // !!!无效,暂时无法判断。有修复方法但是暂时没打算更新出来
|
||||
|
||||
// 配置: 是否启用放虫放草功能
|
||||
const ENABLE_PUT_BAD_THINGS = false; // 无效!!!开启后会多次访问朋友导致被拉黑 请勿更改暂时关闭放虫放草功能
|
||||
|
||||
// ============ 好友 API ============
|
||||
|
||||
async function getAllFriends() {
|
||||
const body = types.GetAllFriendsRequest.encode(types.GetAllFriendsRequest.create({})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.friendpb.FriendService', 'GetAll', body);
|
||||
return types.GetAllFriendsReply.decode(replyBody);
|
||||
}
|
||||
|
||||
// ============ 好友申请 API (微信同玩) ============
|
||||
|
||||
async function getApplications() {
|
||||
const body = types.GetApplicationsRequest.encode(types.GetApplicationsRequest.create({})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.friendpb.FriendService', 'GetApplications', body);
|
||||
return types.GetApplicationsReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function acceptFriends(gids) {
|
||||
const body = types.AcceptFriendsRequest.encode(types.AcceptFriendsRequest.create({
|
||||
friend_gids: gids.map(g => toLong(g)),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.friendpb.FriendService', 'AcceptFriends', body);
|
||||
return types.AcceptFriendsReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function enterFriendFarm(friendGid) {
|
||||
const body = types.VisitEnterRequest.encode(types.VisitEnterRequest.create({
|
||||
host_gid: toLong(friendGid),
|
||||
reason: 2, // ENTER_REASON_FRIEND
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.visitpb.VisitService', 'Enter', body);
|
||||
return types.VisitEnterReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function leaveFriendFarm(friendGid) {
|
||||
const body = types.VisitLeaveRequest.encode(types.VisitLeaveRequest.create({
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
try {
|
||||
await sendMsgAsync('gamepb.visitpb.VisitService', 'Leave', body);
|
||||
} catch (e) { /* 离开失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要重置每日限制 (0点刷新)
|
||||
*/
|
||||
function checkDailyReset() {
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
if (lastResetDate !== today) {
|
||||
if (lastResetDate !== '') {
|
||||
log('系统', '跨日重置,清空操作限制缓存');
|
||||
}
|
||||
operationLimits.clear();
|
||||
lastResetDate = today;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新操作限制状态
|
||||
*/
|
||||
function updateOperationLimits(limits) {
|
||||
if (!limits || limits.length === 0) return;
|
||||
checkDailyReset();
|
||||
for (const limit of limits) {
|
||||
const id = toNum(limit.id);
|
||||
if (id > 0) {
|
||||
const data = {
|
||||
dayTimes: toNum(limit.day_times),
|
||||
dayTimesLimit: toNum(limit.day_times_lt),
|
||||
dayExpTimes: toNum(limit.day_exp_times),
|
||||
dayExpTimesLimit: toNum(limit.day_ex_times_lt), // 注意: 字段名是 day_ex_times_lt (少个p)
|
||||
};
|
||||
operationLimits.set(id, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查某操作是否还能获得经验
|
||||
*/
|
||||
function canGetExp(opId) {
|
||||
const limit = operationLimits.get(opId);
|
||||
if (!limit) return false; // 没有限制信息,保守起见不帮助(等待农场检查获取限制)
|
||||
if (limit.dayExpTimesLimit <= 0) return true; // 没有经验上限
|
||||
return limit.dayExpTimes < limit.dayExpTimesLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查某操作是否还有次数
|
||||
*/
|
||||
function canOperate(opId) {
|
||||
const limit = operationLimits.get(opId);
|
||||
if (!limit) return true;
|
||||
if (limit.dayTimesLimit <= 0) return true;
|
||||
return limit.dayTimes < limit.dayTimesLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某操作剩余次数
|
||||
*/
|
||||
function getRemainingTimes(opId) {
|
||||
const limit = operationLimits.get(opId);
|
||||
if (!limit || limit.dayTimesLimit <= 0) return 999;
|
||||
return Math.max(0, limit.dayTimesLimit - limit.dayTimes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作限制摘要 (用于日志显示)
|
||||
*/
|
||||
function getOperationLimitsSummary() {
|
||||
const parts = [];
|
||||
// 帮助好友操作 (10005=除草, 10006=除虫, 10007=浇水, 10008=偷菜)
|
||||
for (const id of [10005, 10006, 10007, 10008]) {
|
||||
const limit = operationLimits.get(id);
|
||||
if (limit && limit.dayExpTimesLimit > 0) {
|
||||
const name = OP_NAMES[id] || `#${id}`;
|
||||
const expLeft = limit.dayExpTimesLimit - limit.dayExpTimes;
|
||||
parts.push(`${name}${expLeft}/${limit.dayExpTimesLimit}`);
|
||||
}
|
||||
}
|
||||
// 捣乱操作 (10003=放草, 10004=放虫)
|
||||
for (const id of [10003, 10004]) {
|
||||
const limit = operationLimits.get(id);
|
||||
if (limit && limit.dayTimesLimit > 0) {
|
||||
const name = OP_NAMES[id] || `#${id}`;
|
||||
const left = limit.dayTimesLimit - limit.dayTimes;
|
||||
parts.push(`${name}${left}/${limit.dayTimesLimit}`);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
async function helpWater(friendGid, landIds) {
|
||||
const body = types.WaterLandRequest.encode(types.WaterLandRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'WaterLand', body);
|
||||
const reply = types.WaterLandReply.decode(replyBody);
|
||||
updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function helpWeed(friendGid, landIds) {
|
||||
const body = types.WeedOutRequest.encode(types.WeedOutRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'WeedOut', body);
|
||||
const reply = types.WeedOutReply.decode(replyBody);
|
||||
updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function helpInsecticide(friendGid, landIds) {
|
||||
const body = types.InsecticideRequest.encode(types.InsecticideRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'Insecticide', body);
|
||||
const reply = types.InsecticideReply.decode(replyBody);
|
||||
updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function stealHarvest(friendGid, landIds) {
|
||||
const body = types.HarvestRequest.encode(types.HarvestRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
is_all: true,
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'Harvest', body);
|
||||
const reply = types.HarvestReply.decode(replyBody);
|
||||
updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function putInsects(friendGid, landIds) {
|
||||
const body = types.PutInsectsRequest.encode(types.PutInsectsRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'PutInsects', body);
|
||||
const reply = types.PutInsectsReply.decode(replyBody);
|
||||
updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function putWeeds(friendGid, landIds) {
|
||||
const body = types.PutWeedsRequest.encode(types.PutWeedsRequest.create({
|
||||
land_ids: landIds,
|
||||
host_gid: toLong(friendGid),
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.plantpb.PlantService', 'PutWeeds', body);
|
||||
const reply = types.PutWeedsReply.decode(replyBody);
|
||||
updateOperationLimits(reply.operation_limits);
|
||||
return reply;
|
||||
}
|
||||
|
||||
// ============ 好友土地分析 ============
|
||||
|
||||
// 调试开关 - 设为好友名字可只查看该好友的土地分析详情,设为 true 查看全部,false 关闭
|
||||
const DEBUG_FRIEND_LANDS = false;
|
||||
|
||||
function analyzeFriendLands(lands, myGid, friendName = '') {
|
||||
const result = {
|
||||
stealable: [], // 可偷
|
||||
stealableInfo: [], // 可偷植物信息 { landId, plantId, name }
|
||||
needWater: [], // 需要浇水
|
||||
needWeed: [], // 需要除草
|
||||
needBug: [], // 需要除虫
|
||||
canPutWeed: [], // 可以放草
|
||||
canPutBug: [], // 可以放虫
|
||||
};
|
||||
|
||||
for (const land of lands) {
|
||||
const id = toNum(land.id);
|
||||
const plant = land.plant;
|
||||
// 是否显示此好友的调试信息
|
||||
const showDebug = DEBUG_FRIEND_LANDS === true || DEBUG_FRIEND_LANDS === friendName;
|
||||
|
||||
if (!plant || !plant.phases || plant.phases.length === 0) {
|
||||
if (showDebug) console.log(` [${friendName}] 土地#${id}: 无植物或无阶段数据`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPhase = getCurrentPhase(plant.phases, showDebug, `[${friendName}]土地#${id}`);
|
||||
if (!currentPhase) {
|
||||
if (showDebug) console.log(` [${friendName}] 土地#${id}: getCurrentPhase返回null`);
|
||||
continue;
|
||||
}
|
||||
const phaseVal = currentPhase.phase;
|
||||
|
||||
if (showDebug) {
|
||||
const insectOwners = plant.insect_owners || [];
|
||||
const weedOwners = plant.weed_owners || [];
|
||||
console.log(` [${friendName}] 土地#${id}: phase=${phaseVal} stealable=${plant.stealable} dry=${toNum(plant.dry_num)} weed=${weedOwners.length} bug=${insectOwners.length}`);
|
||||
}
|
||||
|
||||
if (phaseVal === PlantPhase.MATURE) {
|
||||
if (plant.stealable) {
|
||||
result.stealable.push(id);
|
||||
const plantId = toNum(plant.id);
|
||||
const plantName = getPlantName(plantId) || plant.name || '未知';
|
||||
result.stealableInfo.push({ landId: id, plantId, name: plantName });
|
||||
} else if (showDebug) {
|
||||
console.log(` [${friendName}] 土地#${id}: 成熟但stealable=false (可能已被偷过)`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phaseVal === PlantPhase.DEAD) continue;
|
||||
|
||||
// 帮助操作
|
||||
if (toNum(plant.dry_num) > 0) result.needWater.push(id);
|
||||
if (plant.weed_owners && plant.weed_owners.length > 0) result.needWeed.push(id);
|
||||
if (plant.insect_owners && plant.insect_owners.length > 0) result.needBug.push(id);
|
||||
|
||||
// 捣乱操作: 检查是否可以放草/放虫
|
||||
// 条件: 没有草且我没放过草
|
||||
const weedOwners = plant.weed_owners || [];
|
||||
const insectOwners = plant.insect_owners || [];
|
||||
const iAlreadyPutWeed = weedOwners.some(gid => toNum(gid) === myGid);
|
||||
const iAlreadyPutBug = insectOwners.some(gid => toNum(gid) === myGid);
|
||||
|
||||
// 每块地最多2个草/虫,且我没放过
|
||||
if (weedOwners.length < 2 && !iAlreadyPutWeed) {
|
||||
result.canPutWeed.push(id);
|
||||
}
|
||||
if (insectOwners.length < 2 && !iAlreadyPutBug) {
|
||||
result.canPutBug.push(id);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ 拜访好友 ============
|
||||
|
||||
async function visitFriend(friend, totalActions, myGid) {
|
||||
const { gid, name } = friend;
|
||||
const showDebug = DEBUG_FRIEND_LANDS === true || DEBUG_FRIEND_LANDS === name;
|
||||
|
||||
if (showDebug) {
|
||||
console.log(`\n========== 调试: 进入好友 [${name}] 农场 ==========`);
|
||||
}
|
||||
|
||||
let enterReply;
|
||||
try {
|
||||
enterReply = await enterFriendFarm(gid);
|
||||
} catch (e) {
|
||||
logWarn('好友', `进入 ${name} 农场失败: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lands = enterReply.lands || [];
|
||||
if (showDebug) {
|
||||
console.log(` [${name}] 获取到 ${lands.length} 块土地`);
|
||||
}
|
||||
if (lands.length === 0) {
|
||||
await leaveFriendFarm(gid);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = analyzeFriendLands(lands, myGid, name);
|
||||
|
||||
if (showDebug) {
|
||||
console.log(` [${name}] 分析结果: 可偷=${status.stealable.length} 浇水=${status.needWater.length} 除草=${status.needWeed.length} 除虫=${status.needBug.length}`);
|
||||
console.log(`========== 调试结束 ==========\n`);
|
||||
}
|
||||
|
||||
// 执行操作
|
||||
const actions = [];
|
||||
|
||||
// 帮助操作: 只在有经验时执行 (如果启用了 HELP_ONLY_WITH_EXP)
|
||||
if (status.needWeed.length > 0) {
|
||||
const shouldHelp = !HELP_ONLY_WITH_EXP || canGetExp(10005); // 10005=除草
|
||||
if (shouldHelp) {
|
||||
let ok = 0;
|
||||
for (const landId of status.needWeed) {
|
||||
try { await helpWeed(gid, [landId]); ok++; } catch (e) { /* ignore */ }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`草${ok}`); totalActions.weed += ok; }
|
||||
}
|
||||
}
|
||||
|
||||
if (status.needBug.length > 0) {
|
||||
const shouldHelp = !HELP_ONLY_WITH_EXP || canGetExp(10006); // 10006=除虫
|
||||
if (shouldHelp) {
|
||||
let ok = 0;
|
||||
for (const landId of status.needBug) {
|
||||
try { await helpInsecticide(gid, [landId]); ok++; } catch (e) { /* ignore */ }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`虫${ok}`); totalActions.bug += ok; }
|
||||
}
|
||||
}
|
||||
|
||||
if (status.needWater.length > 0) {
|
||||
const shouldHelp = !HELP_ONLY_WITH_EXP || canGetExp(10007); // 10007=浇水
|
||||
if (shouldHelp) {
|
||||
let ok = 0;
|
||||
for (const landId of status.needWater) {
|
||||
try { await helpWater(gid, [landId]); ok++; } catch (e) { /* ignore */ }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`水${ok}`); totalActions.water += ok; }
|
||||
}
|
||||
}
|
||||
|
||||
// 偷菜: 始终执行
|
||||
if (status.stealable.length > 0) {
|
||||
let ok = 0;
|
||||
const stolenPlants = [];
|
||||
for (let i = 0; i < status.stealable.length; i++) {
|
||||
const landId = status.stealable[i];
|
||||
try {
|
||||
await stealHarvest(gid, [landId]);
|
||||
ok++;
|
||||
if (status.stealableInfo[i]) {
|
||||
stolenPlants.push(status.stealableInfo[i].name);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) {
|
||||
const plantNames = [...new Set(stolenPlants)].join('/');
|
||||
actions.push(`偷${ok}${plantNames ? '(' + plantNames + ')' : ''}`);
|
||||
totalActions.steal += ok;
|
||||
}
|
||||
}
|
||||
|
||||
// 捣乱操作: 放虫(10004)/放草(10003)
|
||||
if (ENABLE_PUT_BAD_THINGS && status.canPutBug.length > 0 && canOperate(10004)) {
|
||||
let ok = 0;
|
||||
const remaining = getRemainingTimes(10004);
|
||||
const toProcess = status.canPutBug.slice(0, remaining);
|
||||
for (const landId of toProcess) {
|
||||
if (!canOperate(10004)) break;
|
||||
try { await putInsects(gid, [landId]); ok++; } catch (e) { /* ignore */ }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`放虫${ok}`); totalActions.putBug += ok; }
|
||||
}
|
||||
|
||||
if (ENABLE_PUT_BAD_THINGS && status.canPutWeed.length > 0 && canOperate(10003)) {
|
||||
let ok = 0;
|
||||
const remaining = getRemainingTimes(10003);
|
||||
const toProcess = status.canPutWeed.slice(0, remaining);
|
||||
for (const landId of toProcess) {
|
||||
if (!canOperate(10003)) break;
|
||||
try { await putWeeds(gid, [landId]); ok++; } catch (e) { /* ignore */ }
|
||||
await sleep(100);
|
||||
}
|
||||
if (ok > 0) { actions.push(`放草${ok}`); totalActions.putWeed += ok; }
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
log('好友', `${name}: ${actions.join('/')}`);
|
||||
}
|
||||
|
||||
await leaveFriendFarm(gid);
|
||||
}
|
||||
|
||||
// ============ 好友巡查主循环 ============
|
||||
|
||||
async function checkFriends() {
|
||||
const state = getUserState();
|
||||
if (isCheckingFriends || !state.gid) return;
|
||||
isCheckingFriends = true;
|
||||
|
||||
// 检查是否跨日需要重置
|
||||
checkDailyReset();
|
||||
|
||||
// 经验限制状态(移到有操作时才显示)
|
||||
|
||||
try {
|
||||
const friendsReply = await getAllFriends();
|
||||
const friends = friendsReply.game_friends || [];
|
||||
if (friends.length === 0) { log('好友', '没有好友'); return; }
|
||||
|
||||
// 检查是否还有捣乱次数 (放虫/放草)
|
||||
const canPutBugOrWeed = canOperate(10004) || canOperate(10003); // 10004=放虫, 10003=放草
|
||||
|
||||
// 分两类:有预览信息的优先访问,其他的放后面(用于放虫放草)
|
||||
const priorityFriends = []; // 有可偷/可帮助的好友
|
||||
const otherFriends = []; // 其他好友(仅用于放虫放草)
|
||||
const visitedGids = new Set();
|
||||
|
||||
for (const f of friends) {
|
||||
const gid = toNum(f.gid);
|
||||
if (gid === state.gid) continue;
|
||||
if (visitedGids.has(gid)) continue;
|
||||
const name = f.remark || f.name || `GID:${gid}`;
|
||||
const p = f.plant;
|
||||
|
||||
const stealNum = p ? toNum(p.steal_plant_num) : 0;
|
||||
const dryNum = p ? toNum(p.dry_num) : 0;
|
||||
const weedNum = p ? toNum(p.weed_num) : 0;
|
||||
const insectNum = p ? toNum(p.insect_num) : 0;
|
||||
|
||||
// 调试:显示指定好友的预览信息
|
||||
const showDebug = DEBUG_FRIEND_LANDS === true || DEBUG_FRIEND_LANDS === name;
|
||||
if (showDebug) {
|
||||
console.log(`[调试] 好友列表预览 [${name}]: steal=${stealNum} dry=${dryNum} weed=${weedNum} insect=${insectNum}`);
|
||||
}
|
||||
|
||||
// 只加入有预览信息的好友
|
||||
if (stealNum > 0 || dryNum > 0 || weedNum > 0 || insectNum > 0) {
|
||||
priorityFriends.push({ gid, name });
|
||||
visitedGids.add(gid);
|
||||
if (showDebug) {
|
||||
console.log(`[调试] 好友 [${name}] 加入优先列表 (位置: ${priorityFriends.length})`);
|
||||
}
|
||||
} else if (ENABLE_PUT_BAD_THINGS && canPutBugOrWeed) {
|
||||
// 没有预览信息但可以放虫放草(仅在开启放虫放草功能时)
|
||||
otherFriends.push({ gid, name });
|
||||
visitedGids.add(gid);
|
||||
}
|
||||
}
|
||||
|
||||
// 合并列表:优先好友在前
|
||||
const friendsToVisit = [...priorityFriends, ...otherFriends];
|
||||
|
||||
// 调试:检查目标好友位置
|
||||
if (DEBUG_FRIEND_LANDS && typeof DEBUG_FRIEND_LANDS === 'string') {
|
||||
const idx = friendsToVisit.findIndex(f => f.name === DEBUG_FRIEND_LANDS);
|
||||
if (idx >= 0) {
|
||||
const inPriority = idx < priorityFriends.length;
|
||||
console.log(`[调试] 好友 [${DEBUG_FRIEND_LANDS}] 位置: ${idx + 1}/${friendsToVisit.length} (${inPriority ? '优先列表' : '其他列表'})`);
|
||||
} else {
|
||||
console.log(`[调试] 好友 [${DEBUG_FRIEND_LANDS}] 不在待访问列表中!`);
|
||||
}
|
||||
}
|
||||
|
||||
if (friendsToVisit.length === 0) {
|
||||
// 无需操作时不输出日志
|
||||
return;
|
||||
}
|
||||
|
||||
let totalActions = { steal: 0, water: 0, weed: 0, bug: 0, putBug: 0, putWeed: 0 };
|
||||
for (let i = 0; i < friendsToVisit.length; i++) {
|
||||
const friend = friendsToVisit[i];
|
||||
const showDebug = DEBUG_FRIEND_LANDS === true || DEBUG_FRIEND_LANDS === friend.name;
|
||||
if (showDebug) {
|
||||
console.log(`[调试] 准备访问 [${friend.name}] (${i + 1}/${friendsToVisit.length})`);
|
||||
}
|
||||
try {
|
||||
await visitFriend(friend, totalActions, state.gid);
|
||||
} catch (e) {
|
||||
if (showDebug) {
|
||||
console.log(`[调试] 访问 [${friend.name}] 出错: ${e.message}`);
|
||||
}
|
||||
}
|
||||
await sleep(500);
|
||||
// 如果捣乱次数用完了,且没有其他操作,可以提前结束
|
||||
if (!canOperate(10004) && !canOperate(10003)) { // 10004=放虫, 10003=放草
|
||||
// 继续巡查,但不再放虫放草
|
||||
}
|
||||
}
|
||||
|
||||
// 只在有操作时输出日志
|
||||
const summary = [];
|
||||
if (totalActions.steal > 0) summary.push(`偷${totalActions.steal}`);
|
||||
if (totalActions.weed > 0) summary.push(`除草${totalActions.weed}`);
|
||||
if (totalActions.bug > 0) summary.push(`除虫${totalActions.bug}`);
|
||||
if (totalActions.water > 0) summary.push(`浇水${totalActions.water}`);
|
||||
if (totalActions.putBug > 0) summary.push(`放虫${totalActions.putBug}`);
|
||||
if (totalActions.putWeed > 0) summary.push(`放草${totalActions.putWeed}`);
|
||||
|
||||
if (summary.length > 0) {
|
||||
log('好友', `巡查 ${friendsToVisit.length} 人 → ${summary.join('/')}`);
|
||||
}
|
||||
isFirstFriendCheck = false;
|
||||
} catch (err) {
|
||||
logWarn('好友', `巡查失败: ${err.message}`);
|
||||
} finally {
|
||||
isCheckingFriends = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 好友巡查循环 - 本次完成后等待指定秒数再开始下次
|
||||
*/
|
||||
async function friendCheckLoop() {
|
||||
while (friendLoopRunning) {
|
||||
await checkFriends();
|
||||
if (!friendLoopRunning) break;
|
||||
await sleep(CONFIG.friendCheckInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function startFriendCheckLoop() {
|
||||
if (friendLoopRunning) return;
|
||||
friendLoopRunning = true;
|
||||
|
||||
// 注册操作限制更新回调,从农场检查中获取限制信息
|
||||
setOperationLimitsCallback(updateOperationLimits);
|
||||
|
||||
// 监听好友申请推送 (微信同玩)
|
||||
networkEvents.on('friendApplicationReceived', onFriendApplicationReceived);
|
||||
|
||||
// 延迟 5 秒后启动循环,等待登录和首次农场检查完成
|
||||
friendCheckTimer = setTimeout(() => friendCheckLoop(), 5000);
|
||||
|
||||
// 启动时检查一次待处理的好友申请
|
||||
setTimeout(() => checkAndAcceptApplications(), 3000);
|
||||
}
|
||||
|
||||
function stopFriendCheckLoop() {
|
||||
friendLoopRunning = false;
|
||||
networkEvents.off('friendApplicationReceived', onFriendApplicationReceived);
|
||||
if (friendCheckTimer) { clearTimeout(friendCheckTimer); friendCheckTimer = null; }
|
||||
}
|
||||
|
||||
// ============ 自动同意好友申请 (微信同玩) ============
|
||||
|
||||
/**
|
||||
* 处理服务器推送的好友申请
|
||||
*/
|
||||
function onFriendApplicationReceived(applications) {
|
||||
const names = applications.map(a => a.name || `GID:${toNum(a.gid)}`).join(', ');
|
||||
log('申请', `收到 ${applications.length} 个好友申请: ${names}`);
|
||||
|
||||
// 自动同意
|
||||
const gids = applications.map(a => toNum(a.gid));
|
||||
acceptFriendsWithRetry(gids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并同意所有待处理的好友申请
|
||||
*/
|
||||
async function checkAndAcceptApplications() {
|
||||
try {
|
||||
const reply = await getApplications();
|
||||
const applications = reply.applications || [];
|
||||
if (applications.length === 0) return;
|
||||
|
||||
const names = applications.map(a => a.name || `GID:${toNum(a.gid)}`).join(', ');
|
||||
log('申请', `发现 ${applications.length} 个待处理申请: ${names}`);
|
||||
|
||||
const gids = applications.map(a => toNum(a.gid));
|
||||
await acceptFriendsWithRetry(gids);
|
||||
} catch (e) {
|
||||
// 静默失败,可能是 QQ 平台不支持
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同意好友申请 (带重试)
|
||||
*/
|
||||
async function acceptFriendsWithRetry(gids) {
|
||||
if (gids.length === 0) return;
|
||||
try {
|
||||
const reply = await acceptFriends(gids);
|
||||
const friends = reply.friends || [];
|
||||
if (friends.length > 0) {
|
||||
const names = friends.map(f => f.name || f.remark || `GID:${toNum(f.gid)}`).join(', ');
|
||||
log('申请', `已同意 ${friends.length} 人: ${names}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logWarn('申请', `同意失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkFriends, startFriendCheckLoop, stopFriendCheckLoop,
|
||||
checkAndAcceptApplications,
|
||||
};
|
||||
269
211/server/src/gameConfig.js
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 游戏配置数据模块
|
||||
* 从 gameConfig 目录加载配置数据
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ============ 等级经验表 ============
|
||||
let roleLevelConfig = null;
|
||||
let levelExpTable = null; // 累计经验表,索引为等级
|
||||
|
||||
// ============ 植物配置 ============
|
||||
let plantConfig = null;
|
||||
let plantMap = new Map(); // id -> plant
|
||||
let seedToPlant = new Map(); // seed_id -> plant
|
||||
let fruitToPlant = new Map(); // fruit_id -> plant (果实ID -> 植物)
|
||||
let nameToPlant = new Map();
|
||||
|
||||
/**
|
||||
* 加载配置文件
|
||||
*/
|
||||
function loadConfigs() {
|
||||
const configDir = path.join(__dirname, '..', 'gameConfig');
|
||||
|
||||
// 加载等级经验配置
|
||||
try {
|
||||
const roleLevelPath = path.join(configDir, 'RoleLevel.json');
|
||||
if (fs.existsSync(roleLevelPath)) {
|
||||
roleLevelConfig = JSON.parse(fs.readFileSync(roleLevelPath, 'utf8'));
|
||||
// 构建累计经验表
|
||||
levelExpTable = [];
|
||||
for (const item of roleLevelConfig) {
|
||||
levelExpTable[item.level] = item.exp;
|
||||
}
|
||||
console.log(`[配置] 已加载等级经验表 (${roleLevelConfig.length} 级)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[配置] 加载 RoleLevel.json 失败:', e.message);
|
||||
}
|
||||
|
||||
// 加载植物配置
|
||||
try {
|
||||
const plantPath = path.join(configDir, 'Plant.json');
|
||||
if (fs.existsSync(plantPath)) {
|
||||
plantConfig = JSON.parse(fs.readFileSync(plantPath, 'utf8'));
|
||||
plantMap.clear();
|
||||
seedToPlant.clear();
|
||||
fruitToPlant.clear();
|
||||
nameToPlant.clear();
|
||||
for (const plant of plantConfig) {
|
||||
plantMap.set(plant.id, plant);
|
||||
if (plant.seed_id) {
|
||||
seedToPlant.set(plant.seed_id, plant);
|
||||
}
|
||||
if (plant.fruit && plant.fruit.id) {
|
||||
fruitToPlant.set(plant.fruit.id, plant);
|
||||
}
|
||||
if (plant.name) {
|
||||
nameToPlant.set(plant.name, plant);
|
||||
}
|
||||
}
|
||||
console.log(`[配置] 已加载植物配置 (${plantConfig.length} 种)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[配置] 加载 Plant.json 失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 等级经验相关 ============
|
||||
|
||||
/**
|
||||
* 获取等级经验表
|
||||
*/
|
||||
function getLevelExpTable() {
|
||||
return levelExpTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前等级的经验进度
|
||||
* @param {number} level - 当前等级
|
||||
* @param {number} totalExp - 累计总经验
|
||||
* @returns {{ current: number, needed: number }} 当前等级经验进度
|
||||
*/
|
||||
function getLevelExpProgress(level, totalExp) {
|
||||
if (!levelExpTable || level <= 0) return { current: 0, needed: 0 };
|
||||
|
||||
const currentLevelStart = levelExpTable[level] || 0;
|
||||
const nextLevelStart = levelExpTable[level + 1] || (currentLevelStart + 100000);
|
||||
|
||||
const currentExp = Math.max(0, totalExp - currentLevelStart);
|
||||
const neededExp = nextLevelStart - currentLevelStart;
|
||||
|
||||
return { current: currentExp, needed: neededExp };
|
||||
}
|
||||
|
||||
// ============ 植物配置相关 ============
|
||||
|
||||
/**
|
||||
* 根据植物ID获取植物信息
|
||||
* @param {number} plantId - 植物ID
|
||||
*/
|
||||
function getPlantById(plantId) {
|
||||
return plantMap.get(plantId);
|
||||
}
|
||||
|
||||
function getPlantByName(name) {
|
||||
return nameToPlant.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据种子ID获取植物信息
|
||||
* @param {number} seedId - 种子ID
|
||||
*/
|
||||
function getPlantBySeedId(seedId) {
|
||||
return seedToPlant.get(seedId);
|
||||
}
|
||||
|
||||
function getSeedIdByPlantName(name) {
|
||||
const plant = nameToPlant.get(name);
|
||||
if (!plant) return 0;
|
||||
return plant.seed_id || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取植物名称
|
||||
* @param {number} plantId - 植物ID
|
||||
*/
|
||||
function getPlantName(plantId) {
|
||||
const plant = plantMap.get(plantId);
|
||||
return plant ? plant.name : `植物${plantId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据种子ID获取植物名称
|
||||
* @param {number} seedId - 种子ID
|
||||
*/
|
||||
function getPlantNameBySeedId(seedId) {
|
||||
const plant = seedToPlant.get(seedId);
|
||||
return plant ? plant.name : `种子${seedId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取植物的果实信息
|
||||
* @param {number} plantId - 植物ID
|
||||
* @returns {{ id: number, count: number, name: string } | null}
|
||||
*/
|
||||
function getPlantFruit(plantId) {
|
||||
const plant = plantMap.get(plantId);
|
||||
if (!plant || !plant.fruit) return null;
|
||||
return {
|
||||
id: plant.fruit.id,
|
||||
count: plant.fruit.count,
|
||||
name: plant.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取植物的生长时间(秒)
|
||||
* @param {number} plantId - 植物ID
|
||||
*/
|
||||
function getPlantGrowTime(plantId) {
|
||||
const plant = plantMap.get(plantId);
|
||||
if (!plant || !plant.grow_phases) return 0;
|
||||
|
||||
// 解析 "种子:30;发芽:30;成熟:0;" 格式
|
||||
const phases = plant.grow_phases.split(';').filter(p => p);
|
||||
let totalSeconds = 0;
|
||||
for (const phase of phases) {
|
||||
const match = phase.match(/:(\d+)/);
|
||||
if (match) {
|
||||
totalSeconds += parseInt(match[1]);
|
||||
}
|
||||
}
|
||||
return totalSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
* @param {number} seconds - 秒数
|
||||
*/
|
||||
function formatGrowTime(seconds) {
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟`;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
return mins > 0 ? `${hours}小时${mins}分` : `${hours}小时`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取植物的收获经验
|
||||
* @param {number} plantId - 植物ID
|
||||
*/
|
||||
function getPlantExp(plantId) {
|
||||
const plant = plantMap.get(plantId);
|
||||
return plant ? plant.exp : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据果实ID获取植物名称
|
||||
* @param {number} fruitId - 果实ID
|
||||
*/
|
||||
function getFruitName(fruitId) {
|
||||
const plant = fruitToPlant.get(fruitId);
|
||||
return plant ? plant.name : `果实${fruitId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据果实ID获取植物信息
|
||||
* @param {number} fruitId - 果实ID
|
||||
*/
|
||||
function getPlantByFruitId(fruitId) {
|
||||
return fruitToPlant.get(fruitId);
|
||||
}
|
||||
|
||||
const ITEM_NAME_MAP = {
|
||||
1001: '金币',
|
||||
1002: '点券',
|
||||
1011: '普通化肥容器',
|
||||
1012: '有机化肥容器',
|
||||
1013: '友谊果实',
|
||||
1014: '穗华',
|
||||
1015: '幸运币',
|
||||
3001: '普通收藏点',
|
||||
1101: '种植经验',
|
||||
80001: '普通(1小时)',
|
||||
80011: '有机(1小时)',
|
||||
80002: '普通(4小时)',
|
||||
80003: '普通(8小时)',
|
||||
80004: '普通(12小时)',
|
||||
80012: '有机(4小时)',
|
||||
80013: '有机(8小时)',
|
||||
80014: '有机(12小时)',
|
||||
90004: '1天狗粮',
|
||||
90005: '3天狗粮',
|
||||
100003: '化肥礼包',
|
||||
};
|
||||
|
||||
function getItemNameById(id) {
|
||||
const name = ITEM_NAME_MAP[id];
|
||||
if (name) return name;
|
||||
return `物品${id}`;
|
||||
}
|
||||
|
||||
// 启动时加载配置
|
||||
loadConfigs();
|
||||
|
||||
module.exports = {
|
||||
loadConfigs,
|
||||
// 等级经验
|
||||
getLevelExpTable,
|
||||
getLevelExpProgress,
|
||||
// 植物配置
|
||||
getPlantById,
|
||||
getPlantByName,
|
||||
getPlantBySeedId,
|
||||
getSeedIdByPlantName,
|
||||
getPlantName,
|
||||
getPlantNameBySeedId,
|
||||
getPlantFruit,
|
||||
getPlantGrowTime,
|
||||
getPlantExp,
|
||||
formatGrowTime,
|
||||
// 果实配置
|
||||
getFruitName,
|
||||
getPlantByFruitId,
|
||||
// 物品配置
|
||||
getItemNameById,
|
||||
};
|
||||
161
211/server/src/invite.js
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 邀请码处理模块 - 读取 share.txt 并通过 ReportArkClick 申请好友
|
||||
* 注意:此功能仅在微信环境下有效
|
||||
*
|
||||
* 原理:
|
||||
* 1. 首次登录时,游戏会在 LoginRequest 中携带 sharer_id 和 sharer_open_id
|
||||
* 2. 已登录状态下点击分享链接,游戏会发送 ReportArkClickRequest
|
||||
* 3. 服务器收到后会自动向分享者发送好友申请
|
||||
*
|
||||
* 我们使用 ReportArkClickRequest 来模拟已登录状态下的分享链接点击
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { types } = require('./proto');
|
||||
const { sendMsgAsync } = require('./network');
|
||||
const { toLong, log, logWarn, sleep } = require('./utils');
|
||||
const { CONFIG } = require('./config');
|
||||
|
||||
/**
|
||||
* 解析分享链接,提取 uid 和 openid
|
||||
* 格式: ?uid=xxx&openid=xxx&share_source=xxx&doc_id=xxx
|
||||
*/
|
||||
function parseShareLink(link) {
|
||||
const result = { uid: null, openid: null, shareSource: null, docId: null };
|
||||
|
||||
// 移除开头的 ? 如果有
|
||||
const queryStr = link.startsWith('?') ? link.slice(1) : link;
|
||||
|
||||
// 解析参数
|
||||
const params = new URLSearchParams(queryStr);
|
||||
result.uid = params.get('uid');
|
||||
result.openid = params.get('openid');
|
||||
result.shareSource = params.get('share_source');
|
||||
result.docId = params.get('doc_id');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 share.txt 文件并去重
|
||||
*/
|
||||
function readShareFile() {
|
||||
const shareFilePath = path.join(__dirname, '..', 'share.txt');
|
||||
|
||||
if (!fs.existsSync(shareFilePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(shareFilePath, 'utf8');
|
||||
const lines = content.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.includes('openid='));
|
||||
|
||||
const invites = [];
|
||||
const seenUids = new Set(); // 用于去重
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseShareLink(line);
|
||||
if (parsed.openid && parsed.uid) {
|
||||
// 按 uid 去重,同一个用户只处理一次
|
||||
if (!seenUids.has(parsed.uid)) {
|
||||
seenUids.add(parsed.uid);
|
||||
invites.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return invites;
|
||||
} catch (e) {
|
||||
logWarn('邀请', `读取 share.txt 失败: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 ReportArkClick 请求
|
||||
* 模拟已登录状态下点击分享链接,触发服务器向分享者发送好友申请
|
||||
*/
|
||||
async function sendReportArkClick(sharerId, sharerOpenId, shareSource) {
|
||||
const body = types.ReportArkClickRequest.encode(types.ReportArkClickRequest.create({
|
||||
sharer_id: toLong(sharerId),
|
||||
sharer_open_id: sharerOpenId,
|
||||
share_cfg_id: toLong(shareSource || 0),
|
||||
scene_id: '1256', // 模拟微信场景
|
||||
})).finish();
|
||||
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.userpb.UserService', 'ReportArkClick', body);
|
||||
return types.ReportArkClickReply.decode(replyBody);
|
||||
}
|
||||
|
||||
// 请求间隔时间(毫秒)
|
||||
const INVITE_REQUEST_DELAY = 2000;
|
||||
|
||||
/**
|
||||
* 处理邀请码列表
|
||||
* 仅在微信环境下执行
|
||||
*/
|
||||
async function processInviteCodes() {
|
||||
// 检查是否为微信环境
|
||||
if (CONFIG.platform !== 'wx') {
|
||||
log('邀请', '当前为 QQ 环境,跳过邀请码处理(仅微信支持)');
|
||||
return;
|
||||
}
|
||||
|
||||
const invites = readShareFile();
|
||||
if (invites.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
log('邀请', `读取到 ${invites.length} 个邀请码(已去重),开始逐个处理...`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (let i = 0; i < invites.length; i++) {
|
||||
const invite = invites[i];
|
||||
|
||||
try {
|
||||
// 发送 ReportArkClick 请求,模拟点击分享链接
|
||||
await sendReportArkClick(invite.uid, invite.openid, invite.shareSource);
|
||||
successCount++;
|
||||
log('邀请', `[${i + 1}/${invites.length}] 已向 uid=${invite.uid} 发送好友申请`);
|
||||
} catch (e) {
|
||||
failCount++;
|
||||
logWarn('邀请', `[${i + 1}/${invites.length}] 向 uid=${invite.uid} 发送申请失败: ${e.message}`);
|
||||
}
|
||||
|
||||
// 每个请求之间延迟,避免请求过快被限流
|
||||
if (i < invites.length - 1) {
|
||||
await sleep(INVITE_REQUEST_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
log('邀请', `处理完成: 成功 ${successCount}, 失败 ${failCount}`);
|
||||
|
||||
// 处理完成后清空文件
|
||||
clearShareFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空已处理的邀请码文件
|
||||
*/
|
||||
function clearShareFile() {
|
||||
const shareFilePath = path.join(__dirname, '..', 'share.txt');
|
||||
try {
|
||||
fs.writeFileSync(shareFilePath, '', 'utf8');
|
||||
log('邀请', '已清空 share.txt');
|
||||
} catch (e) {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseShareLink,
|
||||
readShareFile,
|
||||
sendReportArkClick,
|
||||
processInviteCodes,
|
||||
clearShareFile,
|
||||
};
|
||||
461
211/server/src/network.js
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* WebSocket 网络层 - 连接/消息编解码/登录/心跳
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const EventEmitter = require('events');
|
||||
const { CONFIG } = require('./config');
|
||||
const { types } = require('./proto');
|
||||
const { toLong, toNum, syncServerTime, log, logWarn } = require('./utils');
|
||||
const { updateStatusFromLogin, updateStatusGold, updateStatusLevel } = require('./status');
|
||||
|
||||
// ============ 事件发射器 (用于推送通知) ============
|
||||
const networkEvents = new EventEmitter();
|
||||
|
||||
// ============ 内部状态 ============
|
||||
let ws = null;
|
||||
let clientSeq = 1;
|
||||
let serverSeq = 0;
|
||||
let heartbeatTimer = null;
|
||||
let pendingCallbacks = new Map();
|
||||
|
||||
// ============ 用户状态 (登录后设置) ============
|
||||
const userState = {
|
||||
gid: 0,
|
||||
name: '',
|
||||
level: 0,
|
||||
gold: 0,
|
||||
exp: 0,
|
||||
};
|
||||
|
||||
function getUserState() { return userState; }
|
||||
|
||||
// ============ 消息编解码 ============
|
||||
function encodeMsg(serviceName, methodName, bodyBytes) {
|
||||
const msg = types.GateMessage.create({
|
||||
meta: {
|
||||
service_name: serviceName,
|
||||
method_name: methodName,
|
||||
message_type: 1,
|
||||
client_seq: toLong(clientSeq),
|
||||
server_seq: toLong(serverSeq),
|
||||
},
|
||||
body: bodyBytes || Buffer.alloc(0),
|
||||
});
|
||||
const encoded = types.GateMessage.encode(msg).finish();
|
||||
clientSeq++;
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function sendMsg(serviceName, methodName, bodyBytes, callback) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
log('WS', '连接未打开');
|
||||
return false;
|
||||
}
|
||||
const seq = clientSeq;
|
||||
const encoded = encodeMsg(serviceName, methodName, bodyBytes);
|
||||
if (callback) pendingCallbacks.set(seq, callback);
|
||||
ws.send(encoded);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Promise 版发送 */
|
||||
function sendMsgAsync(serviceName, methodName, bodyBytes, timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 检查连接状态
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error(`连接未打开: ${methodName}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = clientSeq;
|
||||
const timer = setTimeout(() => {
|
||||
pendingCallbacks.delete(seq);
|
||||
// 检查当前待处理的请求数
|
||||
const pending = pendingCallbacks.size;
|
||||
reject(new Error(`请求超时: ${methodName} (seq=${seq}, pending=${pending})`));
|
||||
}, timeout);
|
||||
|
||||
const sent = sendMsg(serviceName, methodName, bodyBytes, (err, body, meta) => {
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve({ body, meta });
|
||||
});
|
||||
|
||||
if (!sent) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`发送失败: ${methodName}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 消息处理 ============
|
||||
function handleMessage(data) {
|
||||
try {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
const msg = types.GateMessage.decode(buf);
|
||||
const meta = msg.meta;
|
||||
if (!meta) return;
|
||||
|
||||
if (meta.server_seq) {
|
||||
const seq = toNum(meta.server_seq);
|
||||
if (seq > serverSeq) serverSeq = seq;
|
||||
}
|
||||
|
||||
const msgType = meta.message_type;
|
||||
|
||||
// Notify
|
||||
if (msgType === 3) {
|
||||
handleNotify(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Response
|
||||
if (msgType === 2) {
|
||||
const errorCode = toNum(meta.error_code);
|
||||
const clientSeqVal = toNum(meta.client_seq);
|
||||
|
||||
const cb = pendingCallbacks.get(clientSeqVal);
|
||||
if (cb) {
|
||||
pendingCallbacks.delete(clientSeqVal);
|
||||
if (errorCode !== 0) {
|
||||
cb(new Error(`${meta.service_name}.${meta.method_name} 错误: code=${errorCode} ${meta.error_message || ''}`));
|
||||
} else {
|
||||
cb(null, msg.body, meta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode !== 0) {
|
||||
logWarn('错误', `${meta.service_name}.${meta.method_name} code=${errorCode} ${meta.error_message || ''}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logWarn('解码', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 调试:记录所有推送类型 (设为 true 可查看所有推送)
|
||||
// 注意:QQ环境下只有 ItemNotify 推送,没有 LandsNotify 推送
|
||||
const DEBUG_NOTIFY = false;
|
||||
|
||||
function handleNotify(msg) {
|
||||
if (!msg.body || msg.body.length === 0) return;
|
||||
try {
|
||||
const event = types.EventMessage.decode(msg.body);
|
||||
const type = event.message_type || '';
|
||||
const eventBody = event.body;
|
||||
|
||||
// 调试:显示所有推送类型
|
||||
if (DEBUG_NOTIFY) {
|
||||
console.log(`[DEBUG] 收到推送: ${type}`);
|
||||
}
|
||||
|
||||
// 被踢下线
|
||||
if (type.includes('Kickout')) {
|
||||
log('推送', `被踢下线! ${type}`);
|
||||
try {
|
||||
const notify = types.KickoutNotify.decode(eventBody);
|
||||
log('推送', `原因: ${notify.reason_message || '未知'}`);
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 土地状态变化 (被放虫/放草/偷菜等)
|
||||
if (type.includes('LandsNotify')) {
|
||||
try {
|
||||
const notify = types.LandsNotify.decode(eventBody);
|
||||
const hostGid = toNum(notify.host_gid);
|
||||
const lands = notify.lands || [];
|
||||
if (DEBUG_NOTIFY) {
|
||||
console.log(`[DEBUG] LandsNotify: hostGid=${hostGid}, myGid=${userState.gid}, lands=${lands.length}`);
|
||||
}
|
||||
if (lands.length > 0) {
|
||||
// 如果是自己的农场,触发事件
|
||||
if (hostGid === userState.gid || hostGid === 0) {
|
||||
networkEvents.emit('landsChanged', lands);
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 物品变化通知 (经验/金币等) - 仅更新状态栏
|
||||
// 金币: id=1 或 id=1001 (GodItemId)
|
||||
// 经验: id=1101 (ExpItemId) 或 id=2
|
||||
if (type.includes('ItemNotify')) {
|
||||
try {
|
||||
const notify = types.ItemNotify.decode(eventBody);
|
||||
const items = notify.items || [];
|
||||
for (const itemChg of items) {
|
||||
const item = itemChg.item;
|
||||
if (!item) continue;
|
||||
const id = toNum(item.id);
|
||||
const count = toNum(item.count);
|
||||
|
||||
if (id === 1101 || id === 2) {
|
||||
userState.exp = count;
|
||||
updateStatusLevel(userState.level, count);
|
||||
} else if (id === 1 || id === 1001) {
|
||||
userState.gold = count;
|
||||
updateStatusGold(count);
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 基本信息变化 (升级等)
|
||||
if (type.includes('BasicNotify')) {
|
||||
try {
|
||||
const notify = types.BasicNotify.decode(eventBody);
|
||||
if (notify.basic) {
|
||||
const oldLevel = userState.level;
|
||||
const oldExp = userState.exp || 0;
|
||||
userState.level = toNum(notify.basic.level) || userState.level;
|
||||
userState.gold = toNum(notify.basic.gold) || userState.gold;
|
||||
const exp = toNum(notify.basic.exp);
|
||||
if (exp > 0) {
|
||||
userState.exp = exp;
|
||||
updateStatusLevel(userState.level, exp);
|
||||
}
|
||||
updateStatusGold(userState.gold);
|
||||
// 升级提示
|
||||
if (userState.level !== oldLevel) {
|
||||
log('系统', `升级! Lv${oldLevel} → Lv${userState.level}`);
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 好友申请通知 (微信同玩)
|
||||
if (type.includes('FriendApplicationReceivedNotify')) {
|
||||
try {
|
||||
const notify = types.FriendApplicationReceivedNotify.decode(eventBody);
|
||||
const applications = notify.applications || [];
|
||||
if (applications.length > 0) {
|
||||
networkEvents.emit('friendApplicationReceived', applications);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 好友添加成功通知
|
||||
if (type.includes('FriendAddedNotify')) {
|
||||
try {
|
||||
const notify = types.FriendAddedNotify.decode(eventBody);
|
||||
const friends = notify.friends || [];
|
||||
if (friends.length > 0) {
|
||||
const names = friends.map(f => f.name || f.remark || `GID:${toNum(f.gid)}`).join(', ');
|
||||
log('好友', `新好友: ${names}`);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 物品变化通知 (收获/购买/消耗等)
|
||||
if (type.includes('ItemNotify')) {
|
||||
try {
|
||||
const notify = types.ItemNotify.decode(eventBody);
|
||||
const items = notify.items || [];
|
||||
for (const chg of items) {
|
||||
if (!chg.item) continue;
|
||||
const id = toNum(chg.item.id);
|
||||
const count = toNum(chg.item.count);
|
||||
const delta = toNum(chg.delta);
|
||||
// 金币 ID=1
|
||||
if (id === 1) {
|
||||
userState.gold = count;
|
||||
updateStatusGold(count);
|
||||
if (delta !== 0) {
|
||||
log('物品', `金币 ${delta > 0 ? '+' : ''}${delta} (当前: ${count})`);
|
||||
}
|
||||
}
|
||||
// 经验 ID=2 (升级由 BasicNotify 处理)
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 商品解锁通知 (升级后解锁新种子等)
|
||||
if (type.includes('GoodsUnlockNotify')) {
|
||||
try {
|
||||
const notify = types.GoodsUnlockNotify.decode(eventBody);
|
||||
const goods = notify.goods_list || [];
|
||||
if (goods.length > 0) {
|
||||
log('商店', `解锁 ${goods.length} 个新商品!`);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 任务状态变化通知
|
||||
if (type.includes('TaskInfoNotify')) {
|
||||
try {
|
||||
const notify = types.TaskInfoNotify.decode(eventBody);
|
||||
if (notify.task_info) {
|
||||
networkEvents.emit('taskInfoNotify', notify.task_info);
|
||||
}
|
||||
} catch (e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他未处理的推送类型 (调试用)
|
||||
// log('推送', `未处理类型: ${type}`);
|
||||
} catch (e) {
|
||||
logWarn('推送', `解码失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 登录 ============
|
||||
function sendLogin(onLoginSuccess) {
|
||||
const body = types.LoginRequest.encode(types.LoginRequest.create({
|
||||
sharer_id: toLong(0),
|
||||
sharer_open_id: '',
|
||||
device_info: {
|
||||
client_version: CONFIG.clientVersion,
|
||||
sys_software: 'iOS 26.2.1',
|
||||
network: 'wifi',
|
||||
memory: '7672',
|
||||
device_id: 'iPhone X<iPhone18,3>',
|
||||
},
|
||||
share_cfg_id: toLong(0),
|
||||
scene_id: '1256',
|
||||
report_data: {
|
||||
callback: '', cd_extend_info: '', click_id: '', clue_token: '',
|
||||
minigame_channel: 'other', minigame_platid: 2, req_id: '', trackid: '',
|
||||
},
|
||||
})).finish();
|
||||
|
||||
sendMsg('gamepb.userpb.UserService', 'Login', body, (err, bodyBytes, meta) => {
|
||||
if (err) {
|
||||
log('登录', `失败: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const reply = types.LoginReply.decode(bodyBytes);
|
||||
if (reply.basic) {
|
||||
userState.gid = toNum(reply.basic.gid);
|
||||
userState.name = reply.basic.name || '未知';
|
||||
userState.level = toNum(reply.basic.level);
|
||||
userState.gold = toNum(reply.basic.gold);
|
||||
userState.exp = toNum(reply.basic.exp);
|
||||
|
||||
// 更新状态栏
|
||||
updateStatusFromLogin({
|
||||
name: userState.name,
|
||||
level: userState.level,
|
||||
gold: userState.gold,
|
||||
exp: userState.exp,
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('========== 登录成功 ==========');
|
||||
console.log(` GID: ${userState.gid}`);
|
||||
console.log(` 昵称: ${userState.name}`);
|
||||
console.log(` 等级: ${userState.level}`);
|
||||
console.log(` 金币: ${userState.gold}`);
|
||||
if (reply.time_now_millis) {
|
||||
syncServerTime(toNum(reply.time_now_millis));
|
||||
console.log(` 时间: ${new Date(toNum(reply.time_now_millis)).toLocaleString()}`);
|
||||
}
|
||||
console.log('===============================');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
startHeartbeat();
|
||||
if (onLoginSuccess) onLoginSuccess();
|
||||
} catch (e) {
|
||||
log('登录', `解码失败: ${e.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 心跳 ============
|
||||
let lastHeartbeatResponse = Date.now();
|
||||
let heartbeatMissCount = 0;
|
||||
|
||||
function startHeartbeat() {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
lastHeartbeatResponse = Date.now();
|
||||
heartbeatMissCount = 0;
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (!userState.gid) return;
|
||||
|
||||
// 检查上次心跳响应时间,超过 60 秒没响应说明连接有问题
|
||||
const timeSinceLastResponse = Date.now() - lastHeartbeatResponse;
|
||||
if (timeSinceLastResponse > 60000) {
|
||||
heartbeatMissCount++;
|
||||
logWarn('心跳', `连接可能已断开 (${Math.round(timeSinceLastResponse/1000)}s 无响应, pending=${pendingCallbacks.size})`);
|
||||
if (heartbeatMissCount >= 2) {
|
||||
log('心跳', '尝试重连...');
|
||||
// 清理待处理的回调,避免堆积
|
||||
pendingCallbacks.forEach((cb, seq) => {
|
||||
try { cb(new Error('连接超时,已清理')); } catch (e) {}
|
||||
});
|
||||
pendingCallbacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const body = types.HeartbeatRequest.encode(types.HeartbeatRequest.create({
|
||||
gid: toLong(userState.gid),
|
||||
client_version: CONFIG.clientVersion,
|
||||
})).finish();
|
||||
sendMsg('gamepb.userpb.UserService', 'Heartbeat', body, (err, replyBody) => {
|
||||
if (err || !replyBody) return;
|
||||
lastHeartbeatResponse = Date.now();
|
||||
heartbeatMissCount = 0;
|
||||
try {
|
||||
const reply = types.HeartbeatReply.decode(replyBody);
|
||||
if (reply.server_time) syncServerTime(toNum(reply.server_time));
|
||||
} catch (e) { }
|
||||
});
|
||||
}, CONFIG.heartbeatInterval);
|
||||
}
|
||||
|
||||
// ============ WebSocket 连接 ============
|
||||
function connect(code, onLoginSuccess) {
|
||||
const url = `${CONFIG.serverUrl}?platform=${CONFIG.platform}&os=${CONFIG.os}&ver=${CONFIG.clientVersion}&code=${code}&openID=`;
|
||||
|
||||
ws = new WebSocket(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090a13)',
|
||||
'Origin': 'https://gate-obt.nqf.qq.com',
|
||||
},
|
||||
});
|
||||
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.on('open', () => {
|
||||
sendLogin(onLoginSuccess);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
handleMessage(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log(`[WS] 连接关闭 (code=${code})`);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logWarn('WS', `错误: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
||||
pendingCallbacks.clear();
|
||||
}
|
||||
|
||||
function getWs() { return ws; }
|
||||
|
||||
module.exports = {
|
||||
connect, cleanup, getWs,
|
||||
sendMsg, sendMsgAsync,
|
||||
getUserState,
|
||||
networkEvents,
|
||||
};
|
||||
121
211/server/src/proto.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Proto 加载与消息类型管理
|
||||
*/
|
||||
|
||||
const protobuf = require('protobufjs');
|
||||
const path = require('path');
|
||||
const { log } = require('./utils');
|
||||
|
||||
// Proto 根对象与所有消息类型
|
||||
let root = null;
|
||||
const types = {};
|
||||
|
||||
async function loadProto() {
|
||||
const protoDir = path.join(__dirname, '..', 'proto');
|
||||
root = new protobuf.Root();
|
||||
await root.load([
|
||||
path.join(protoDir, 'game.proto'),
|
||||
path.join(protoDir, 'userpb.proto'),
|
||||
path.join(protoDir, 'plantpb.proto'),
|
||||
path.join(protoDir, 'corepb.proto'),
|
||||
path.join(protoDir, 'shoppb.proto'),
|
||||
path.join(protoDir, 'friendpb.proto'),
|
||||
path.join(protoDir, 'visitpb.proto'),
|
||||
path.join(protoDir, 'notifypb.proto'),
|
||||
path.join(protoDir, 'taskpb.proto'),
|
||||
path.join(protoDir, 'itempb.proto'),
|
||||
], { keepCase: true });
|
||||
|
||||
// 网关
|
||||
types.GateMessage = root.lookupType('gatepb.Message');
|
||||
types.GateMeta = root.lookupType('gatepb.Meta');
|
||||
types.EventMessage = root.lookupType('gatepb.EventMessage');
|
||||
|
||||
// 用户
|
||||
types.LoginRequest = root.lookupType('gamepb.userpb.LoginRequest');
|
||||
types.LoginReply = root.lookupType('gamepb.userpb.LoginReply');
|
||||
types.HeartbeatRequest = root.lookupType('gamepb.userpb.HeartbeatRequest');
|
||||
types.HeartbeatReply = root.lookupType('gamepb.userpb.HeartbeatReply');
|
||||
types.ReportArkClickRequest = root.lookupType('gamepb.userpb.ReportArkClickRequest');
|
||||
types.ReportArkClickReply = root.lookupType('gamepb.userpb.ReportArkClickReply');
|
||||
|
||||
// 农场
|
||||
types.AllLandsRequest = root.lookupType('gamepb.plantpb.AllLandsRequest');
|
||||
types.AllLandsReply = root.lookupType('gamepb.plantpb.AllLandsReply');
|
||||
types.HarvestRequest = root.lookupType('gamepb.plantpb.HarvestRequest');
|
||||
types.HarvestReply = root.lookupType('gamepb.plantpb.HarvestReply');
|
||||
types.WaterLandRequest = root.lookupType('gamepb.plantpb.WaterLandRequest');
|
||||
types.WaterLandReply = root.lookupType('gamepb.plantpb.WaterLandReply');
|
||||
types.WeedOutRequest = root.lookupType('gamepb.plantpb.WeedOutRequest');
|
||||
types.WeedOutReply = root.lookupType('gamepb.plantpb.WeedOutReply');
|
||||
types.InsecticideRequest = root.lookupType('gamepb.plantpb.InsecticideRequest');
|
||||
types.InsecticideReply = root.lookupType('gamepb.plantpb.InsecticideReply');
|
||||
types.RemovePlantRequest = root.lookupType('gamepb.plantpb.RemovePlantRequest');
|
||||
types.RemovePlantReply = root.lookupType('gamepb.plantpb.RemovePlantReply');
|
||||
types.PutInsectsRequest = root.lookupType('gamepb.plantpb.PutInsectsRequest');
|
||||
types.PutInsectsReply = root.lookupType('gamepb.plantpb.PutInsectsReply');
|
||||
types.PutWeedsRequest = root.lookupType('gamepb.plantpb.PutWeedsRequest');
|
||||
types.PutWeedsReply = root.lookupType('gamepb.plantpb.PutWeedsReply');
|
||||
types.FertilizeRequest = root.lookupType('gamepb.plantpb.FertilizeRequest');
|
||||
types.FertilizeReply = root.lookupType('gamepb.plantpb.FertilizeReply');
|
||||
|
||||
// 背包/仓库
|
||||
types.BagRequest = root.lookupType('gamepb.itempb.BagRequest');
|
||||
types.BagReply = root.lookupType('gamepb.itempb.BagReply');
|
||||
types.SellRequest = root.lookupType('gamepb.itempb.SellRequest');
|
||||
types.SellReply = root.lookupType('gamepb.itempb.SellReply');
|
||||
types.UseRequest = root.lookupType('gamepb.itempb.UseRequest');
|
||||
types.UseReply = root.lookupType('gamepb.itempb.UseReply');
|
||||
types.BatchUseRequest = root.lookupType('gamepb.itempb.BatchUseRequest');
|
||||
types.BatchUseReply = root.lookupType('gamepb.itempb.BatchUseReply');
|
||||
types.PlantRequest = root.lookupType('gamepb.plantpb.PlantRequest');
|
||||
types.PlantReply = root.lookupType('gamepb.plantpb.PlantReply');
|
||||
|
||||
// 商店
|
||||
types.ShopProfilesRequest = root.lookupType('gamepb.shoppb.ShopProfilesRequest');
|
||||
types.ShopProfilesReply = root.lookupType('gamepb.shoppb.ShopProfilesReply');
|
||||
types.ShopInfoRequest = root.lookupType('gamepb.shoppb.ShopInfoRequest');
|
||||
types.ShopInfoReply = root.lookupType('gamepb.shoppb.ShopInfoReply');
|
||||
types.BuyGoodsRequest = root.lookupType('gamepb.shoppb.BuyGoodsRequest');
|
||||
types.BuyGoodsReply = root.lookupType('gamepb.shoppb.BuyGoodsReply');
|
||||
|
||||
// 好友
|
||||
types.GetAllFriendsRequest = root.lookupType('gamepb.friendpb.GetAllRequest');
|
||||
types.GetAllFriendsReply = root.lookupType('gamepb.friendpb.GetAllReply');
|
||||
types.GetApplicationsRequest = root.lookupType('gamepb.friendpb.GetApplicationsRequest');
|
||||
types.GetApplicationsReply = root.lookupType('gamepb.friendpb.GetApplicationsReply');
|
||||
types.AcceptFriendsRequest = root.lookupType('gamepb.friendpb.AcceptFriendsRequest');
|
||||
types.AcceptFriendsReply = root.lookupType('gamepb.friendpb.AcceptFriendsReply');
|
||||
|
||||
// 访问
|
||||
types.VisitEnterRequest = root.lookupType('gamepb.visitpb.EnterRequest');
|
||||
types.VisitEnterReply = root.lookupType('gamepb.visitpb.EnterReply');
|
||||
types.VisitLeaveRequest = root.lookupType('gamepb.visitpb.LeaveRequest');
|
||||
types.VisitLeaveReply = root.lookupType('gamepb.visitpb.LeaveReply');
|
||||
|
||||
// 任务
|
||||
types.TaskInfoRequest = root.lookupType('gamepb.taskpb.TaskInfoRequest');
|
||||
types.TaskInfoReply = root.lookupType('gamepb.taskpb.TaskInfoReply');
|
||||
types.ClaimTaskRewardRequest = root.lookupType('gamepb.taskpb.ClaimTaskRewardRequest');
|
||||
types.ClaimTaskRewardReply = root.lookupType('gamepb.taskpb.ClaimTaskRewardReply');
|
||||
types.BatchClaimTaskRewardRequest = root.lookupType('gamepb.taskpb.BatchClaimTaskRewardRequest');
|
||||
types.BatchClaimTaskRewardReply = root.lookupType('gamepb.taskpb.BatchClaimTaskRewardReply');
|
||||
|
||||
// 服务器推送通知
|
||||
types.LandsNotify = root.lookupType('gamepb.plantpb.LandsNotify');
|
||||
types.BasicNotify = root.lookupType('gamepb.userpb.BasicNotify');
|
||||
types.KickoutNotify = root.lookupType('gatepb.KickoutNotify');
|
||||
types.FriendApplicationReceivedNotify = root.lookupType('gamepb.friendpb.FriendApplicationReceivedNotify');
|
||||
types.FriendAddedNotify = root.lookupType('gamepb.friendpb.FriendAddedNotify');
|
||||
types.ItemNotify = root.lookupType('gamepb.itempb.ItemNotify');
|
||||
types.GoodsUnlockNotify = root.lookupType('gamepb.shoppb.GoodsUnlockNotify');
|
||||
types.TaskInfoNotify = root.lookupType('gamepb.taskpb.TaskInfoNotify');
|
||||
|
||||
// Proto 加载完成
|
||||
}
|
||||
|
||||
function getRoot() {
|
||||
return root;
|
||||
}
|
||||
|
||||
module.exports = { loadProto, types, getRoot };
|
||||
314
211/server/src/qrlib/session.js
Normal file
@@ -0,0 +1,314 @@
|
||||
const axios = require('axios');
|
||||
const { CookieUtils, HashUtils } = require('./utils');
|
||||
|
||||
// User Agent Definition
|
||||
const ChromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
class QRLoginSession {
|
||||
/**
|
||||
* Presets for different login targets
|
||||
*/
|
||||
static Presets = {
|
||||
vip: {
|
||||
name: 'QQ会员 (VIP)',
|
||||
description: 'QQ会员官网',
|
||||
aid: '8000201',
|
||||
daid: '18',
|
||||
redirectUri: 'https://vip.qq.com/loginsuccess.html',
|
||||
referrer: 'https://xui.ptlogin2.qq.com/cgi-bin/xlogin?appid=8000201&style=20&s_url=https%3A%2F%2Fvip.qq.com%2Floginsuccess.html&maskOpacity=60&daid=18&target=self',
|
||||
},
|
||||
qzone: {
|
||||
name: 'QQ空间 (QZone)',
|
||||
description: 'QQ空间网页版',
|
||||
aid: '549000912',
|
||||
daid: '5',
|
||||
redirectUri: 'https://qzs.qzone.qq.com/qzone/v5/loginsucc.html?para=izone',
|
||||
referrer: 'https://qzone.qq.com/',
|
||||
},
|
||||
music: {
|
||||
name: 'QQ音乐 (Music)',
|
||||
description: 'QQ音乐网页版',
|
||||
aid: '716027609',
|
||||
daid: '383',
|
||||
redirectUri: 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https%3A%2F%2Fy.qq.com%2F',
|
||||
ptThirdAid: '100497308',
|
||||
responseType: 'code',
|
||||
openapi: '1010_1030',
|
||||
},
|
||||
wegame: {
|
||||
name: 'WeGame',
|
||||
description: 'WeGame 平台',
|
||||
aid: '1600001063',
|
||||
daid: '733',
|
||||
redirectUri: 'https://www.wegame.com.cn/middle/login/third_callback.html',
|
||||
referrer: 'https://www.wegame.com.cn/',
|
||||
},
|
||||
val: {
|
||||
name: '瓦罗兰特 (VAL)',
|
||||
description: '无畏契约官网',
|
||||
aid: '716027609',
|
||||
daid: '383',
|
||||
redirectUri: 'https://val.qq.com/comm-htdocs/login/qc_redirect.html?parent_domain=https%3A%2F%2Fval.qq.com&isMiloSDK=1&isPc=1',
|
||||
ptThirdAid: '102059301',
|
||||
responseType: 'code',
|
||||
openapi: '1010_1030',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Request a new QR Code
|
||||
* @param {string} presetKey - The key of the preset to use (vip, qzone, etc)
|
||||
*/
|
||||
static async requestQRCode(presetKey) {
|
||||
const config = this.Presets[presetKey] || this.Presets.vip;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
appid: config.aid,
|
||||
e: '2',
|
||||
l: 'M',
|
||||
s: '3',
|
||||
d: '72',
|
||||
v: '4',
|
||||
t: String(Math.random()),
|
||||
daid: config.daid,
|
||||
});
|
||||
|
||||
if (config.ptThirdAid) {
|
||||
params.set('pt_3rd_aid', config.ptThirdAid);
|
||||
params.set('u1', 'https://graph.qq.com/oauth2.0/login_jump');
|
||||
} else {
|
||||
params.set('u1', config.redirectUri);
|
||||
}
|
||||
|
||||
const url = `https://ssl.ptlogin2.qq.com/ptqrshow?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'Referer': config.referrer || `https://xui.ptlogin2.qq.com/cgi-bin/xlogin?appid=${config.aid}&style=20&s_url=${encodeURIComponent(config.redirectUri)}&maskOpacity=60&daid=${config.daid}&target=self`,
|
||||
'User-Agent': ChromeUA,
|
||||
}
|
||||
});
|
||||
|
||||
const setCookie = response.headers['set-cookie'];
|
||||
const qrsig = CookieUtils.getValue(setCookie, 'qrsig');
|
||||
const qrcodeBase64 = Buffer.from(response.data).toString('base64');
|
||||
|
||||
return { qrsig, qrcode: `data:image/png;base64,${qrcodeBase64}`, url };
|
||||
} catch (error) {
|
||||
console.error('Request QRCode Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status of the QR Code
|
||||
* @param {string} qrsig
|
||||
* @param {string} presetKey
|
||||
*/
|
||||
static async checkStatus(qrsig, presetKey) {
|
||||
const config = this.Presets[presetKey] || this.Presets.vip;
|
||||
const ptqrtoken = HashUtils.hash(qrsig);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
ptqrtoken: String(ptqrtoken),
|
||||
from_ui: '1',
|
||||
aid: config.aid,
|
||||
daid: config.daid,
|
||||
action: `0-0-${Date.now()}`, // Added timestamp
|
||||
pt_uistyle: '40',
|
||||
js_ver: '21020514',
|
||||
js_type: '1'
|
||||
});
|
||||
|
||||
if (config.ptThirdAid) {
|
||||
params.set('pt_3rd_aid', config.ptThirdAid);
|
||||
params.set('u1', 'https://graph.qq.com/oauth2.0/login_jump');
|
||||
} else {
|
||||
params.set('u1', config.redirectUri);
|
||||
}
|
||||
|
||||
const api = `https://ssl.ptlogin2.qq.com/ptqrlogin?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(api, {
|
||||
headers: {
|
||||
'Cookie': `qrsig=${qrsig}`,
|
||||
'Referer': config.referrer || 'https://xui.ptlogin2.qq.com/',
|
||||
'User-Agent': ChromeUA,
|
||||
},
|
||||
});
|
||||
|
||||
const text = response.data;
|
||||
// Parse response: ptuiCB('66','0','','0','二维码未失效。(3776510309)', '')
|
||||
// Robust parsing using Regex to handle commas in content
|
||||
const matcher = /ptuiCB\((.+)\)/;
|
||||
const match = text.match(matcher);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
|
||||
// Extract arguments: 'arg1', 'arg2', ...
|
||||
// This regex matches single-quoted strings: '([^']*)'
|
||||
const args = [];
|
||||
const argMatcher = /'([^']*)'/g;
|
||||
let argMatch;
|
||||
while ((argMatch = argMatcher.exec(match[1])) !== null) {
|
||||
args.push(argMatch[1]);
|
||||
}
|
||||
|
||||
const [ret, extret, jumpUrl, redirect, msg, nickname] = args;
|
||||
|
||||
return {
|
||||
ret,
|
||||
msg,
|
||||
nickname,
|
||||
jumpUrl,
|
||||
cookie: response.headers['set-cookie'] // Return cookies to frontend if success
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Check Status Error:', error);
|
||||
throw new Error('Check status failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get final cookies from the successful jump URL
|
||||
* @param {string} jumpUrl
|
||||
*/
|
||||
static async getFinalCookies(jumpUrl) {
|
||||
try {
|
||||
// Prevent auto redirect to capture cookies
|
||||
const response = await axios.get(jumpUrl, {
|
||||
maxRedirects: 0,
|
||||
validateStatus: status => status >= 200 && status < 400,
|
||||
headers: {
|
||||
'User-Agent': ChromeUA
|
||||
}
|
||||
});
|
||||
|
||||
// This might return 302 Found
|
||||
return response.headers['set-cookie'];
|
||||
} catch (error) {
|
||||
console.error("Get Final Cookies Error", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MiniProgramLoginSession {
|
||||
static QUA = 'V1_HT5_QDT_0.70.2209190_x64_0_DEV_D';
|
||||
|
||||
/**
|
||||
* Mini Program Presets
|
||||
*/
|
||||
static Presets = {
|
||||
miniprogram: {
|
||||
name: '小程序开发 (DevTools)',
|
||||
description: 'QQ小程序开发者工具',
|
||||
appid: '' // User provided
|
||||
},
|
||||
farm: {
|
||||
name: 'QQ经典农场 (Farm)',
|
||||
description: 'QQ经典农场小程序',
|
||||
appid: '1112386029'
|
||||
}
|
||||
};
|
||||
|
||||
static getHeaders() {
|
||||
return {
|
||||
'qua': MiniProgramLoginSession.QUA,
|
||||
'host': 'q.qq.com',
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'user-agent': ChromeUA
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Login Code (for Mini Program DevTools)
|
||||
*/
|
||||
static async requestLoginCode() {
|
||||
try {
|
||||
const response = await axios.get('https://q.qq.com/ide/devtoolAuth/GetLoginCode', {
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
|
||||
const { code, data } = response.data;
|
||||
|
||||
if (+code !== 0) {
|
||||
throw new Error('获取登录码失败');
|
||||
}
|
||||
|
||||
return {
|
||||
code: data.code || '',
|
||||
url: `https://h5.qzone.qq.com/qqq/code/${data.code}?_proxy=1&from=ide`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('MP Request Login Code Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Status for Mini Program Login
|
||||
* @param {string} code
|
||||
*/
|
||||
static async queryStatus(code) {
|
||||
try {
|
||||
const response = await axios.get(`https://q.qq.com/ide/devtoolAuth/syncScanSateGetTicket?code=${code}`, {
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
|
||||
// If response is not OK (e.g. 404/500), return Error
|
||||
if (response.status !== 200) {
|
||||
return { status: 'Error' };
|
||||
}
|
||||
|
||||
const { code: resCode, data } = response.data;
|
||||
|
||||
if (+resCode === 0) {
|
||||
// data.ok: 1 = Success, 0 = Waiting/Scanning?
|
||||
if (+data.ok !== 1) return { status: 'Wait' };
|
||||
// User says uin is here
|
||||
return { status: 'OK', ticket: data.ticket, uin: data.uin };
|
||||
}
|
||||
|
||||
if (+resCode === -10003) return { status: 'Used' };
|
||||
|
||||
return { status: 'Error', msg: `Code: ${resCode}` };
|
||||
} catch (error) {
|
||||
console.error('MP Query Status Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Auth Code (Final step for MP login)
|
||||
* @param {string} ticket
|
||||
* @param {string} appid
|
||||
*/
|
||||
static async getAuthCode(ticket, appid) {
|
||||
try {
|
||||
const response = await axios.post('https://q.qq.com/ide/login', {
|
||||
appid: appid,
|
||||
ticket: ticket
|
||||
}, {
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
|
||||
if (response.status !== 200) return '';
|
||||
|
||||
const { code } = response.data;
|
||||
return code || '';
|
||||
} catch (error) {
|
||||
console.error('MP Get Auth Code Error:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { QRLoginSession, MiniProgramLoginSession };
|
||||
52
211/server/src/qrlib/utils.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* Cookie handling utilities
|
||||
*/
|
||||
class CookieUtils {
|
||||
static parse(cookieStr) {
|
||||
if (!cookieStr) return {};
|
||||
return cookieStr.split(';').reduce((acc, curr) => {
|
||||
const [key, value] = curr.split('=');
|
||||
if (key) acc[key.trim()] = value ? value.trim() : '';
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
static getValue(cookies, key) {
|
||||
if (!cookies) return null;
|
||||
if (Array.isArray(cookies)) cookies = cookies.join('; ');
|
||||
const match = cookies.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
static getUin(cookies) {
|
||||
const uin = this.getValue(cookies, 'wxuin') || this.getValue(cookies, 'uin') || this.getValue(cookies, 'ptui_loginuin');
|
||||
if (!uin) return null;
|
||||
// Remove leading 'o' if present (common in QQ cookies like 'o123456')
|
||||
return uin.replace(/^o0*/, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashing utilities for QQ login
|
||||
*/
|
||||
class HashUtils {
|
||||
static hash(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash += (hash << 5) + str.charCodeAt(i);
|
||||
}
|
||||
return 2147483647 & hash;
|
||||
}
|
||||
|
||||
static getGTk(pskey) {
|
||||
let gtk = 5381;
|
||||
for (let i = 0; i < pskey.length; i++) {
|
||||
gtk += (gtk << 5) + pskey.charCodeAt(i);
|
||||
}
|
||||
return gtk & 0x7fffffff;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CookieUtils, HashUtils };
|
||||
193
211/server/src/status.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 状态栏 - 在终端固定位置显示用户状态
|
||||
*/
|
||||
|
||||
const { getLevelExpTable, getLevelExpProgress } = require('./gameConfig');
|
||||
|
||||
// ============ 状态数据 ============
|
||||
const statusData = {
|
||||
platform: 'qq',
|
||||
name: '',
|
||||
level: 0,
|
||||
gold: 0,
|
||||
exp: 0,
|
||||
};
|
||||
|
||||
// ============ 状态栏高度 ============
|
||||
const STATUS_LINES = 3; // 状态栏占用行数
|
||||
const FREE_PROJECT_TIP = '本程序免费开源,GitHub: https://github.com/linguo2625469/qq-farm-bot';
|
||||
|
||||
// ============ ANSI 转义码 ============
|
||||
const ESC = '\x1b';
|
||||
const SAVE_CURSOR = `${ESC}7`;
|
||||
const RESTORE_CURSOR = `${ESC}8`;
|
||||
const MOVE_TO = (row, col) => `${ESC}[${row};${col}H`;
|
||||
const CLEAR_LINE = `${ESC}[2K`;
|
||||
const SCROLL_REGION = (top, bottom) => `${ESC}[${top};${bottom}r`;
|
||||
const RESET_SCROLL = `${ESC}[r`;
|
||||
const BOLD = `${ESC}[1m`;
|
||||
const RESET = `${ESC}[0m`;
|
||||
const DIM = `${ESC}[2m`;
|
||||
const CYAN = `${ESC}[36m`;
|
||||
const YELLOW = `${ESC}[33m`;
|
||||
const GREEN = `${ESC}[32m`;
|
||||
const MAGENTA = `${ESC}[35m`;
|
||||
|
||||
// ============ 状态栏是否启用 ============
|
||||
let statusEnabled = false;
|
||||
let termRows = 24;
|
||||
|
||||
/**
|
||||
* 初始化状态栏
|
||||
*/
|
||||
function initStatusBar() {
|
||||
// 检测终端是否支持
|
||||
if (!process.stdout.isTTY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
termRows = process.stdout.rows || 24;
|
||||
statusEnabled = true;
|
||||
|
||||
// 设置滚动区域,留出顶部状态栏空间
|
||||
process.stdout.write(SCROLL_REGION(STATUS_LINES + 1, termRows));
|
||||
// 移动光标到滚动区域
|
||||
process.stdout.write(MOVE_TO(STATUS_LINES + 1, 1));
|
||||
|
||||
// 监听终端大小变化
|
||||
process.stdout.on('resize', () => {
|
||||
termRows = process.stdout.rows || 24;
|
||||
process.stdout.write(SCROLL_REGION(STATUS_LINES + 1, termRows));
|
||||
renderStatusBar();
|
||||
});
|
||||
|
||||
// 初始渲染
|
||||
renderStatusBar();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理状态栏(退出时调用)
|
||||
*/
|
||||
function cleanupStatusBar() {
|
||||
if (!statusEnabled) return;
|
||||
statusEnabled = false;
|
||||
// 重置滚动区域
|
||||
process.stdout.write(RESET_SCROLL);
|
||||
// 清除状态栏
|
||||
process.stdout.write(MOVE_TO(1, 1) + CLEAR_LINE);
|
||||
process.stdout.write(MOVE_TO(2, 1) + CLEAR_LINE);
|
||||
process.stdout.write(MOVE_TO(3, 1) + CLEAR_LINE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染状态栏
|
||||
*/
|
||||
function renderStatusBar() {
|
||||
if (!statusEnabled) return;
|
||||
|
||||
const { platform, name, level, gold, exp } = statusData;
|
||||
|
||||
// 构建状态行
|
||||
const platformStr = platform === 'wx' ? `${MAGENTA}微信${RESET}` : `${CYAN}QQ${RESET}`;
|
||||
const nameStr = name ? `${BOLD}${name}${RESET}` : '未登录';
|
||||
const levelStr = `${GREEN}Lv${level}${RESET}`;
|
||||
const goldStr = `${YELLOW}金币:${gold}${RESET}`;
|
||||
|
||||
// 显示经验值
|
||||
let expStr = '';
|
||||
if (level > 0 && exp >= 0) {
|
||||
const levelExpTable = getLevelExpTable();
|
||||
if (levelExpTable) {
|
||||
// 有配置表时显示当前等级进度
|
||||
const progress = getLevelExpProgress(level, exp);
|
||||
expStr = `${DIM}经验:${progress.current}/${progress.needed}${RESET}`;
|
||||
} else {
|
||||
// 没有配置表时只显示累计经验
|
||||
expStr = `${DIM}经验:${exp}${RESET}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 第一行:平台 | 昵称 | 等级 | 金币 | 经验
|
||||
const line1 = `${platformStr} | ${nameStr} | ${levelStr} | ${goldStr}${expStr ? ' | ' + expStr : ''}`;
|
||||
|
||||
// 第二行:固定提醒
|
||||
const line2 = `${DIM}${FREE_PROJECT_TIP}${RESET}`;
|
||||
|
||||
// 第三行:分隔线
|
||||
const width = process.stdout.columns || 80;
|
||||
const line3 = `${DIM}${'─'.repeat(Math.min(width, 80))}${RESET}`;
|
||||
|
||||
// 保存光标位置
|
||||
process.stdout.write(SAVE_CURSOR);
|
||||
// 移动到第一行并清除
|
||||
process.stdout.write(MOVE_TO(1, 1) + CLEAR_LINE + line1);
|
||||
// 移动到第二行并清除
|
||||
process.stdout.write(MOVE_TO(2, 1) + CLEAR_LINE + line2);
|
||||
// 移动到第三行并清除
|
||||
process.stdout.write(MOVE_TO(3, 1) + CLEAR_LINE + line3);
|
||||
// 恢复光标位置
|
||||
process.stdout.write(RESTORE_CURSOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态数据并刷新显示
|
||||
*/
|
||||
function updateStatus(data) {
|
||||
let changed = false;
|
||||
for (const key of Object.keys(data)) {
|
||||
if (statusData[key] !== data[key]) {
|
||||
statusData[key] = data[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed && statusEnabled) {
|
||||
renderStatusBar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置平台
|
||||
*/
|
||||
function setStatusPlatform(platform) {
|
||||
updateStatus({ platform });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从登录数据更新状态
|
||||
*/
|
||||
function updateStatusFromLogin(basic) {
|
||||
updateStatus({
|
||||
name: basic.name || statusData.name,
|
||||
level: basic.level || statusData.level,
|
||||
gold: basic.gold || statusData.gold,
|
||||
exp: basic.exp || statusData.exp,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新金币
|
||||
*/
|
||||
function updateStatusGold(gold) {
|
||||
updateStatus({ gold });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新等级和经验
|
||||
*/
|
||||
function updateStatusLevel(level, exp) {
|
||||
const data = { level };
|
||||
if (exp !== undefined) data.exp = exp;
|
||||
updateStatus(data);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initStatusBar,
|
||||
cleanupStatusBar,
|
||||
updateStatus,
|
||||
setStatusPlatform,
|
||||
updateStatusFromLogin,
|
||||
updateStatusGold,
|
||||
updateStatusLevel,
|
||||
statusData,
|
||||
};
|
||||
181
211/server/src/task.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 任务系统 - 自动领取任务奖励
|
||||
*/
|
||||
|
||||
const { types } = require('./proto');
|
||||
const { sendMsgAsync, networkEvents } = require('./network');
|
||||
const { toLong, toNum, log, logWarn, sleep } = require('./utils');
|
||||
|
||||
// ============ 任务 API ============
|
||||
|
||||
async function getTaskInfo() {
|
||||
const body = types.TaskInfoRequest.encode(types.TaskInfoRequest.create({})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.taskpb.TaskService', 'TaskInfo', body);
|
||||
return types.TaskInfoReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function claimTaskReward(taskId, doShared = false) {
|
||||
const body = types.ClaimTaskRewardRequest.encode(types.ClaimTaskRewardRequest.create({
|
||||
id: toLong(taskId),
|
||||
do_shared: doShared,
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.taskpb.TaskService', 'ClaimTaskReward', body);
|
||||
return types.ClaimTaskRewardReply.decode(replyBody);
|
||||
}
|
||||
|
||||
async function batchClaimTaskReward(taskIds, doShared = false) {
|
||||
const body = types.BatchClaimTaskRewardRequest.encode(types.BatchClaimTaskRewardRequest.create({
|
||||
ids: taskIds.map(id => toLong(id)),
|
||||
do_shared: doShared,
|
||||
})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.taskpb.TaskService', 'BatchClaimTaskReward', body);
|
||||
return types.BatchClaimTaskRewardReply.decode(replyBody);
|
||||
}
|
||||
|
||||
// ============ 任务分析 ============
|
||||
|
||||
/**
|
||||
* 分析任务列表,找出可领取的任务
|
||||
*/
|
||||
function analyzeTaskList(tasks) {
|
||||
const claimable = [];
|
||||
for (const task of tasks) {
|
||||
const id = toNum(task.id);
|
||||
const progress = toNum(task.progress);
|
||||
const totalProgress = toNum(task.total_progress);
|
||||
const isClaimed = task.is_claimed;
|
||||
const isUnlocked = task.is_unlocked;
|
||||
const shareMultiple = toNum(task.share_multiple);
|
||||
|
||||
// 可领取条件: 已解锁 + 未领取 + 进度完成
|
||||
if (isUnlocked && !isClaimed && progress >= totalProgress && totalProgress > 0) {
|
||||
claimable.push({
|
||||
id,
|
||||
desc: task.desc || `任务#${id}`,
|
||||
shareMultiple,
|
||||
rewards: task.rewards || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
return claimable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算奖励摘要
|
||||
*/
|
||||
function getRewardSummary(items) {
|
||||
const summary = [];
|
||||
for (const item of items) {
|
||||
const id = toNum(item.id);
|
||||
const count = toNum(item.count);
|
||||
// 常见物品ID: 1=金币, 2=经验
|
||||
if (id === 1) summary.push(`金币${count}`);
|
||||
else if (id === 2) summary.push(`经验${count}`);
|
||||
else summary.push(`物品#${id}x${count}`);
|
||||
}
|
||||
return summary.join('/');
|
||||
}
|
||||
|
||||
// ============ 自动领取 ============
|
||||
|
||||
/**
|
||||
* 检查并领取所有可领取的任务奖励
|
||||
*/
|
||||
async function checkAndClaimTasks() {
|
||||
try {
|
||||
const reply = await getTaskInfo();
|
||||
if (!reply.task_info) return;
|
||||
|
||||
const taskInfo = reply.task_info;
|
||||
const allTasks = [
|
||||
...(taskInfo.growth_tasks || []),
|
||||
...(taskInfo.daily_tasks || []),
|
||||
...(taskInfo.tasks || []),
|
||||
];
|
||||
|
||||
const claimable = analyzeTaskList(allTasks);
|
||||
if (claimable.length === 0) return;
|
||||
|
||||
log('任务', `发现 ${claimable.length} 个可领取任务`);
|
||||
|
||||
for (const task of claimable) {
|
||||
try {
|
||||
// 如果有分享翻倍,使用翻倍领取
|
||||
const useShare = task.shareMultiple > 1;
|
||||
const multipleStr = useShare ? ` (${task.shareMultiple}倍)` : '';
|
||||
|
||||
const claimReply = await claimTaskReward(task.id, useShare);
|
||||
const items = claimReply.items || [];
|
||||
const rewardStr = items.length > 0 ? getRewardSummary(items) : '无';
|
||||
|
||||
log('任务', `领取: ${task.desc}${multipleStr} → ${rewardStr}`);
|
||||
await sleep(300);
|
||||
} catch (e) {
|
||||
logWarn('任务', `领取失败 #${task.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理任务状态变化推送
|
||||
*/
|
||||
function onTaskInfoNotify(taskInfo) {
|
||||
if (!taskInfo) return;
|
||||
|
||||
const allTasks = [
|
||||
...(taskInfo.growth_tasks || []),
|
||||
...(taskInfo.daily_tasks || []),
|
||||
...(taskInfo.tasks || []),
|
||||
];
|
||||
|
||||
const claimable = analyzeTaskList(allTasks);
|
||||
if (claimable.length === 0) return;
|
||||
|
||||
// 有可领取任务,延迟后自动领取
|
||||
log('任务', `有 ${claimable.length} 个任务可领取,准备自动领取...`);
|
||||
setTimeout(() => claimTasksFromList(claimable), 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务列表领取奖励
|
||||
*/
|
||||
async function claimTasksFromList(claimable) {
|
||||
for (const task of claimable) {
|
||||
try {
|
||||
const useShare = task.shareMultiple > 1;
|
||||
const multipleStr = useShare ? ` (${task.shareMultiple}倍)` : '';
|
||||
|
||||
const claimReply = await claimTaskReward(task.id, useShare);
|
||||
const items = claimReply.items || [];
|
||||
const rewardStr = items.length > 0 ? getRewardSummary(items) : '无';
|
||||
|
||||
log('任务', `领取: ${task.desc}${multipleStr} → ${rewardStr}`);
|
||||
await sleep(300);
|
||||
} catch (e) {
|
||||
logWarn('任务', `领取失败 #${task.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 初始化 ============
|
||||
|
||||
function initTaskSystem() {
|
||||
// 监听任务状态变化推送
|
||||
networkEvents.on('taskInfoNotify', onTaskInfoNotify);
|
||||
|
||||
// 启动时检查一次任务
|
||||
setTimeout(() => checkAndClaimTasks(), 4000);
|
||||
}
|
||||
|
||||
function cleanupTaskSystem() {
|
||||
networkEvents.off('taskInfoNotify', onTaskInfoNotify);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkAndClaimTasks,
|
||||
initTaskSystem,
|
||||
cleanupTaskSystem,
|
||||
};
|
||||
475
211/server/src/userManager.js
Normal file
@@ -0,0 +1,475 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { log } = require('./utils');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '../data');
|
||||
const DATA_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const SQLITE_FILE = path.join(DATA_DIR, 'users.db');
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
let mysqlPool = null;
|
||||
let sqliteDb = null;
|
||||
let sqliteRun = null;
|
||||
let sqliteAll = null;
|
||||
let sqliteReady = null;
|
||||
|
||||
if (isProduction) {
|
||||
const mysql = require('mysql2/promise');
|
||||
mysqlPool = mysql.createPool({
|
||||
host: process.env.MYSQL_HOST || 'localhost',
|
||||
user: process.env.MYSQL_USER || 'root',
|
||||
password: process.env.MYSQL_PASSWORD || 'root',
|
||||
database: process.env.MYSQL_DB || 'game_account_db',
|
||||
port: process.env.MYSQL_PORT || 3306,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
// Init Table
|
||||
(async () => {
|
||||
try {
|
||||
const connection = await mysqlPool.getConnection();
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255),
|
||||
code TEXT,
|
||||
auth_provider VARCHAR(50) DEFAULT 'local',
|
||||
auth_id VARCHAR(255),
|
||||
created_at BIGINT
|
||||
)
|
||||
`);
|
||||
connection.release();
|
||||
log('UserManager', 'MySQL initialized.');
|
||||
} catch (err) {
|
||||
log('UserManager', `MySQL Init Error: ${err.message}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
const sqlite3 = require('sqlite3');
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
sqliteDb = new sqlite3.Database(SQLITE_FILE);
|
||||
sqliteRun = (sql, params = []) => new Promise((resolve, reject) => {
|
||||
sqliteDb.run(sql, params, function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this);
|
||||
});
|
||||
});
|
||||
sqliteAll = (sql, params = []) => new Promise((resolve, reject) => {
|
||||
sqliteDb.all(sql, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
sqliteReady = sqliteRun(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT,
|
||||
code TEXT,
|
||||
auth_provider TEXT DEFAULT 'local',
|
||||
auth_id TEXT,
|
||||
created_at INTEGER
|
||||
)
|
||||
`).then(() => {
|
||||
log('UserManager', 'SQLite initialized.');
|
||||
}).catch((err) => {
|
||||
log('UserManager', `SQLite Init Error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
class UserManager {
|
||||
constructor() {
|
||||
this.users = new Map(); // email -> user (Cache)
|
||||
this.useMySQL = isProduction && !!mysqlPool;
|
||||
this.useSQLite = !this.useMySQL && !!sqliteDb;
|
||||
this.extraReady = this.initExtraTables();
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.useMySQL) {
|
||||
try {
|
||||
const [rows] = await mysqlPool.query('SELECT * FROM users');
|
||||
rows.forEach(u => this.users.set(u.email, u));
|
||||
log('UserManager', `Loaded ${rows.length} users from MySQL.`);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to load users from MySQL: ${e.message}`);
|
||||
}
|
||||
} else if (this.useSQLite) {
|
||||
try {
|
||||
await sqliteReady;
|
||||
const rows = await sqliteAll('SELECT * FROM users');
|
||||
rows.forEach(u => this.users.set(u.email, u));
|
||||
log('UserManager', `Loaded ${rows.length} users from SQLite.`);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to load users from SQLite: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (fs.existsSync(DATA_FILE)) {
|
||||
const data = fs.readFileSync(DATA_FILE, 'utf8');
|
||||
const json = JSON.parse(data);
|
||||
if (Array.isArray(json)) {
|
||||
json.forEach(u => this.users.set(u.email, u));
|
||||
}
|
||||
log('UserManager', `Loaded ${this.users.size} users from JSON.`);
|
||||
}
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to load users from JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async save(user) {
|
||||
const createdAt = user.createdAt || user.created_at || Date.now();
|
||||
if (this.useMySQL) {
|
||||
try {
|
||||
await mysqlPool.query(
|
||||
`INSERT INTO users (email, password, code, auth_provider, auth_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE password = VALUES(password), code = VALUES(code), auth_provider = VALUES(auth_provider), auth_id = VALUES(auth_id)`,
|
||||
[user.email, user.password, user.code, user.auth_provider || 'local', user.auth_id || null, createdAt]
|
||||
);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to save user to MySQL: ${e.message}`);
|
||||
}
|
||||
} else if (this.useSQLite) {
|
||||
try {
|
||||
await sqliteReady;
|
||||
await sqliteRun(
|
||||
`INSERT INTO users (email, password, code, auth_provider, auth_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(email) DO UPDATE SET password = excluded.password, code = excluded.code, auth_provider = excluded.auth_provider, auth_id = excluded.auth_id`,
|
||||
[user.email, user.password, user.code, user.auth_provider || 'local', user.auth_id || null, createdAt]
|
||||
);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to save user to SQLite: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const data = JSON.stringify(Array.from(this.users.values()), null, 2);
|
||||
fs.writeFileSync(DATA_FILE, data, 'utf8');
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to save users to JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async initExtraTables() {
|
||||
if (this.useMySQL) {
|
||||
try {
|
||||
await mysqlPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS bot_logs (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
tag VARCHAR(50),
|
||||
msg TEXT,
|
||||
type VARCHAR(20),
|
||||
time BIGINT,
|
||||
INDEX idx_email_time (email, time)
|
||||
)
|
||||
`);
|
||||
await mysqlPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS leaderboard_unlucky (
|
||||
email VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
count BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (email, name)
|
||||
)
|
||||
`);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to init extra tables: ${e.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.useSQLite) {
|
||||
try {
|
||||
await sqliteReady;
|
||||
await sqliteRun(`
|
||||
CREATE TABLE IF NOT EXISTS bot_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
tag TEXT,
|
||||
msg TEXT,
|
||||
type TEXT,
|
||||
time INTEGER
|
||||
)
|
||||
`);
|
||||
await sqliteRun(`
|
||||
CREATE TABLE IF NOT EXISTS leaderboard_unlucky (
|
||||
email TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (email, name)
|
||||
)
|
||||
`);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to init extra tables: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizeLogMessage(msg) {
|
||||
if (typeof msg === 'string') return msg;
|
||||
if (msg === null || msg === undefined) return '';
|
||||
if (typeof msg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(msg);
|
||||
} catch {
|
||||
return String(msg);
|
||||
}
|
||||
}
|
||||
return String(msg);
|
||||
}
|
||||
|
||||
async saveBotLog(email, logEntry) {
|
||||
if (!email || !logEntry) return;
|
||||
await this.extraReady;
|
||||
const tag = logEntry.tag || 'System';
|
||||
const msg = this.normalizeLogMessage(logEntry.msg);
|
||||
const type = logEntry.type || 'info';
|
||||
const time = Number(logEntry.time) || Date.now();
|
||||
const limit = 500;
|
||||
if (this.useMySQL) {
|
||||
try {
|
||||
await mysqlPool.query(
|
||||
`INSERT INTO bot_logs (email, tag, msg, type, time) VALUES (?, ?, ?, ?, ?)`,
|
||||
[email, tag, msg, type, time]
|
||||
);
|
||||
await mysqlPool.query(
|
||||
`DELETE FROM bot_logs WHERE email = ? AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM bot_logs WHERE email = ? ORDER BY id DESC LIMIT ?
|
||||
) t
|
||||
)`,
|
||||
[email, email, limit]
|
||||
);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to save bot log: ${e.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.useSQLite) {
|
||||
try {
|
||||
await sqliteRun(
|
||||
`INSERT INTO bot_logs (email, tag, msg, type, time) VALUES (?, ?, ?, ?, ?)`,
|
||||
[email, tag, msg, type, time]
|
||||
);
|
||||
await sqliteRun(
|
||||
`DELETE FROM bot_logs WHERE email = ? AND id NOT IN (
|
||||
SELECT id FROM bot_logs WHERE email = ? ORDER BY id DESC LIMIT ?
|
||||
)`,
|
||||
[email, email, limit]
|
||||
);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to save bot log: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getBotLogs(email, limit = 200) {
|
||||
if (!email) return [];
|
||||
await this.extraReady;
|
||||
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 500) : 200;
|
||||
if (this.useMySQL) {
|
||||
try {
|
||||
const [rows] = await mysqlPool.query(
|
||||
`SELECT id, tag, msg, type, time FROM bot_logs WHERE email = ? ORDER BY id DESC LIMIT ?`,
|
||||
[email, safeLimit]
|
||||
);
|
||||
return rows.slice().reverse().map(row => ({
|
||||
id: String(row.id),
|
||||
tag: row.tag || 'System',
|
||||
msg: row.msg || '',
|
||||
type: row.type || 'info',
|
||||
time: Number(row.time) || Date.now()
|
||||
}));
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to load bot logs: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (this.useSQLite) {
|
||||
try {
|
||||
const rows = await sqliteAll(
|
||||
`SELECT id, tag, msg, type, time FROM bot_logs WHERE email = ? ORDER BY id DESC LIMIT ?`,
|
||||
[email, safeLimit]
|
||||
);
|
||||
return rows.slice().reverse().map(row => ({
|
||||
id: String(row.id),
|
||||
tag: row.tag || 'System',
|
||||
msg: row.msg || '',
|
||||
type: row.type || 'info',
|
||||
time: Number(row.time) || Date.now()
|
||||
}));
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to load bot logs: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async saveUnlucky(email, name, count) {
|
||||
if (!email || !name || !Number.isFinite(count) || count <= 0) return;
|
||||
await this.extraReady;
|
||||
if (this.useMySQL) {
|
||||
try {
|
||||
await mysqlPool.query(
|
||||
`INSERT INTO leaderboard_unlucky (email, name, count)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE count = count + VALUES(count)`,
|
||||
[email, name, count]
|
||||
);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to save leaderboard: ${e.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.useSQLite) {
|
||||
try {
|
||||
await sqliteRun(
|
||||
`INSERT INTO leaderboard_unlucky (email, name, count)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(email, name) DO UPDATE SET count = count + excluded.count`,
|
||||
[email, name, count]
|
||||
);
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to save leaderboard: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadUnluckyBoards() {
|
||||
await this.extraReady;
|
||||
const result = new Map();
|
||||
if (this.useMySQL) {
|
||||
try {
|
||||
const [rows] = await mysqlPool.query(`SELECT email, name, count FROM leaderboard_unlucky`);
|
||||
for (const row of rows) {
|
||||
const email = row.email;
|
||||
const name = row.name;
|
||||
const count = Number(row.count) || 0;
|
||||
if (!email || !name || count <= 0) continue;
|
||||
let board = result.get(email);
|
||||
if (!board) {
|
||||
board = new Map();
|
||||
result.set(email, board);
|
||||
}
|
||||
board.set(name, count);
|
||||
}
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to load leaderboard: ${e.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (this.useSQLite) {
|
||||
try {
|
||||
const rows = await sqliteAll(`SELECT email, name, count FROM leaderboard_unlucky`);
|
||||
for (const row of rows) {
|
||||
const email = row.email;
|
||||
const name = row.name;
|
||||
const count = Number(row.count) || 0;
|
||||
if (!email || !name || count <= 0) continue;
|
||||
let board = result.get(email);
|
||||
if (!board) {
|
||||
board = new Map();
|
||||
result.set(email, board);
|
||||
}
|
||||
board.set(name, count);
|
||||
}
|
||||
} catch (e) {
|
||||
log('UserManager', `Failed to load leaderboard: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by email
|
||||
* @param {string} email
|
||||
*/
|
||||
getUser(email) {
|
||||
return this.users.get(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update user
|
||||
* @param {string} email
|
||||
* @param {string} password
|
||||
*/
|
||||
async register(email, password, provider = 'local', authId = null) {
|
||||
if (this.users.has(email) && provider === 'local') {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
let user = this.users.get(email);
|
||||
if (user) {
|
||||
// Update existing user (e.g. linking OAuth)
|
||||
if (provider !== 'local') {
|
||||
user.auth_provider = provider;
|
||||
user.auth_id = authId;
|
||||
await this.save(user);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
user = {
|
||||
email,
|
||||
password, // In production, hash this!
|
||||
code: '',
|
||||
auth_provider: provider,
|
||||
auth_id: authId,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
this.users.set(email, user);
|
||||
await this.save(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate login
|
||||
* @param {string} email
|
||||
* @param {string} password
|
||||
*/
|
||||
login(email, password) {
|
||||
const user = this.users.get(email);
|
||||
if (!user) return null;
|
||||
|
||||
// If OAuth user tries to login with password (and has no password set)
|
||||
if (user.auth_provider !== 'local' && !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user.password !== password) {
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's game code
|
||||
* @param {string} email
|
||||
* @param {string} code
|
||||
*/
|
||||
async updateCode(email, code) {
|
||||
const user = this.users.get(email);
|
||||
if (user) {
|
||||
user.code = code;
|
||||
await this.save(user);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UserManager();
|
||||
90
211/server/src/utils.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 通用工具函数
|
||||
*/
|
||||
|
||||
const Long = require('long');
|
||||
const { RUNTIME_HINT_MASK, RUNTIME_HINT_DATA } = require('./config');
|
||||
|
||||
// ============ 服务器时间状态 ============
|
||||
let serverTimeMs = 0;
|
||||
let localTimeAtSync = 0;
|
||||
|
||||
// ============ 类型转换 ============
|
||||
function toLong(val) {
|
||||
return Long.fromValue(val);
|
||||
}
|
||||
|
||||
function toNum(val) {
|
||||
if (Long.isLong(val)) return val.toNumber();
|
||||
return val || 0;
|
||||
}
|
||||
|
||||
// ============ 时间相关 ============
|
||||
function now() {
|
||||
return new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
/** 获取当前推算的服务器时间(秒) */
|
||||
function getServerTimeSec() {
|
||||
if (!serverTimeMs) return Math.floor(Date.now() / 1000);
|
||||
const elapsed = Date.now() - localTimeAtSync;
|
||||
return Math.floor((serverTimeMs + elapsed) / 1000);
|
||||
}
|
||||
|
||||
/** 同步服务器时间 */
|
||||
function syncServerTime(ms) {
|
||||
serverTimeMs = ms;
|
||||
localTimeAtSync = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳归一化为秒级
|
||||
* 大于 1e12 认为是毫秒级,转换为秒级
|
||||
*/
|
||||
function toTimeSec(val) {
|
||||
const n = toNum(val);
|
||||
if (n <= 0) return 0;
|
||||
if (n > 1e12) return Math.floor(n / 1000);
|
||||
return n;
|
||||
}
|
||||
|
||||
// ============ 日志 ============
|
||||
function log(tag, msg) {
|
||||
console.log(`[${now()}] [${tag}] ${msg}`);
|
||||
}
|
||||
|
||||
function logWarn(tag, msg) {
|
||||
console.log(`[${now()}] [${tag}] ⚠ ${msg}`);
|
||||
}
|
||||
|
||||
// ============ 异步工具 ============
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
let hintPrinted = false;
|
||||
function decodeRuntimeHint() {
|
||||
return String.fromCharCode(...RUNTIME_HINT_DATA.map(n => n ^ RUNTIME_HINT_MASK));
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出开源声明:
|
||||
* - force=true 时必定输出(用于启动)
|
||||
* - 默认低频输出(用于业务流程中的隐性提示)
|
||||
*/
|
||||
function emitRuntimeHint(force = false) {
|
||||
if (!force) {
|
||||
// 约 3.3% 概率,且同一次进程最多输出 2 次
|
||||
if (Math.random() > 0.033) return;
|
||||
if (hintPrinted && Math.random() > 0.2) return;
|
||||
}
|
||||
log('声明', decodeRuntimeHint());
|
||||
hintPrinted = true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toLong, toNum, now,
|
||||
getServerTimeSec, syncServerTime, toTimeSec,
|
||||
log, logWarn, sleep,
|
||||
emitRuntimeHint,
|
||||
};
|
||||
173
211/server/src/warehouse.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 仓库系统 - 自动出售果实
|
||||
* 协议说明:BagReply 使用 item_bag(ItemBag),item_bag.items 才是背包物品列表
|
||||
*/
|
||||
|
||||
const { types } = require('./proto');
|
||||
const { sendMsgAsync } = require('./network');
|
||||
const { toLong, toNum, log, logWarn, emitRuntimeHint } = require('./utils');
|
||||
const { getFruitName } = require('./gameConfig');
|
||||
const seedShopData = require('../tools/seed-shop-merged-export.json');
|
||||
|
||||
// 游戏内金币和点券的物品 ID (GlobalData.GodItemId / DiamondItemId)
|
||||
const GOLD_ITEM_ID = 1001;
|
||||
const FRUIT_ID_SET = new Set(
|
||||
((seedShopData && seedShopData.rows) || [])
|
||||
.map(row => Number(row.fruitId))
|
||||
.filter(Number.isFinite)
|
||||
);
|
||||
|
||||
let sellTimer = null;
|
||||
let sellInterval = 60000;
|
||||
|
||||
function isFruitIdBySeedData(id) {
|
||||
return FRUIT_ID_SET.has(toNum(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SellReply 中提取获得的金币数量
|
||||
* 新版 SellReply 返回 get_items (repeated Item),其中 id=1001 为金币
|
||||
*/
|
||||
function extractGold(sellReply) {
|
||||
if (sellReply.get_items && sellReply.get_items.length > 0) {
|
||||
for (const item of sellReply.get_items) {
|
||||
const id = toNum(item.id);
|
||||
if (id === GOLD_ITEM_ID) {
|
||||
return toNum(item.count);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (sellReply.gold !== undefined && sellReply.gold !== null) {
|
||||
return toNum(sellReply.gold);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function getBag() {
|
||||
const body = types.BagRequest.encode(types.BagRequest.create({})).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.itempb.ItemService', 'Bag', body);
|
||||
return types.BagReply.decode(replyBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 item 转为 Sell 请求所需格式(id/count/uid 保留 Long 或转成 Long,与游戏一致)
|
||||
*/
|
||||
function toSellItem(item) {
|
||||
const id = item.id != null ? toLong(item.id) : undefined;
|
||||
const count = item.count != null ? toLong(item.count) : undefined;
|
||||
const uid = item.uid != null ? toLong(item.uid) : undefined;
|
||||
return { id, count, uid };
|
||||
}
|
||||
|
||||
async function sellItems(items) {
|
||||
const payload = items.map(toSellItem);
|
||||
const body = types.SellRequest.encode(types.SellRequest.create({ items: payload })).finish();
|
||||
const { body: replyBody } = await sendMsgAsync('gamepb.itempb.ItemService', 'Sell', body);
|
||||
return types.SellReply.decode(replyBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 BagReply 取出物品列表(兼容 item_bag 与旧版 items)
|
||||
*/
|
||||
function getBagItems(bagReply) {
|
||||
if (bagReply.item_bag && bagReply.item_bag.items && bagReply.item_bag.items.length)
|
||||
return bagReply.item_bag.items;
|
||||
return bagReply.items || [];
|
||||
}
|
||||
|
||||
async function sellAllFruits() {
|
||||
try {
|
||||
const bagReply = await getBag();
|
||||
const items = getBagItems(bagReply);
|
||||
|
||||
const toSell = [];
|
||||
const names = [];
|
||||
for (const item of items) {
|
||||
const id = toNum(item.id);
|
||||
const count = toNum(item.count);
|
||||
const uid = item.uid ? toNum(item.uid) : 0;
|
||||
if (isFruitIdBySeedData(id) && count > 0) {
|
||||
if (uid === 0) continue; // 跳过无效格子
|
||||
toSell.push(item);
|
||||
names.push(`${getFruitName(id)}x${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (toSell.length === 0) return;
|
||||
|
||||
const reply = await sellItems(toSell);
|
||||
const totalGold = extractGold(reply);
|
||||
log('仓库', `出售 ${names.join(', ')},获得 ${totalGold} 金币`);
|
||||
emitRuntimeHint(false);
|
||||
} catch (e) {
|
||||
logWarn('仓库', `出售失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function debugSellFruits() {
|
||||
try {
|
||||
log('仓库', '正在检查背包...');
|
||||
const bagReply = await getBag();
|
||||
const items = getBagItems(bagReply);
|
||||
log('仓库', `背包共 ${items.length} 种物品`);
|
||||
|
||||
for (const item of items) {
|
||||
const id = toNum(item.id);
|
||||
const count = toNum(item.count);
|
||||
const isFruit = isFruitIdBySeedData(id);
|
||||
if (isFruit) {
|
||||
const name = getFruitName(id);
|
||||
log('仓库', ` [果实] ${name}(${id}) x${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
const toSell = [];
|
||||
for (const item of items) {
|
||||
const id = toNum(item.id);
|
||||
const count = toNum(item.count);
|
||||
if (isFruitIdBySeedData(id) && count > 0)
|
||||
toSell.push(item);
|
||||
}
|
||||
|
||||
if (toSell.length === 0) {
|
||||
log('仓库', '没有果实可出售');
|
||||
return;
|
||||
}
|
||||
|
||||
log('仓库', `准备出售 ${toSell.length} 种果实...`);
|
||||
const reply = await sellItems(toSell);
|
||||
const totalGold = extractGold(reply);
|
||||
log('仓库', `出售完成,共获得 ${totalGold} 金币`);
|
||||
emitRuntimeHint(false);
|
||||
} catch (e) {
|
||||
logWarn('仓库', `调试出售失败: ${e.message}`);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function startSellLoop(interval = 60000) {
|
||||
if (sellTimer) return;
|
||||
sellInterval = interval;
|
||||
setTimeout(() => {
|
||||
sellAllFruits();
|
||||
sellTimer = setInterval(() => sellAllFruits(), sellInterval);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function stopSellLoop() {
|
||||
if (sellTimer) {
|
||||
clearInterval(sellTimer);
|
||||
sellTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBag,
|
||||
sellItems,
|
||||
sellAllFruits,
|
||||
debugSellFruits,
|
||||
getBagItems,
|
||||
startSellLoop,
|
||||
stopSellLoop,
|
||||
};
|
||||
474
211/server/tools/calc-exp-yield.js
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 基于 tools/seed-shop-merged-export.json 计算经验收益率
|
||||
*
|
||||
* 规则:
|
||||
* 1) 每次收获经验 = exp(新版已去除铲地+1经验)
|
||||
* 2) 种植速度:
|
||||
* - 不施肥:2 秒种 18 块地 => 9 块/秒
|
||||
* - 普通肥:2 秒种 12 块地 => 6 块/秒
|
||||
* 3) 普通肥:直接减少一个生长阶段(按 Plant.json 的 grow_phases 取首个非0阶段时长)
|
||||
*
|
||||
* 用法:
|
||||
* node tools/calc-exp-yield.js
|
||||
* node tools/calc-exp-yield.js --lands 18 --level 27
|
||||
* node tools/calc-exp-yield.js --input tools/seed-shop-merged-export.json
|
||||
*
|
||||
* 运行时调用:
|
||||
* const { getPlantingRecommendation } = require('../tools/calc-exp-yield');
|
||||
* const rec = getPlantingRecommendation(27, 18);
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_INPUT = path.join(__dirname, 'seed-shop-merged-export.json');
|
||||
const PLANT_CONFIG_PATH = path.join(__dirname, '..', 'gameConfig', 'Plant.json');
|
||||
const DEFAULT_OUT_JSON = path.join(__dirname, 'exp-yield-result.json');
|
||||
const DEFAULT_OUT_CSV = path.join(__dirname, 'exp-yield-result.csv');
|
||||
const DEFAULT_OUT_TXT = path.join(__dirname, 'exp-yield-summary.txt');
|
||||
|
||||
const NO_FERT_PLANTS_PER_2_SEC = 18;
|
||||
const NORMAL_FERT_PLANTS_PER_2_SEC = 12;
|
||||
const NO_FERT_PLANT_SPEED_PER_SEC = NO_FERT_PLANTS_PER_2_SEC / 2; // 9 块/秒
|
||||
const NORMAL_FERT_PLANT_SPEED_PER_SEC = NORMAL_FERT_PLANTS_PER_2_SEC / 2; // 6 块/秒
|
||||
|
||||
function toNum(v, fallback = 0) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
input: DEFAULT_INPUT,
|
||||
outJson: DEFAULT_OUT_JSON,
|
||||
outCsv: DEFAULT_OUT_CSV,
|
||||
outTxt: DEFAULT_OUT_TXT,
|
||||
lands: 18,
|
||||
level: null,
|
||||
top: 20,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--input' && argv[i + 1]) opts.input = argv[++i];
|
||||
else if (a === '--out-json' && argv[i + 1]) opts.outJson = argv[++i];
|
||||
else if (a === '--out-csv' && argv[i + 1]) opts.outCsv = argv[++i];
|
||||
else if (a === '--out-txt' && argv[i + 1]) opts.outTxt = argv[++i];
|
||||
else if (a === '--lands' && argv[i + 1]) opts.lands = Math.max(1, Math.floor(toNum(argv[++i], 18)));
|
||||
else if (a === '--level' && argv[i + 1]) opts.level = Math.max(1, Math.floor(toNum(argv[++i], 1)));
|
||||
else if (a === '--top' && argv[i + 1]) opts.top = Math.max(1, Math.floor(toNum(argv[++i], 20)));
|
||||
else if (a === '--help' || a === '-h') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log('Usage: node tools/calc-exp-yield.js [options]');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --input <path> 输入 JSON 文件路径');
|
||||
console.log(' --lands <n> 地块数(默认 18)');
|
||||
console.log(' --level <n> 指定账号等级,输出该等级可用最优作物');
|
||||
console.log(' --top <n> 摘要 Top 数量(默认 20)');
|
||||
console.log(' --out-json <path> 输出 JSON 路径');
|
||||
console.log(' --out-csv <path> 输出 CSV 路径');
|
||||
console.log(' --out-txt <path> 输出 TXT 路径');
|
||||
}
|
||||
|
||||
function readSeeds(inputPath) {
|
||||
const text = fs.readFileSync(inputPath, 'utf8');
|
||||
const data = JSON.parse(text);
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data.rows)) return data.rows;
|
||||
if (data && Array.isArray(data.seeds)) return data.seeds;
|
||||
throw new Error('无法识别输入数据格式,需要数组或 rows/seeds 字段');
|
||||
}
|
||||
|
||||
function parseGrowPhases(growPhases) {
|
||||
if (!growPhases || typeof growPhases !== 'string') return [];
|
||||
return growPhases
|
||||
.split(';')
|
||||
.map(x => x.trim())
|
||||
.filter(Boolean)
|
||||
.map(seg => {
|
||||
const parts = seg.split(':');
|
||||
return parts.length >= 2 ? toNum(parts[1], 0) : 0;
|
||||
})
|
||||
.filter(sec => sec > 0);
|
||||
}
|
||||
|
||||
function loadSeedPhaseReduceMap() {
|
||||
const text = fs.readFileSync(PLANT_CONFIG_PATH, 'utf8');
|
||||
const rows = JSON.parse(text);
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(`Plant 配置格式异常: ${PLANT_CONFIG_PATH}`);
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
for (const p of rows) {
|
||||
const seedId = toNum(p.seed_id, 0);
|
||||
if (seedId <= 0 || map.has(seedId)) continue;
|
||||
const phases = parseGrowPhases(p.grow_phases);
|
||||
if (phases.length === 0) continue;
|
||||
map.set(seedId, phases[0]); // 普通肥减少一个阶段:以首个阶段时长为准
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function calcEffectiveGrowTime(growSec, seedId, seedPhaseReduceMap) {
|
||||
const reduce = toNum(seedPhaseReduceMap.get(seedId), 0);
|
||||
if (reduce <= 0) return growSec;
|
||||
return Math.max(1, growSec - reduce);
|
||||
}
|
||||
|
||||
function formatSec(sec) {
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
if (m < 60) return r > 0 ? `${m}m${r}s` : `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
const mm = m % 60;
|
||||
return r > 0 ? `${h}h${mm}m${r}s` : `${h}h${mm}m`;
|
||||
}
|
||||
|
||||
function csvCell(v) {
|
||||
const s = v == null ? '' : String(v);
|
||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildRows(rawSeeds, lands, seedPhaseReduceMap) {
|
||||
const plantSecondsNoFert = lands / NO_FERT_PLANT_SPEED_PER_SEC;
|
||||
const plantSecondsNormalFert = lands / NORMAL_FERT_PLANT_SPEED_PER_SEC;
|
||||
const rows = [];
|
||||
let skipped = 0;
|
||||
let missingPhaseReduceCount = 0;
|
||||
|
||||
for (const s of rawSeeds) {
|
||||
const seedId = toNum(s.seedId || s.seed_id);
|
||||
const name = s.name || `seed_${seedId}`;
|
||||
const requiredLevel = toNum(s.requiredLevel || s.required_level || 1, 1);
|
||||
const price = toNum(s.price, 0);
|
||||
const expHarvest = toNum(s.exp, 0);
|
||||
const growTimeSec = toNum(s.growTimeSec || s.growTime || s.grow_time || 0, 0);
|
||||
|
||||
if (seedId <= 0 || growTimeSec <= 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const expPerCycle = expHarvest;
|
||||
const reduceSec = toNum(seedPhaseReduceMap.get(seedId), 0);
|
||||
if (reduceSec <= 0) missingPhaseReduceCount++;
|
||||
const growTimeNormalFert = calcEffectiveGrowTime(growTimeSec, seedId, seedPhaseReduceMap);
|
||||
|
||||
// 整个农场一轮 = 生长时间 + 本轮全部地块种植耗时
|
||||
const cycleSecNoFert = growTimeSec + plantSecondsNoFert;
|
||||
const cycleSecNormalFert = growTimeNormalFert + plantSecondsNormalFert;
|
||||
|
||||
const farmExpPerHourNoFert = (lands * expPerCycle / cycleSecNoFert) * 3600;
|
||||
const farmExpPerHourNormalFert = (lands * expPerCycle / cycleSecNormalFert) * 3600;
|
||||
const gainPercent = farmExpPerHourNoFert > 0
|
||||
? ((farmExpPerHourNormalFert - farmExpPerHourNoFert) / farmExpPerHourNoFert) * 100
|
||||
: 0;
|
||||
const expPerGoldSeed = price > 0 ? expPerCycle / price : 0;
|
||||
|
||||
rows.push({
|
||||
seedId,
|
||||
goodsId: toNum(s.goodsId || s.goods_id),
|
||||
plantId: toNum(s.plantId || s.plant_id),
|
||||
name,
|
||||
requiredLevel,
|
||||
unlocked: !!s.unlocked,
|
||||
price,
|
||||
expHarvest,
|
||||
expPerCycle,
|
||||
growTimeSec,
|
||||
growTimeStr: s.growTimeStr || formatSec(growTimeSec),
|
||||
normalFertReduceSec: reduceSec,
|
||||
growTimeNormalFert,
|
||||
growTimeNormalFertStr: formatSec(growTimeNormalFert),
|
||||
cycleSecNoFert,
|
||||
cycleSecNormalFert,
|
||||
farmExpPerHourNoFert,
|
||||
farmExpPerHourNormalFert,
|
||||
farmExpPerDayNoFert: farmExpPerHourNoFert * 24,
|
||||
farmExpPerDayNormalFert: farmExpPerHourNormalFert * 24,
|
||||
gainPercent,
|
||||
expPerGoldSeed,
|
||||
fruitId: toNum(s?.fruit?.id || s.fruitId),
|
||||
fruitCount: toNum(s?.fruit?.count || s.fruitCount),
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, skipped, plantSecondsNoFert, plantSecondsNormalFert, missingPhaseReduceCount };
|
||||
}
|
||||
|
||||
function pickTop(rows, key, topN) {
|
||||
return [...rows]
|
||||
.sort((a, b) => b[key] - a[key])
|
||||
.slice(0, topN);
|
||||
}
|
||||
|
||||
function buildBestByLevel(rows) {
|
||||
const maxLevel = rows.reduce((m, r) => Math.max(m, r.requiredLevel), 1);
|
||||
const result = [];
|
||||
for (let lv = 1; lv <= maxLevel; lv++) {
|
||||
// 按用户指定等级做理论可种分析,不受商店 unlocked 状态影响
|
||||
const available = rows.filter(r => r.requiredLevel <= lv);
|
||||
if (available.length === 0) continue;
|
||||
const bestNo = pickTop(available, 'farmExpPerHourNoFert', 1)[0];
|
||||
const bestFert = pickTop(available, 'farmExpPerHourNormalFert', 1)[0];
|
||||
result.push({
|
||||
level: lv,
|
||||
bestNoFert: {
|
||||
seedId: bestNo.seedId,
|
||||
name: bestNo.name,
|
||||
expPerHour: Number(bestNo.farmExpPerHourNoFert.toFixed(2)),
|
||||
},
|
||||
bestNormalFert: {
|
||||
seedId: bestFert.seedId,
|
||||
name: bestFert.name,
|
||||
expPerHour: Number(bestFert.farmExpPerHourNormalFert.toFixed(2)),
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeJson(outPath, payload) {
|
||||
fs.writeFileSync(outPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function writeCsv(outPath, rows) {
|
||||
const headers = [
|
||||
'seedId',
|
||||
'name',
|
||||
'requiredLevel',
|
||||
'price',
|
||||
'expHarvest',
|
||||
'expPerCycle',
|
||||
'growTimeSec',
|
||||
'growTimeNormalFert',
|
||||
'cycleSecNoFert',
|
||||
'cycleSecNormalFert',
|
||||
'farmExpPerHourNoFert',
|
||||
'farmExpPerHourNormalFert',
|
||||
'farmExpPerDayNoFert',
|
||||
'farmExpPerDayNormalFert',
|
||||
'gainPercent',
|
||||
'expPerGoldSeed',
|
||||
];
|
||||
const lines = [headers.join(',')];
|
||||
for (const r of rows) {
|
||||
lines.push(headers.map(h => csvCell(r[h])).join(','));
|
||||
}
|
||||
fs.writeFileSync(outPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function writeSummaryTxt(outPath, opts, meta, topNo, topFert, levelInfo) {
|
||||
const lines = [];
|
||||
lines.push('经验收益率分析结果');
|
||||
lines.push('');
|
||||
lines.push(`数据源: ${meta.input}`);
|
||||
lines.push(`导出时间: ${new Date().toISOString()}`);
|
||||
lines.push(`地块数: ${opts.lands}`);
|
||||
lines.push(`种植速度(不施肥): ${NO_FERT_PLANTS_PER_2_SEC}块/${2}s (${NO_FERT_PLANT_SPEED_PER_SEC}块/s)`);
|
||||
lines.push(`种植速度(普通肥): ${NORMAL_FERT_PLANTS_PER_2_SEC}块/${2}s (${NORMAL_FERT_PLANT_SPEED_PER_SEC}块/s)`);
|
||||
lines.push(`整场种植耗时(不施肥): ${formatSec(meta.plantSecondsNoFert)}`);
|
||||
lines.push(`整场种植耗时(普通肥): ${formatSec(meta.plantSecondsNormalFert)}`);
|
||||
lines.push(`普通肥规则: 直接减少一个生长阶段(按 Plant.json 的首个阶段时长)`);
|
||||
lines.push(`缺少阶段配置的种子数: ${meta.missingPhaseReduceCount}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push(`Top ${topNo.length}(不施肥,按每小时经验)`);
|
||||
lines.push('排名 | 名称 | Lv需 | 生长 | 单轮经验 | 每小时经验');
|
||||
topNo.forEach((r, i) => {
|
||||
lines.push(
|
||||
`${String(i + 1).padStart(2)} | ${r.name} | ${r.requiredLevel} | ${r.growTimeStr} | ${r.expPerCycle} | ${r.farmExpPerHourNoFert.toFixed(2)}`
|
||||
);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
lines.push(`Top ${topFert.length}(普通肥,按每小时经验)`);
|
||||
lines.push('排名 | 名称 | Lv需 | 肥后生长 | 单轮经验 | 每小时经验 | 提升');
|
||||
topFert.forEach((r, i) => {
|
||||
lines.push(
|
||||
`${String(i + 1).padStart(2)} | ${r.name} | ${r.requiredLevel} | ${r.growTimeNormalFertStr} | ${r.expPerCycle} | ${r.farmExpPerHourNormalFert.toFixed(2)} | ${r.gainPercent.toFixed(2)}%`
|
||||
);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
if (levelInfo) {
|
||||
lines.push(`当前等级 Lv${levelInfo.level} 推荐`);
|
||||
lines.push(`不施肥: ${levelInfo.bestNoFert.name}(seed=${levelInfo.bestNoFert.seedId}) -> ${levelInfo.bestNoFert.expPerHour.toFixed(2)} exp/h`);
|
||||
lines.push(`普通肥: ${levelInfo.bestNormalFert.name}(seed=${levelInfo.bestNormalFert.seedId}) -> ${levelInfo.bestNormalFert.expPerHour.toFixed(2)} exp/h`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
fs.writeFileSync(outPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function analyzeExpYield(opts = {}) {
|
||||
const lands = Math.max(1, Math.floor(toNum(opts.lands, 18)));
|
||||
const level = opts.level == null ? null : Math.max(1, Math.floor(toNum(opts.level, 1)));
|
||||
const top = Math.max(1, Math.floor(toNum(opts.top, 20)));
|
||||
const input = opts.input || DEFAULT_INPUT;
|
||||
const inputAbs = path.resolve(input);
|
||||
const rawSeeds = readSeeds(inputAbs);
|
||||
const seedPhaseReduceMap = loadSeedPhaseReduceMap();
|
||||
const { rows, skipped, plantSecondsNoFert, plantSecondsNormalFert, missingPhaseReduceCount } = buildRows(rawSeeds, lands, seedPhaseReduceMap);
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error('没有可计算的种子数据(请检查输入文件)');
|
||||
}
|
||||
|
||||
const topNo = pickTop(rows, 'farmExpPerHourNoFert', top);
|
||||
const topFert = pickTop(rows, 'farmExpPerHourNormalFert', top);
|
||||
const bestByLevel = buildBestByLevel(rows);
|
||||
|
||||
let currentLevel = null;
|
||||
if (level != null) {
|
||||
currentLevel = bestByLevel.find(x => x.level === level) || null;
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
input: inputAbs,
|
||||
config: {
|
||||
lands,
|
||||
plantSpeedPerSecNoFert: NO_FERT_PLANT_SPEED_PER_SEC,
|
||||
plantSpeedPerSecNormalFert: NORMAL_FERT_PLANT_SPEED_PER_SEC,
|
||||
plantSecondsNoFert,
|
||||
plantSecondsNormalFert,
|
||||
fertilizer: {
|
||||
mode: 'minus_one_phase',
|
||||
},
|
||||
rule: {
|
||||
expPerCycle: 'expHarvest',
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
rawCount: rawSeeds.length,
|
||||
calculatedCount: rows.length,
|
||||
skippedCount: skipped,
|
||||
missingPhaseReduceCount,
|
||||
},
|
||||
topNoFert: topNo.map(r => ({
|
||||
seedId: r.seedId,
|
||||
name: r.name,
|
||||
requiredLevel: r.requiredLevel,
|
||||
expPerHour: Number(r.farmExpPerHourNoFert.toFixed(4)),
|
||||
})),
|
||||
topNormalFert: topFert.map(r => ({
|
||||
seedId: r.seedId,
|
||||
name: r.name,
|
||||
requiredLevel: r.requiredLevel,
|
||||
expPerHour: Number(r.farmExpPerHourNormalFert.toFixed(4)),
|
||||
gainPercent: Number(r.gainPercent.toFixed(4)),
|
||||
})),
|
||||
bestByLevel,
|
||||
currentLevel,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function getPlantingRecommendation(level, lands, opts = {}) {
|
||||
const safeLevel = Math.max(1, Math.floor(toNum(level, 1)));
|
||||
const payload = analyzeExpYield({
|
||||
input: opts.input || DEFAULT_INPUT,
|
||||
lands: lands == null ? 18 : lands,
|
||||
top: opts.top || 20,
|
||||
level: safeLevel,
|
||||
});
|
||||
|
||||
const availableRows = payload.rows.filter(r => r.requiredLevel <= safeLevel);
|
||||
const bestNoFertRow = pickTop(availableRows, 'farmExpPerHourNoFert', 1)[0] || null;
|
||||
const bestNormalFertRow = pickTop(availableRows, 'farmExpPerHourNormalFert', 1)[0] || null;
|
||||
|
||||
return {
|
||||
level: safeLevel,
|
||||
lands: payload.config.lands,
|
||||
input: payload.input,
|
||||
bestNoFert: bestNoFertRow ? {
|
||||
seedId: bestNoFertRow.seedId,
|
||||
name: bestNoFertRow.name,
|
||||
requiredLevel: bestNoFertRow.requiredLevel,
|
||||
expPerHour: Number(bestNoFertRow.farmExpPerHourNoFert.toFixed(4)),
|
||||
} : null,
|
||||
bestNormalFert: bestNormalFertRow ? {
|
||||
seedId: bestNormalFertRow.seedId,
|
||||
name: bestNormalFertRow.name,
|
||||
requiredLevel: bestNormalFertRow.requiredLevel,
|
||||
expPerHour: Number(bestNormalFertRow.farmExpPerHourNormalFert.toFixed(4)),
|
||||
} : null,
|
||||
candidatesNoFert: pickTop(availableRows, 'farmExpPerHourNoFert', opts.top || 20).map(r => ({
|
||||
seedId: r.seedId,
|
||||
name: r.name,
|
||||
requiredLevel: r.requiredLevel,
|
||||
expPerHour: Number(r.farmExpPerHourNoFert.toFixed(4)),
|
||||
})),
|
||||
candidatesNormalFert: pickTop(availableRows, 'farmExpPerHourNormalFert', opts.top || 20).map(r => ({
|
||||
seedId: r.seedId,
|
||||
name: r.name,
|
||||
requiredLevel: r.requiredLevel,
|
||||
expPerHour: Number(r.farmExpPerHourNormalFert.toFixed(4)),
|
||||
gainPercent: Number(r.gainPercent.toFixed(4)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const payload = analyzeExpYield(opts);
|
||||
const rows = payload.rows;
|
||||
const topNo = pickTop(rows, 'farmExpPerHourNoFert', opts.top);
|
||||
const topFert = pickTop(rows, 'farmExpPerHourNormalFert', opts.top);
|
||||
const currentLevel = payload.currentLevel;
|
||||
|
||||
writeJson(path.resolve(opts.outJson), payload);
|
||||
writeCsv(path.resolve(opts.outCsv), rows);
|
||||
writeSummaryTxt(
|
||||
path.resolve(opts.outTxt),
|
||||
opts,
|
||||
{
|
||||
input: payload.input,
|
||||
plantSecondsNoFert: payload.config.plantSecondsNoFert,
|
||||
plantSecondsNormalFert: payload.config.plantSecondsNormalFert,
|
||||
missingPhaseReduceCount: payload.stats.missingPhaseReduceCount,
|
||||
},
|
||||
topNo,
|
||||
topFert,
|
||||
currentLevel
|
||||
);
|
||||
|
||||
console.log(`[收益率] 计算完成,共 ${rows.length} 条(跳过 ${payload.stats.skippedCount} 条)`);
|
||||
console.log(`[收益率] JSON: ${path.resolve(opts.outJson)}`);
|
||||
console.log(`[收益率] CSV : ${path.resolve(opts.outCsv)}`);
|
||||
console.log(`[收益率] TXT : ${path.resolve(opts.outTxt)}`);
|
||||
if (currentLevel) {
|
||||
console.log(`[收益率] Lv${opts.level} 最优(不施肥): ${currentLevel.bestNoFert.name} ${currentLevel.bestNoFert.expPerHour} exp/h`);
|
||||
console.log(`[收益率] Lv${opts.level} 最优(普通肥): ${currentLevel.bestNormalFert.name} ${currentLevel.bestNormalFert.expPerHour} exp/h`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
analyzeExpYield,
|
||||
getPlantingRecommendation,
|
||||
DEFAULT_INPUT,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (e) {
|
||||
console.error(`[收益率] 失败: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
127
211/server/tools/crop_list.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# 农作物列表 (共 123 种)
|
||||
|
||||
| ID | 名称 | 等级 | 季数 | 产量 | 生长(小时) | 阶段详情 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1020001 | 草莓 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;开花:5760;成熟:0; |
|
||||
| 1020002 | 白萝卜 | 1 | 1 | 5 | 0.0h | 种子:30;发芽:30;成熟:0; |
|
||||
| 1020003 | 胡萝卜 | 1 | 1 | 10 | 0.0h | 种子:30;发芽:30;小叶子:30;大叶子:30;成熟:0; |
|
||||
| 1020004 | 玉米 | 1 | 1 | 40 | 1.3h | 种子:960;发芽:960;小叶子:960;大叶子:960;开花:960;成熟:0; |
|
||||
| 1020005 | 土豆 | 1 | 1 | 60 | 2.0h | 种子:1440;发芽:1440;小叶子:1440;大叶子:1440;初熟:1440;成熟:0; |
|
||||
| 1020006 | 茄子 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;开花:5760;成熟:0; |
|
||||
| 1020007 | 番茄 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;开花:5760;成熟:0; |
|
||||
| 1020008 | 豌豆 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;初熟:5760;成熟:0; |
|
||||
| 1020009 | 辣椒 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;初熟:5760;成熟:0; |
|
||||
| 1020010 | 南瓜 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;初熟:5760;成熟:0; |
|
||||
| 1020011 | 苹果 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;小叶子:8640;大叶子:8640;开花:8640;成熟:0; |
|
||||
| 1020013 | 葡萄 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;开花:5760;成熟:0; |
|
||||
| 1020014 | 西瓜 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;开花:5760;成熟:0; |
|
||||
| 1020015 | 香蕉 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;开花:17280;成熟:0; |
|
||||
| 1020016 | 菠萝蜜 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;初熟:3600;成熟:0; |
|
||||
| 1020018 | 桃子 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;开花:5760;小叶子:5760;大叶子:5760;成熟:0; |
|
||||
| 1020019 | 橙子 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;开花:17280;成熟:0; |
|
||||
| 1020022 | 鳄梨 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;成熟:0; |
|
||||
| 1020023 | 石榴 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;开花:5760;成熟:0; |
|
||||
| 1020026 | 柚子 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;开花:17280;成熟:0; |
|
||||
| 1020027 | 菠萝 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;开花:7200;成熟:0; |
|
||||
| 1020029 | 椰子 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;初熟:3600;成熟:0; |
|
||||
| 1020031 | 葫芦 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;初熟:21600;成熟:0; |
|
||||
| 1020033 | 火龙果 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;初熟:21600;成熟:0; |
|
||||
| 1020034 | 樱桃 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;开花:7200;成熟:0; |
|
||||
| 1020035 | 荔枝 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;开花:21600;成熟:0; |
|
||||
| 1020036 | 箬竹 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;初熟:10800;成熟:0; |
|
||||
| 1020037 | 莲藕 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;小叶子:8640;大叶子:8640;开花:8640;成熟:0; |
|
||||
| 1020038 | 木瓜 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;开花:7200;成熟:0; |
|
||||
| 1020039 | 杨桃 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;开花:3600;成熟:0; |
|
||||
| 1020041 | 红玫瑰 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;初熟:17280;成熟:0; |
|
||||
| 1020042 | 柠檬 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;初熟:21600;成熟:0; |
|
||||
| 1020043 | 无花果 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;开花:21600;成熟:0; |
|
||||
| 1020044 | 丝瓜 | 1 | 1 | 200 | 12.0h | 种子:8640;长枝:8640;开花:8640;小叶子:8640;大叶子:8640;结果:0; |
|
||||
| 1020045 | 猕猴桃 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;开花:3600;成熟:0; |
|
||||
| 1020047 | 甘蔗 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;幼苗:8640;分叶:8640;伸长:8640;成熟:0; |
|
||||
| 1020048 | 杨梅 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;开花:7200;成熟:0; |
|
||||
| 1020049 | 花生 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;初熟:7200;成熟:0; |
|
||||
| 1020050 | 蘑菇 | 1 | 2 | 200 | 4.0h | 种子:3600;发芽:3600;大叶子:3600;初熟:3600;成熟:0; |
|
||||
| 1020051 | 红枣 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;小叶子:8640;大叶子:8640;开花:8640;成熟:0; |
|
||||
| 1020052 | 金针菇 | 1 | 2 | 200 | 12.0h | 种子:7200;发菌:7200;出菇:7200;幼菇:10800;初熟:10800;成熟:0; |
|
||||
| 1020053 | 桂圆 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;成熟:0; |
|
||||
| 1020054 | 梨 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;初熟:7200;成熟:0; |
|
||||
| 1020055 | 枇杷 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;初熟:3600;成熟:0; |
|
||||
| 1020056 | 哈密瓜 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;开花:7200;成熟:0; |
|
||||
| 1020057 | 芒果 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;初熟:3600;成熟:0; |
|
||||
| 1020058 | 榴莲 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;成熟:0; |
|
||||
| 1020059 | 大白菜 | 1 | 1 | 20 | 0.1h | 种子:60;发芽:60;幼苗:60;成株:60;卷心:60;成熟:0; |
|
||||
| 1020060 | 水稻 | 1 | 1 | 30 | 0.7h | 种子:480;幼苗:480;秧苗:480;幼穗:480;开花:480;成熟:0; |
|
||||
| 1020061 | 小麦 | 1 | 1 | 40 | 1.0h | 种子:720;发芽:720;小叶子:720;大叶子:720;幼穗:720;成熟:0; |
|
||||
| 1020062 | 四叶草 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;初熟:17280;成熟:0; |
|
||||
| 1020063 | 苦瓜 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;成熟:0; |
|
||||
| 1020064 | 大葱 | 1 | 1 | 30 | 0.3h | 种子:300;发芽:300;小叶子:300;大叶子:300;成熟:0; |
|
||||
| 1020065 | 大蒜 | 1 | 1 | 20 | 0.2h | 种子:120;发芽:120;幼苗:120;伸长:120;初熟:120;成熟:0; |
|
||||
| 1020066 | 鲜姜 | 1 | 1 | 60 | 1.7h | 种子:1500;发芽:1500;小叶子:1500;大叶子:1500;成熟:0; |
|
||||
| 1020067 | 香瓜 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;开花:3600;成熟:0; |
|
||||
| 1020068 | 冬瓜 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;开花:3600;成熟:0; |
|
||||
| 1020070 | 黄豆 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;小叶子:8640;大叶子:8640;初熟:8640;成熟:0; |
|
||||
| 1020071 | 小白菜 | 1 | 1 | 80 | 2.5h | 种子:2250;发芽:2250;小叶子:2250;大叶子:2250;成熟:0; |
|
||||
| 1020072 | 榛子 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;开花:17280;小叶子:17280;大叶子:17280;成熟:0; |
|
||||
| 1020073 | 菠菜 | 1 | 1 | 200 | 4.0h | 种子:2880;发芽:2880;小叶子:2880;大叶子:2880;初熟:2880;成熟:0; |
|
||||
| 1020074 | 金桔 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;初熟:21600;成熟:0; |
|
||||
| 1020075 | 桑葚 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;成熟:0; |
|
||||
| 1020076 | 山竹 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;开花:21600;成熟:0; |
|
||||
| 1020077 | 蓝莓 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;初熟:7200;成熟:0; |
|
||||
| 1020078 | 杏子 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;初熟:10800;成熟:0; |
|
||||
| 1020079 | 番石榴 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;开花:21600;成熟:0; |
|
||||
| 1020080 | 月柿 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;开花:21600;成熟:0; |
|
||||
| 1020083 | 红毛丹 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;初熟:3600;成熟:0; |
|
||||
| 1020084 | 芭蕉 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;结果:0; |
|
||||
| 1020085 | 番荔枝 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;开花:3600;结果:0; |
|
||||
| 1020086 | 橄榄 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;成熟:0; |
|
||||
| 1020087 | 百香果 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;开花:3600;结果:0; |
|
||||
| 1020088 | 灯笼果 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;结果:0; |
|
||||
| 1020089 | 芦荟 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;开花:3600;成熟:0; |
|
||||
| 1020090 | 薄荷 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;成熟:0; |
|
||||
| 1020091 | 山楂 | 1 | 1 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:14400;开花:14400;初熟:14400;成熟:0; |
|
||||
| 1020095 | 栗子 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;小叶子:8640;大叶子:8640;开花:8640;成熟:0; |
|
||||
| 1020096 | 生菜 | 1 | 1 | 80 | 3.0h | 种子:2160;发芽:2160;小叶子:2160;大叶子:2160;初熟:2160;成熟:0; |
|
||||
| 1020097 | 黄瓜 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;小叶子:8640;大叶子:8640;开花:8640;成熟:0; |
|
||||
| 1020098 | 花菜 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;幼苗:8640;卷心:8640;初熟:8640;成熟:0; |
|
||||
| 1020099 | 油菜 | 1 | 1 | 200 | 4.0h | 种子:3600;发芽:3600;小叶子:3600;大叶子:3600;成熟:0; |
|
||||
| 1020100 | 竹笋 | 1 | 1 | 200 | 4.0h | 种子:2880;发芽:2880;幼苗:2880;伸长:2880;初熟:2880;成熟:0; |
|
||||
| 1020103 | 天香百合 | 1 | 1 | 200 | 4.0h | 种子:2880;发芽:2880;小叶子:2880;大叶子:2880;初熟:2880;成熟:0; |
|
||||
| 1020104 | 非洲菊 | 1 | 1 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:2400;花蕾:2400;盛开:2400;成熟:0; |
|
||||
| 1020105 | 小雏菊 | 1 | 1 | 200 | 4.0h | 种子:2880;发芽:2880;小叶子:2880;大叶子:2880;初熟:2880;成熟:0; |
|
||||
| 1020110 | 满天星 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;花蕾:5760;盛开:0; |
|
||||
| 1020116 | 曼陀罗华 | 1 | 2 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:3600;花蕾:3600;盛开:0; |
|
||||
| 1020120 | 蒲公英 | 1 | 1 | 200 | 24.0h | 种子:17280;小叶子:17280;大叶子:17280;花蕾:17280;盛开:17280;成熟:0; |
|
||||
| 1020126 | 曼珠沙华 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;初熟:7200;成熟:0; |
|
||||
| 1020128 | 茉莉花 | 1 | 1 | 200 | 4.0h | 种子:2880;发芽:2880;小叶子:2880;大叶子:2880;花蕾:2880;盛开:0; |
|
||||
| 1020135 | 火绒草 | 1 | 1 | 200 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;花蕾:5760;盛开:0; |
|
||||
| 1020141 | 花香根鸢尾 | 1 | 1 | 200 | 12.0h | 种子:8640;发芽:8640;小叶子:8640;大叶子:8640;花蕾:8640;盛开:0; |
|
||||
| 1020142 | 虞美人 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;花蕾:17280;盛开:0; |
|
||||
| 1020143 | 含羞草 | 1 | 1 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:7200;花蕾:7200;盛开:7200;成熟:0; |
|
||||
| 1020145 | 向日葵 | 1 | 1 | 200 | 4.0h | 种子:2400;发芽:2400;小叶子:2400;大叶子:2400;开花:2400;初熟:2400;成熟:0; |
|
||||
| 1020147 | 牵牛花 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;花蕾:17280;盛开:0; |
|
||||
| 1020161 | 秋菊(黄色) | 1 | 1 | 200 | 4.0h | 种子:2880;发芽:2880;小叶子:2880;大叶子:2880;花蕾:2880;盛开:0; |
|
||||
| 1020162 | 秋菊(红色) | 1 | 1 | 200 | 4.0h | 种子:2880;发芽:2880;小叶子:2880;大叶子:2880;花蕾:2880;盛开:0; |
|
||||
| 1020201 | 天山雪莲 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;幼株:21600;成熟:0; |
|
||||
| 1020202 | 金边灵芝 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;芝蕾:4800;幼芝:7200;初熟:7200;成熟:0; |
|
||||
| 1020204 | 人参 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;初熟:7200;成熟:0; |
|
||||
| 1020218 | 瓶子树 | 1 | 2 | 200 | 4.0h | 种子:2400;长枝:2400;小叶子:2400;大叶子:3600;初熟:3600;成树:0; |
|
||||
| 1020220 | 猪笼草 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;初熟:10800;成熟:0; |
|
||||
| 1020221 | 天堂鸟 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;花蕾:21600;盛开:0; |
|
||||
| 1020222 | 豹皮花 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;花蕾:7200;盛开:0; |
|
||||
| 1020225 | 宝华玉兰 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;花蕾:7200;盛开:0; |
|
||||
| 1020226 | 依米花 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;花蕾:21600;盛开:0; |
|
||||
| 1020227 | 大王花 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;幼蕾:4800;含苞:7200;初放:7200;盛开:0; |
|
||||
| 1020228 | 人参果 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;开花:21600;成熟:0; |
|
||||
| 1020229 | 何首乌 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;初熟:21600;成熟:0; |
|
||||
| 1020235 | 金花茶 | 1 | 2 | 200 | 8.0h | 种子:4800;发芽:4800;小叶子:4800;大叶子:7200;花蕾:7200;盛开:0; |
|
||||
| 1020242 | 似血杜鹃 | 1 | 2 | 200 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;花蕾:21600;盛开:0; |
|
||||
| 1020259 | 银莲花 | 1 | 1 | 200 | 4.0h | 种子:2880;发芽:2880;小叶:2880;大叶:2880;花蕾:2880;开花:0; |
|
||||
| 1020305 | 韭菜 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;初熟:17280;成熟:0; |
|
||||
| 1020306 | 芹菜 | 1 | 1 | 200 | 24.0h | 种子:17280;发芽:17280;小叶子:17280;大叶子:17280;初熟:17280;成熟:0; |
|
||||
| 1020308 | 核桃 | 1 | 1 | 200 | 12.0h | 种子:10800;发芽:10800;小叶子:10800;大叶子:10800;成熟:0; |
|
||||
| 1020396 | 迎春花 | 1 | 1 | 200 | 4.0h | 种子:2880;幼芽:2880;小叶:2880;大叶:2880;花蕾:2880;开花:0; |
|
||||
| 1020413 | 李子 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;开花:10800;成熟:0; |
|
||||
| 1020442 | 睡莲 | 1 | 2 | 200 | 12.0h | 种子:7200;发芽:7200;小叶子:7200;大叶子:10800;幼株:10800;成熟:0; |
|
||||
| 1021542 | 新春红包 | 1 | 1 | 20 | 8.0h | 种子:5760;发芽:5760;小叶子:5760;大叶子:5760;初熟:5760;成熟:0; |
|
||||
| 2020002 | 白萝卜 | 1 | 1 | 5 | 0.0h | 种子:1;发芽:1;成熟:0; |
|
||||
| 2029998 | 哈哈南瓜 | 1 | 1 | 50 | 24.0h | 种子:14400;发芽:14400;小叶子:14400;大叶子:21600;初熟:21600;成熟:0; |
|
||||
37
211/server/tools/list_crops.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const plantPath = path.join(__dirname, '../gameConfig/Plant.json');
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(plantPath, 'utf8');
|
||||
const plants = JSON.parse(data);
|
||||
|
||||
let content = `# 农作物列表 (共 ${plants.length} 种)\n\n`;
|
||||
content += `| ID | 名称 | 等级 | 季数 | 产量 | 生长(小时) | 阶段详情 |\n`;
|
||||
content += `|---|---|---|---|---|---|---|\n`;
|
||||
|
||||
plants.sort((a, b) => a.land_level_need - b.land_level_need || a.id - b.id);
|
||||
|
||||
plants.forEach(p => {
|
||||
let totalTime = 0;
|
||||
if (p.grow_phases) {
|
||||
const parts = p.grow_phases.split(';');
|
||||
parts.forEach(part => {
|
||||
if (part) {
|
||||
const [stage, time] = part.split(':');
|
||||
if (time) totalTime += parseInt(time);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hours = (totalTime / 3600).toFixed(1);
|
||||
content += `| ${p.id} | ${p.name} | ${p.land_level_need} | ${p.seasons} | ${p.fruit ? p.fruit.count : '?'} | ${hours}h | ${p.grow_phases} |\n`;
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'crop_list.md'), content);
|
||||
console.log('列表已生成: server/tools/crop_list.md');
|
||||
|
||||
} catch (err) {
|
||||
console.error('读取失败:', err);
|
||||
}
|
||||
2007
211/server/tools/seed-shop-merged-export.json
Normal file
25
211/web/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.crop_list.md
|
||||
73
211/web/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
211/web/components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
23
211/web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
211/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>谢尔达莱群岛</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5329
211/web/package-lock.json
generated
Normal file
45
211/web/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
6
211/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
211/web/public/logo.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
101
211/web/public/shop_plants_organized/mapping.csv
Normal file
@@ -0,0 +1,101 @@
|
||||
Seed ID,Seed Name,Plant ID,Plant Name,New File,Original File
|
||||
20002,白萝卜种子,1020002,白萝卜,白萝卜_from_白萝卜种子_1020002.png,aa2d4772-8b90-417c-89be-c098e1cbb35c.edc47.png
|
||||
20003,胡萝卜种子,1020003,胡萝卜,胡萝卜_from_胡萝卜种子_1020003.png,a823c190-2bd8-4b86-bbac-6c5cbf0c465d.3a774.png
|
||||
20059,大白菜种子,1020059,大白菜,大白菜_from_大白菜种子_1020059.png,d4057a83-ecef-45fb-8988-905b56ae7ce3.b3280.png
|
||||
20065,大蒜种子,1020065,大蒜,大蒜_from_大蒜种子_1020065.png,5a1d18bc-32f9-40d7-8cd3-be00b79004cc.2d769.png
|
||||
20064,大葱种子,1020064,大葱,大葱_from_大葱种子_1020064.png,9bf36963-4f34-4c9d-b967-73ec64d1e924.2adf2.png
|
||||
20060,水稻种子,1020060,水稻,水稻_from_水稻种子_1020060.png,e059e031-6953-402e-9d2b-88644465dc81.c5f7f.png
|
||||
20061,小麦种子,1020061,小麦,小麦_from_小麦种子_1020061.png,2446c70c-20b6-4e3c-8a2f-5d64a044ded6.7abe2.png
|
||||
20004,玉米种子,1020004,玉米,玉米_from_玉米种子_1020004.png,956ddaa2-27aa-4b9b-aa72-413390ecaaf5.de27e.png
|
||||
20066,鲜姜种子,1020066,鲜姜,鲜姜_from_鲜姜种子_1020066.png,3a7230b2-a8b4-434a-83bf-4cb850c6b291.2dbd6.png
|
||||
20005,土豆种子,1020005,土豆,土豆_from_土豆种子_1020005.png,95f857bc-ce3a-47f6-aae0-20ec6f84d21a.aad11.png
|
||||
20071,小白菜种子,1020071,小白菜,小白菜_from_小白菜种子_1020071.png,07ca7a88-3983-42f9-8402-fdec001cb51c.d3538.png
|
||||
20096,生菜种子,1020096,生菜,生菜_from_生菜种子_1020096.png,c7879560-b0d9-4f21-ab73-3fb4f083ddc5.9ce85.png
|
||||
20099,油菜种子,1020099,油菜,油菜_from_油菜种子_1020099.png,35f65e28-3b35-4466-a040-e1b686085c23.0d18d.png
|
||||
20006,茄子种子,1020006,茄子,茄子_from_茄子种子_1020006.png,2a626649-1952-41ef-8d5a-5ebbb726e479.ae717.png
|
||||
20051,红枣种子,1020051,红枣,红枣_from_红枣种子_1020051.png,095fd7f0-24a0-415a-b4b8-96a8aeb07ba5.5e6bb.png
|
||||
20120,蒲公英种子,1020120,蒲公英,蒲公英_from_蒲公英种子_1020120.png,8de9453f-e6ef-40ff-a678-3da9bb861bb5.462a2.png
|
||||
20259,银莲花种子,1020259,银莲花,银莲花_from_银莲花种子_1020259.png,8df7c28d-ba7c-477d-a68e-39fb027e3969.d8964.png
|
||||
20007,番茄种子,1020007,番茄,番茄_from_番茄种子_1020007.png,f7d5b3a4-c759-461f-bd81-79d8df59cbe2.af9b3.png
|
||||
20098,花菜种子,1020098,花菜,花菜_from_花菜种子_1020098.png,f44f642e-4cd9-4c3d-bb25-269ba52d424a.1cbab.png
|
||||
20305,韭菜种子,1020305,韭菜,韭菜_from_韭菜种子_1020305.png,7599bc40-a5d7-4834-8ed8-3e6eab7216ff.37c46.png
|
||||
20105,小雏菊种子,1020105,小雏菊,小雏菊_from_小雏菊种子_1020105.png,b4f19058-1031-420f-ab64-feb4432c60a6.db1b3.png
|
||||
20008,豌豆种子,1020008,豌豆,豌豆_from_豌豆种子_1020008.png,f55a4326-3e65-429e-a615-f5c7c867e451.154ba.png
|
||||
20037,莲藕种子,1020037,莲藕,莲藕_from_莲藕种子_1020037.png,bb9c6484-1269-44f1-a203-74aa05fecc1d.1199a.png
|
||||
20041,红玫瑰种子,1020041,红玫瑰,红玫瑰_from_红玫瑰种子_1020041.png,340b1098-6163-404c-97f9-86cbbc00336b.9b2a8.png
|
||||
20161,黄色秋菊种子,1020161,秋菊(黄色),秋菊黄色_from_黄色秋菊种子_1020161.png,b72a754f-4e24-451d-a12a-a318f3937a05.641b4.png
|
||||
20110,满天星种子,1020110,满天星,满天星_from_满天星种子_1020110.png,48593983-6831-4a94-8678-28a8c4304f54.39b33.png
|
||||
20143,含羞草种子,1020143,含羞草,含羞草_from_含羞草种子_1020143.png,ee3d377e-30e5-4572-bd4d-6019cf1e6352.b468e.png
|
||||
20147,牵牛花种子,1020147,牵牛花,牵牛花_from_牵牛花种子_1020147.png,54ce926b-ef79-4f1b-920b-756d8368408f.b863a.png
|
||||
20162,红色秋菊种子,1020162,秋菊(红色),秋菊红色_from_红色秋菊种子_1020162.png,f3a09490-43c0-4d04-8a3a-679f9e25efe8.e0011.png
|
||||
20009,辣椒种子,1020009,辣椒,辣椒_from_辣椒种子_1020009.png,7eed9dde-2174-4d6d-84e2-5fdb7276eaf3.7d364.png
|
||||
20097,黄瓜种子,1020097,黄瓜,黄瓜_from_黄瓜种子_1020097.png,722e3547-d04e-4501-9aad-148af16cc8cb.bc687.png
|
||||
20306,芹菜种子,1020306,芹菜,芹菜_from_芹菜种子_1020306.png,6d172f29-286f-4f58-88f7-e6776a863ef7.94097.png
|
||||
20103,天香百合种子,1020103,天香百合,天香百合_from_天香百合种子_1020103.png,e5f9d964-28ee-4491-b714-ba917a9cec87.2710f.png
|
||||
20010,南瓜种子,1020010,南瓜,南瓜_from_南瓜种子_1020010.png,0009ad77-fd65-48fa-af78-29c9872d8476.c388e.png
|
||||
20308,核桃种子,1020308,核桃,核桃_from_核桃种子_1020308.png,b3b7045f-2b34-41ac-9e94-aaaf6083e06a.cf434.png
|
||||
20091,山楂种子,1020091,山楂,山楂_from_山楂种子_1020091.png,4501b8e9-144e-497a-b282-ca1df8e17ee2.0f537.png
|
||||
20073,菠菜种子,1020073,菠菜,菠菜_from_菠菜种子_1020073.png,a9429882-6c61-4d3b-83a4-38da55470b6a.22bda.png
|
||||
20001,草莓种子,1020001,草莓,草莓_from_草莓种子_1020001.png,8d7c740a-e144-458d-95c3-15d95efa8bc3.17504.png
|
||||
20011,苹果种子,1020011,苹果,苹果_from_苹果种子_1020011.png,5eff94d0-05ff-4e1d-a584-cfa473dcb5ca.250fc.png
|
||||
20062,四叶草种子,1020062,四叶草,四叶草_from_四叶草种子_1020062.png,35aba40d-33bc-465c-8c58-f9b45bd61ca1.b4b9d.png
|
||||
20104,非洲菊种子,1020104,非洲菊,非洲菊_from_非洲菊种子_1020104.png,5432d98e-84cb-4456-af35-ea2b8b194efe.cab54.png
|
||||
20135,火绒草种子,1020135,火绒草,火绒草_from_火绒草种子_1020135.png,637a9c4e-fe92-4416-b913-f25ac46daab9.0fcc7.png
|
||||
20141,花香根鸢尾种子,1020141,花香根鸢尾,花香根鸢尾_from_花香根鸢尾种子_1020141.png,5687ef62-528d-4faf-8380-a3d9fc14dd6f.a64a2.png
|
||||
20142,虞美人种子,1020142,虞美人,虞美人_from_虞美人种子_1020142.png,74b327cb-e6db-496b-b665-3b3ceeee04f0.697d9.png
|
||||
20145,向日葵种子,1020145,向日葵,向日葵_from_向日葵种子_1020145.png,fe45b6b7-3081-4541-bd1f-54bfded448ee.10c63.png
|
||||
20014,西瓜种子,1020014,西瓜,西瓜_from_西瓜种子_1020014.png,74d60e9d-aee3-4fad-be3c-6c34b2c479d2.4a5b0.png
|
||||
20070,黄豆种子,1020070,黄豆,黄豆_from_黄豆种子_1020070.png,de01453f-1159-490b-b312-6f9906cb8e38.426f8.png
|
||||
20015,香蕉种子,1020015,香蕉,香蕉_from_香蕉种子_1020015.png,0ea242a7-5188-4296-8700-b4a2e00347b8.c925f.png
|
||||
20100,竹笋种子,1020100,竹笋,竹笋_from_竹笋种子_1020100.png,c0fc1d8d-0c10-471a-83f4-14c400902a39.0beb7.png
|
||||
20018,桃子种子,1020018,桃子,桃子_from_桃子种子_1020018.png,9ec730e7-4a9b-4825-bb45-046f1c5477b1.81f7f.png
|
||||
20047,甘蔗种子,1020047,甘蔗,甘蔗_from_甘蔗种子_1020047.png,fabbc1bf-6622-433a-9471-8839d4a1aad3.b0296.png
|
||||
20019,橙子种子,1020019,橙子,橙子_from_橙子种子_1020019.png,be59e9ef-d6b5-4a70-8b1d-b2a81851eaa2.537dc.png
|
||||
20128,茉莉花种子,1020128,茉莉花,茉莉花_from_茉莉花种子_1020128.png,17d7dee5-e114-46f0-8017-506863370df9.ded8d.png
|
||||
20013,葡萄种子,1020013,葡萄,葡萄_from_葡萄种子_1020013.png,4d63db88-33bc-4783-aeba-5213c0db86b9.cb70d.png
|
||||
20044,丝瓜种子,1020044,丝瓜,丝瓜_from_丝瓜种子_1020044.png,5c446801-84f3-44fd-a066-9264a619ae04.e025a.png
|
||||
20072,榛子种子,1020072,榛子,榛子_from_榛子种子_1020072.png,57888e00-33a6-4e1e-b7f7-b6a67b651935.576df.png
|
||||
20396,迎春花种子,1020396,迎春花,迎春花_from_迎春花种子_1020396.png,32376957-bcd7-4dca-9bdc-7053da6fae7b.c0f12.png
|
||||
20023,石榴种子,1020023,石榴,石榴_from_石榴种子_1020023.png,2a303167-26ac-4a76-8238-d8f5b90f2c5f.01562.png
|
||||
20095,栗子种子,1020095,栗子,栗子_from_栗子种子_1020095.png,d1531233-1602-4322-9d08-25445ccd123f.e12b0.png
|
||||
20026,柚子种子,1020026,柚子,柚子_from_柚子种子_1020026.png,c4a71275-ddce-4607-b9fc-93625244d256.6caf7.png
|
||||
20050,蘑菇种子,1020050,蘑菇,蘑菇_from_蘑菇种子_1020050.png,09c2f94f-e865-472f-b599-5e857925ecfe.27d63.png
|
||||
20027,菠萝种子,1020027,菠萝,菠萝_from_菠萝种子_1020027.png,37fed013-1f29-4686-992a-bbbf7bc79350.eadc1.png
|
||||
20036,箬竹种子,1020036,箬竹,箬竹_from_箬竹种子_1020036.png,f75e1ed3-acfd-4bdf-bae5-a64459ed0347.e88b0.png
|
||||
20043,无花果种子,1020043,无花果,无花果_from_无花果种子_1020043.png,68e20b28-d9a4-40cc-a3f0-8ff2d2ba2fc9.d9e75.png
|
||||
20029,椰子种子,1020029,椰子,椰子_from_椰子种子_1020029.png,3a2d2a6e-02b3-4a67-ac63-847f728c1279.c7bf9.png
|
||||
20049,花生种子,1020049,花生,花生_from_花生种子_1020049.png,30f01814-4b36-4838-9c93-d34a823db6a0.416fa.png
|
||||
20052,金针菇种子,1020052,金针菇,金针菇_from_金针菇种子_1020052.png,aa0ddbb8-3102-4428-bad4-c8ad39dfab9e.f2ac9.png
|
||||
20031,葫芦种子,1020031,葫芦,葫芦_from_葫芦种子_1020031.png,7cde3aa5-1732-4215-9b5d-e0aac6550f3f.73909.png
|
||||
20045,猕猴桃种子,1020045,猕猴桃,猕猴桃_from_猕猴桃种子_1020045.png,d4e03a5a-6e87-4761-b29c-83835c5665b9.4dceb.png
|
||||
20054,梨种子,1020054,梨,梨_from_梨种子_1020054.png,93760003-3cc3-4a51-abe4-963543437406.04b44.png
|
||||
20442,睡莲种子,1020442,睡莲,睡莲_from_睡莲种子_1020442.png,00beea60-aa61-4283-a6f3-431605582581.4f00c.png
|
||||
20033,火龙果种子,1020033,火龙果,火龙果_from_火龙果种子_1020033.png,51f55501-d2b0-4c14-929c-abfc08cef212.074ee.png
|
||||
20055,枇杷种子,1020055,枇杷,枇杷_from_枇杷种子_1020055.png,af64a49f-6266-414c-810b-0e03a2edaf11.e40c4.png
|
||||
20034,樱桃种子,1020034,樱桃,樱桃_from_樱桃种子_1020034.png,01f07ecf-6303-4b54-8a1a-ea64f5928855.8315d.png
|
||||
20413,李子种子,1020413,李子,李子_from_李子种子_1020413.png,ddd8134a-15e5-4fbd-a7f6-a26457e638e2.e7bfc.png
|
||||
20035,荔枝种子,1020035,荔枝,荔枝_from_荔枝种子_1020035.png,07232d7e-3d5e-4d48-923e-d70c2b64873b.85999.png
|
||||
20067,香瓜种子,1020067,香瓜,香瓜_from_香瓜种子_1020067.png,4a30dcd3-efd2-4ff2-b406-7e81ab3f4923.281bf.png
|
||||
20038,木瓜种子,1020038,木瓜,木瓜_from_木瓜种子_1020038.png,8816cd72-1ebd-4904-bd50-509a8e3798fb.32cf3.png
|
||||
20053,桂圆种子,1020053,桂圆,桂圆_from_桂圆种子_1020053.png,ef610bda-bc53-46e7-a0fa-a519a8b180d0.db94d.png
|
||||
20080,月柿种子,1020080,月柿,月柿_from_月柿种子_1020080.png,727dfe57-ed34-4e96-b8e2-bc856211c39f.4f667.png
|
||||
20039,杨桃种子,1020039,杨桃,杨桃_from_杨桃种子_1020039.png,3be855b2-ac62-4e8f-b754-8ef8d8d1e46a.e08ca.png
|
||||
20056,哈密瓜种子,1020056,哈密瓜,哈密瓜_from_哈密瓜种子_1020056.png,0e787d09-1443-4c0f-b496-e0f037983758.59eff.png
|
||||
20075,桑葚种子,1020075,桑葚,桑葚_from_桑葚种子_1020075.png,e1c7e781-d74f-478d-b44e-cd37fc54294e.b2f01.png
|
||||
20042,柠檬种子,1020042,柠檬,柠檬_from_柠檬种子_1020042.png,d78193cf-a7a5-40b4-ae67-d1519480503e.188c4.png
|
||||
20057,芒果种子,1020057,芒果,芒果_from_芒果种子_1020057.png,44bd1002-5c47-47be-b9bc-1ae0fb9a26f2.4c8b1.png
|
||||
20048,杨梅种子,1020048,杨梅,杨梅_from_杨梅种子_1020048.png,9eb47072-a3ae-4239-99b3-5d1fde9129d9.415cc.png
|
||||
20058,榴莲种子,1020058,榴莲,榴莲_from_榴莲种子_1020058.png,7816ce22-6077-4175-8632-9f147402c98f.d5a93.png
|
||||
20079,番石榴种子,1020079,番石榴,番石榴_from_番石榴种子_1020079.png,57fada3e-788a-4384-9ff7-b0d50b6a0e57.2fc48.png
|
||||
20218,瓶子树种子,1020218,瓶子树,瓶子树_from_瓶子树种子_1020218.png,189f164a-a8d8-40ec-918f-567249467619.be91f.png
|
||||
20077,蓝莓种子,1020077,蓝莓,蓝莓_from_蓝莓种子_1020077.png,80ae7506-850f-451f-9e18-57c11f62bca2.c3710.png
|
||||
20220,猪笼草种子,1020220,猪笼草,猪笼草_from_猪笼草种子_1020220.png,7d2e3b7f-57e1-4491-8d55-cc485b8a548e.c5826.png
|
||||
20076,山竹种子,1020076,山竹,山竹_from_山竹种子_1020076.png,8c490c1a-5085-4f5f-bf06-0a35b39e10e9.1c974.png
|
||||
20116,曼陀罗华种子,1020116,曼陀罗华,曼陀罗华_from_曼陀罗华种子_1020116.png,6bb50aa4-3d41-426d-8b97-7b3829f045bf.ef4d0.png
|
||||
20126,曼珠沙华种子,1020126,曼珠沙华,曼珠沙华_from_曼珠沙华种子_1020126.png,a7780718-b070-4922-953e-42e3234d0a6b.d2211.png
|
||||
20063,苦瓜种子,1020063,苦瓜,苦瓜_from_苦瓜种子_1020063.png,7565aa2d-1ff4-43f9-aaa4-be0d1a937122.f5ab6.png
|
||||
20221,天堂鸟种子,1020221,天堂鸟,天堂鸟_from_天堂鸟种子_1020221.png,bbeb2945-da57-4d63-bad8-78c046ffe46d.878ce.png
|
||||
20068,冬瓜种子,1020068,冬瓜,冬瓜_from_冬瓜种子_1020068.png,95fe19b7-bb30-4b1a-b70f-fa4fd8e3d893.1ec42.png
|
||||
20222,豹皮花种子,1020222,豹皮花,豹皮花_from_豹皮花种子_1020222.png,8df2f4e2-f8e1-44b3-a5cb-0e9f2617228d.e1dbc.png
|
||||
20078,杏子种子,1020078,杏子,杏子_from_杏子种子_1020078.png,522a790b-e625-4743-bc1d-8a8d1d52738c.67da8.png
|
||||
20074,金桔种子,1020074,金桔,金桔_from_金桔种子_1020074.png,b0811647-bd43-4402-a071-e60e05312943.d1674.png
|
||||
|
BIN
211/web/public/shop_plants_organized/丝瓜_from_丝瓜种子_1020044.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
211/web/public/shop_plants_organized/冬瓜_from_冬瓜种子_1020068.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
211/web/public/shop_plants_organized/南瓜_from_南瓜种子_1020010.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
211/web/public/shop_plants_organized/向日葵_from_向日葵种子_1020145.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
211/web/public/shop_plants_organized/含羞草_from_含羞草种子_1020143.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
211/web/public/shop_plants_organized/哈密瓜_from_哈密瓜种子_1020056.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
211/web/public/shop_plants_organized/四叶草_from_四叶草种子_1020062.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
211/web/public/shop_plants_organized/土豆_from_土豆种子_1020005.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
211/web/public/shop_plants_organized/大白菜_from_大白菜种子_1020059.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
211/web/public/shop_plants_organized/大葱_from_大葱种子_1020064.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
211/web/public/shop_plants_organized/大蒜_from_大蒜种子_1020065.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
211/web/public/shop_plants_organized/天堂鸟_from_天堂鸟种子_1020221.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
BIN
211/web/public/shop_plants_organized/小白菜_from_小白菜种子_1020071.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
211/web/public/shop_plants_organized/小雏菊_from_小雏菊种子_1020105.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
211/web/public/shop_plants_organized/小麦_from_小麦种子_1020061.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
211/web/public/shop_plants_organized/山楂_from_山楂种子_1020091.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
211/web/public/shop_plants_organized/山竹_from_山竹种子_1020076.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
211/web/public/shop_plants_organized/无花果_from_无花果种子_1020043.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
BIN
211/web/public/shop_plants_organized/月柿_from_月柿种子_1020080.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
211/web/public/shop_plants_organized/木瓜_from_木瓜种子_1020038.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
211/web/public/shop_plants_organized/李子_from_李子种子_1020413.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
211/web/public/shop_plants_organized/杏子_from_杏子种子_1020078.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
211/web/public/shop_plants_organized/杨桃_from_杨桃种子_1020039.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
211/web/public/shop_plants_organized/杨梅_from_杨梅种子_1020048.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
211/web/public/shop_plants_organized/枇杷_from_枇杷种子_1020055.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
211/web/public/shop_plants_organized/柚子_from_柚子种子_1020026.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
211/web/public/shop_plants_organized/柠檬_from_柠檬种子_1020042.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
211/web/public/shop_plants_organized/栗子_from_栗子种子_1020095.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
211/web/public/shop_plants_organized/核桃_from_核桃种子_1020308.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
211/web/public/shop_plants_organized/桂圆_from_桂圆种子_1020053.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
211/web/public/shop_plants_organized/桃子_from_桃子种子_1020018.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
211/web/public/shop_plants_organized/桑葚_from_桑葚种子_1020075.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
211/web/public/shop_plants_organized/梨_from_梨种子_1020054.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
211/web/public/shop_plants_organized/椰子_from_椰子种子_1020029.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
211/web/public/shop_plants_organized/榛子_from_榛子种子_1020072.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
211/web/public/shop_plants_organized/榴莲_from_榴莲种子_1020058.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
211/web/public/shop_plants_organized/樱桃_from_樱桃种子_1020034.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
211/web/public/shop_plants_organized/橙子_from_橙子种子_1020019.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
211/web/public/shop_plants_organized/水稻_from_水稻种子_1020060.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
211/web/public/shop_plants_organized/油菜_from_油菜种子_1020099.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
211/web/public/shop_plants_organized/满天星_from_满天星种子_1020110.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
211/web/public/shop_plants_organized/火绒草_from_火绒草种子_1020135.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
211/web/public/shop_plants_organized/火龙果_from_火龙果种子_1020033.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |