295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
// ================================================================
|
||
// 04c_execute_bootcamp_trade.js
|
||
// Auto-Bootcamp & Auto-Rural-Trade loops
|
||
//
|
||
// Both are driven by jitterLoop (registered in 05_main.js boot()).
|
||
// Settings arrive via cmdData.bot_settings in the poll response —
|
||
// no extra network call is needed.
|
||
//
|
||
// Timers (human-like, randomised):
|
||
// Bootcamp : 12–22 min (camp cooldown is ~12–15 min)
|
||
// RuralTrade: 25–45 min (trading is low-urgency)
|
||
// ================================================================
|
||
|
||
// Shared cache — set by pollAndExecute every 8-18 s
|
||
let lastKnownBotSettings = {};
|
||
|
||
// ----------------------------------------------------------------
|
||
// botLog — sends a log entry to the server
|
||
// ----------------------------------------------------------------
|
||
async function botLog(player_id, feature, message) {
|
||
log(`[${feature}] ${message}`);
|
||
try {
|
||
await apiFetch(`${BASE_URL}/api/bot-logs`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ player_id, feature, message })
|
||
});
|
||
} catch (e) { /* non-critical */ }
|
||
}
|
||
|
||
// ================================================================
|
||
// AUTO BOOTCAMP
|
||
// ================================================================
|
||
async function autoBootcampLoop() {
|
||
if (paused) return;
|
||
|
||
const settings = lastKnownBotSettings;
|
||
if (!settings.bootcamp_enabled) return;
|
||
|
||
const player_id = uw.Game?.player_id;
|
||
if (!player_id) return;
|
||
|
||
let model;
|
||
try {
|
||
model = uw.MM.getModelByNameAndPlayerId('PlayerAttackSpot');
|
||
} catch (e) { return; }
|
||
|
||
if (!model) return;
|
||
|
||
// ── 1. Claim reward if available ──────────────────────────────
|
||
try {
|
||
const hasReward = model.hasReward?.();
|
||
if (hasReward) {
|
||
const reward = model.getReward?.();
|
||
if (reward) {
|
||
const isInstant = reward.power_id?.includes('instant');
|
||
const isFavor = reward.power_id?.includes('favor');
|
||
const stashable = reward.stashable;
|
||
|
||
if (isInstant && !isFavor) {
|
||
// Use instant rewards immediately
|
||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||
model_url: `PlayerAttackSpot/${player_id}`,
|
||
action_name: 'useReward',
|
||
arguments: {}
|
||
});
|
||
await botLog(player_id, 'bootcamp', `Reward used: ${reward.power_id}`);
|
||
} else if (stashable) {
|
||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||
model_url: `PlayerAttackSpot/${player_id}`,
|
||
action_name: 'stashReward',
|
||
arguments: {}
|
||
});
|
||
await botLog(player_id, 'bootcamp', `Reward stashed: ${reward.power_id}`);
|
||
} else {
|
||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||
model_url: `PlayerAttackSpot/${player_id}`,
|
||
action_name: 'useReward',
|
||
arguments: {}
|
||
});
|
||
await botLog(player_id, 'bootcamp', `Reward used (fallback): ${reward.power_id}`);
|
||
}
|
||
await sleep(randInt(3000, 7000));
|
||
return; // Wait for next cycle to attack
|
||
}
|
||
}
|
||
} catch (e) { /* reward check failed — continue to attack check */ }
|
||
|
||
// ── 2. Attack if no cooldown ───────────────────────────────────
|
||
try {
|
||
const cooldown = model.getCooldownDuration?.() ?? 1;
|
||
if (cooldown > 0) {
|
||
const minRemaining = Math.round(cooldown / 60);
|
||
await botLog(player_id, 'bootcamp', `Camp on cooldown — ${minRemaining} min remaining`);
|
||
return;
|
||
}
|
||
|
||
// Check no existing attack movement to/from camp
|
||
const movements = uw.MM.getModels()?.MovementsUnits;
|
||
if (movements) {
|
||
for (const mv of Object.values(movements)) {
|
||
if (mv.attributes.destination_is_attack_spot || mv.attributes.origin_is_attack_spot) {
|
||
await botLog(player_id, 'bootcamp', 'Attack already in flight — skipping');
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Collect units from current town
|
||
const currentTownId = uw.Game?.townId;
|
||
if (!currentTownId) return;
|
||
|
||
const town = uw.ITowns?.towns?.[currentTownId];
|
||
if (!town) return;
|
||
|
||
const units = { ...town.units?.() };
|
||
delete units.militia;
|
||
|
||
// Remove naval
|
||
for (const unit in units) {
|
||
if (uw.GameData?.units?.[unit]?.is_naval) delete units[unit];
|
||
}
|
||
|
||
// Remove defensive if use_def is off
|
||
if (!settings.bootcamp_use_def) {
|
||
delete units.sword;
|
||
delete units.archer;
|
||
}
|
||
|
||
// Remove zero-count
|
||
for (const unit in units) {
|
||
if (!units[unit] || units[unit] <= 0) delete units[unit];
|
||
}
|
||
|
||
if (Object.keys(units).length === 0) {
|
||
await botLog(player_id, 'bootcamp', 'No available units — skipping attack');
|
||
return;
|
||
}
|
||
|
||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||
model_url: `PlayerAttackSpot/${player_id}`,
|
||
action_name: 'attack',
|
||
arguments: units
|
||
});
|
||
|
||
const unitSummary = Object.entries(units).map(([u, n]) => `${n}x${u}`).join(', ');
|
||
await botLog(player_id, 'bootcamp', `Attack sent — ${unitSummary}`);
|
||
|
||
} catch (e) {
|
||
await botLog(player_id, 'bootcamp', `Error during attack: ${e}`);
|
||
}
|
||
}
|
||
|
||
|
||
// ================================================================
|
||
// AUTO RURAL TRADE
|
||
// Triggers only when a town's pending build command is stuck due to
|
||
// insufficient resources. Trades for the specific missing resource.
|
||
//
|
||
// Ratio map: 1→0.25, 2→0.5, 3→0.75, 4→1.0, 5→1.25
|
||
// ================================================================
|
||
const RATIO_MAP = { 1: 0.25, 2: 0.50, 3: 0.75, 4: 1.00, 5: 1.25 };
|
||
|
||
async function autoRuralTradeLoop() {
|
||
if (paused) return;
|
||
|
||
const settings = lastKnownBotSettings;
|
||
if (!settings.rural_trade_enabled) return;
|
||
|
||
const player_id = uw.Game?.player_id;
|
||
if (!player_id) return;
|
||
|
||
const minRatio = RATIO_MAP[settings.rural_trade_ratio] ?? 0.75;
|
||
|
||
let farmModels, relModels;
|
||
try {
|
||
farmModels = uw.MM.getOnlyCollectionByName('FarmTown')?.models;
|
||
relModels = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation')?.models;
|
||
} catch (e) { return; }
|
||
|
||
if (!farmModels || !relModels) return;
|
||
|
||
// ── Find towns with stuck build commands ───────────────────────
|
||
// We look at each town's in-game build queue. If the queue slot
|
||
// is empty but we have a pending remote command, the build is
|
||
// waiting — check which resource it needs.
|
||
const towns = uw.ITowns?.towns;
|
||
if (!towns) return;
|
||
|
||
let tradesTotal = 0;
|
||
|
||
for (const [town_id_str, town] of Object.entries(towns)) {
|
||
if (paused) return;
|
||
|
||
// Get next pending build for this town from BuildingBuildData
|
||
let missingResource = null;
|
||
try {
|
||
const buildData = uw.MM.getModels()?.BuildingBuildData?.[town_id_str];
|
||
if (!buildData) continue;
|
||
|
||
const orders = town.buildingOrders?.()?.models ?? [];
|
||
// Only check if the in-game queue has room (build could be submitted)
|
||
const queueCap = uw.GameDataPremium?.isAdvisorActivated('curator') ? 7 : 2;
|
||
if (orders.length >= queueCap) continue; // Queue full — not stuck on resources
|
||
|
||
// Check all building types to find one we might be trying to build
|
||
// Heuristic: look for any building where we have less resources than needed
|
||
const allBuildingData = buildData.attributes?.building_data ?? {};
|
||
const res = town.resources?.() ?? {};
|
||
|
||
for (const [building_id, bdata] of Object.entries(allBuildingData)) {
|
||
if (!bdata.resources_for) continue;
|
||
const cost = bdata.resources_for;
|
||
const w = cost.wood || 0;
|
||
const s = cost.stone || 0;
|
||
const ir = cost.iron || 0;
|
||
|
||
// Skip if we can already afford it
|
||
if (res.wood >= w && res.stone >= s && res.iron >= ir) continue;
|
||
|
||
// Find which resource is most lacking (relative to cost)
|
||
const shortfalls = [];
|
||
if (w > 0) shortfalls.push({ res: 'wood', ratio: res.wood / w });
|
||
if (s > 0) shortfalls.push({ res: 'stone', ratio: res.stone / s });
|
||
if (ir > 0) shortfalls.push({ res: 'iron', ratio: res.iron / ir });
|
||
|
||
if (shortfalls.length === 0) continue;
|
||
shortfalls.sort((a, b) => a.ratio - b.ratio);
|
||
missingResource = shortfalls[0].res;
|
||
break;
|
||
}
|
||
} catch (e) { continue; }
|
||
|
||
if (!missingResource) continue;
|
||
|
||
// ── Find farm villages on this island offering missingResource ──
|
||
const town_obj = town;
|
||
const ix = town_obj.getIslandCoordinateX?.();
|
||
const iy = town_obj.getIslandCoordinateY?.();
|
||
if (ix == null || iy == null) continue;
|
||
|
||
let tradeMade = false;
|
||
for (const farm of farmModels) {
|
||
if (farm.attributes.island_x !== ix || farm.attributes.island_y !== iy) continue;
|
||
if (farm.attributes.resource_offer !== missingResource) continue;
|
||
|
||
for (const rel of relModels) {
|
||
if (rel.attributes.farm_town_id !== farm.attributes.id) continue;
|
||
if (rel.attributes.relation_status !== 1) continue; // must be allied
|
||
|
||
// Check ratio meets minimum
|
||
const tradeRatio = rel.attributes.current_trade_ratio ?? 0;
|
||
if (tradeRatio < minRatio) continue;
|
||
|
||
const tradeCapacity = town_obj.getAvailableTradeCapacity?.() ?? 0;
|
||
if (tradeCapacity < 100) continue;
|
||
|
||
const amount = Math.min(tradeCapacity, 3000);
|
||
|
||
if (paused) return;
|
||
|
||
try {
|
||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||
model_url: `FarmTownPlayerRelation/${rel.id}`,
|
||
action_name: 'trade',
|
||
arguments: { farm_town_id: farm.attributes.id, amount },
|
||
town_id: parseInt(town_id_str)
|
||
});
|
||
await botLog(player_id, 'rural_trade',
|
||
`Traded ${amount} ${missingResource} ← ${farm.attributes.name} via ${town_obj.getName?.() ?? town_id_str}`);
|
||
tradesTotal++;
|
||
tradeMade = true;
|
||
} catch (e) {
|
||
await botLog(player_id, 'rural_trade', `Trade error: ${e}`);
|
||
}
|
||
|
||
await sleep(randInt(800, 1800));
|
||
}
|
||
|
||
if (tradeMade) break; // One trade per town per cycle is enough
|
||
}
|
||
|
||
if (!tradeMade && missingResource) {
|
||
await botLog(player_id, 'rural_trade',
|
||
`${town_obj.getName?.() ?? town_id_str} needs ${missingResource} but no suitable village found`);
|
||
}
|
||
|
||
await sleep(randInt(500, 1200));
|
||
}
|
||
|
||
if (tradesTotal === 0) {
|
||
log('[rural_trade] No stuck builds or no tradeable villages — nothing to do');
|
||
}
|
||
}
|