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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions config/default.ini
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ backgroundResolverEnabled = true
backgroundPartyEnabled = true
phasePolicyEnabled = true
directorEnabled = true
generatedColdTarget = 100
generatedColdBatchSize = 25
maxPlayingPopulation = 1700
starterBotsPerRace = 30
generatedColdBatchSize = 50
generatedColdSeedDelayMs = 45000
activationRadius = 9000
activationLevelRange = 5
Expand Down
4 changes: 4 additions & 0 deletions scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const tests = [
'tests/test_bot_gear_skill_hints.js',
'tests/test_bot_class_progression.js',
'tests/test_generated_cold_skills.js',
'tests/test_population_seed_planner.js',
'tests/test_spot_profile_state_priority.js',
'tests/test_population_starter_party_grouping.js',
'tests/test_bot_goal_state.js',
'tests/test_bot_goal_planner.js',
'tests/test_bot_goal_market_priority.js',
Expand Down Expand Up @@ -87,6 +90,7 @@ const tests = [
'tests/test_npc_sell_shop.js',
'tests/test_personal_warehouse.js',
'tests/test_npc_social_aggro.js',
'tests/test_npc_known_object_lifecycle.js',
'tests/test_npc_respawn.js',
'tests/test_party_companion_rest_follow.js',
'tests/test_party_buff_targets.js',
Expand Down
8 changes: 8 additions & 0 deletions src/Database.js
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,14 @@ const Database = {
);
},

updateCharacterName(id, name) {
return Database.execute(
builder.update('characters', {
name: name
}, 'id = ? LIMIT 1', id)
);
},

updateCharacterExperience(id, level, exp, sp) {
return Database.execute(
builder.update('characters', {
Expand Down
101 changes: 1 addition & 100 deletions src/GameServer/Bot/AI/BotGear.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const Database = invoke('Database');
const DataCache = invoke('GameServer/DataCache');
const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints');
Expand Down Expand Up @@ -30,7 +29,6 @@ const GRADE_BANDS = [
];

const RANK_ORDER = ['none', 'd', 'c', 'b', 'a', 's'];
const WEARABLE_SLOTS = new Set(Object.values(ARMOR_SLOTS));
const NO_GRADE_PRICE_CAPS = [
{ maxLevel: 5, price: 1000 },
{ maxLevel: 10, price: 12500 },
Expand Down Expand Up @@ -314,105 +312,8 @@ function planFor(character) {
};
}

function templateFor(selfId) {
return allItems().find((item) => Number(item.selfId) === Number(selfId)) || null;
}

function isWearableRow(row) {
const template = templateFor(row.selfId);
return !!template && WEARABLE_SLOTS.has(Number(row.slot || template.slot || 0));
}

function desiredKey(item, index) {
return `${item.selfId}:${item.slot}:${index}`;
}

function assignExistingItems(existing, desired) {
const available = new Map();
existing.forEach((row) => {
const selfId = Number(row.selfId || 0);
if (!available.has(selfId)) available.set(selfId, []);
available.get(selfId).push(row);
});

return desired.map((item, index) => {
const rows = available.get(Number(item.selfId)) || [];
const existingRow = rows.shift() || null;
return {
key: desiredKey(item, index),
desired: item,
existing: existingRow
};
});
}

function createMissingItem(characterId, desired) {
return Database.setItem(characterId, {
selfId: desired.selfId,
name: desired.name,
amount: desired.amount || 1,
equipped: false,
slot: desired.slot
}).then((result) => ({
id: Number(result.insertId),
selfId: desired.selfId,
slot: desired.slot,
equipped: false
}));
}

function syncAssignments(characterId, existing, assignments) {
const assignedIds = new Map();
let chain = Promise.resolve();
let changed = false;

assignments.forEach((assignment) => {
chain = chain.then(() => {
if (assignment.existing) return assignment.existing;
changed = true;
return createMissingItem(characterId, assignment.desired);
}).then((row) => {
assignedIds.set(Number(row.id), assignment.desired);
if (Number(row.slot) !== Number(assignment.desired.slot) || Number(row.equipped) !== 1) {
changed = true;
return Database.updateItemEquipState(characterId, row.id, true, assignment.desired.slot);
}
return null;
});
});

existing.filter(isWearableRow).forEach((row) => {
chain = chain.then(() => {
if (assignedIds.has(Number(row.id))) return null;
if (Number(row.equipped) !== 1) return null;
changed = true;
return Database.updateItemEquipState(characterId, row.id, false, row.slot || 0);
});
});

return chain.then(() => changed);
}

const BotGear = {
planFor,

ensureCharacterGear(character, options = {}) {
if (!character || !character.id || options.disabled === true) {
return Promise.resolve({ changed: false, plan: null });
}

const plan = planFor(character);
if (!plan.items.length) return Promise.resolve({ changed: false, plan });

return Database.fetchItems(character.id).then((existing) => {
const assignments = assignExistingItems(existing || [], plan.items);
return syncAssignments(character.id, existing || [], assignments)
.then((changed) => ({ changed, plan }));
}).catch((err) => {
utils.infoWarn('BotGear', 'failed to gear %s: %s', character.name || character.id, err.message);
return { changed: false, plan, error: err.message };
});
}
planFor
};

module.exports = BotGear;
86 changes: 79 additions & 7 deletions src/GameServer/Bot/AI/GearAcquisitionPlanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ const ProgressionRates = invoke('GameServer/ProgressionRates');
const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService');
const CraftSupplementMaterials = invoke('GameServer/Bot/Economy/CraftSupplementMaterials');
const BotGear = invoke('GameServer/Bot/AI/BotGear');
const GearLifecycle = invoke('GameServer/Bot/AI/GearLifecycle');
const MarketOpportunity = invoke('GameServer/Bot/Economy/MarketOpportunity');

const RANKS = ['none', 'd', 'c', 'b', 'a', 's'];
const WEAPON_SLOTS = new Set([7, 14]);
Expand Down Expand Up @@ -153,6 +156,51 @@ function preferredDropTarget(state = {}) {
.sort((a, b) => itemScore(b, role) - itemScore(a, role) || Number(b.template?.price || 0) - Number(a.template?.price || 0))[0] || null;
}

function preferredNoGradeTarget(state = {}) {
const role = roleFor(state);
const ownedItems = inventoryItems(state.inventory);
const classId = Number(state.stats?.classId || state.classId || 0);
const planned = BotGear.planFor({ classId, level: Math.max(GearLifecycle.GEAR_FOCUS_LEVEL, Number(state.level || 1)) });
const uniqueItems = new Set();

return planned.items
.map((desired) => (DataCache.items || []).find((item) => Number(item.selfId) === Number(desired.selfId)))
.filter(Boolean)
.filter((item) => {
if (uniqueItems.has(Number(item.selfId))) return false;
uniqueItems.add(Number(item.selfId));
return isSlotUpgrade(item, ownedItems, role);
})
.sort((a, b) => GearLifecycle.slotPriority(b.etc?.slot) - GearLifecycle.slotPriority(a.etc?.slot)
|| Number(a.template?.price || 0) - Number(b.template?.price || 0))[0] || null;
}

function marketOfferForTarget(target, state = {}, options = {}) {
if (!target) return null;
if (typeof options.findMarketOffer === 'function') return options.findMarketOffer(target, state) || null;
const towns = [...new Set([
state.currentRegion,
...Object.keys(MarketOpportunity.TOWN_NPC_SELLERS || {}),
'Giran'
].filter(Boolean))];
return towns
.map((town) => MarketOpportunity.bestOffer(target.selfId, {
town,
budget: Infinity,
buyerCharacterId: state.characterId
}))
.filter(Boolean)
.sort((left, right) => Number(left.price) - Number(right.price))[0] || null;
}

function expectedAdenaPerKill(state = {}) {
return Math.max(20, Number(state.level || 1) * 25);
}

function marketEffort(offer, state) {
return offer ? Number(offer.price || Infinity) / expectedAdenaPerKill(state) : Infinity;
}

function itemDropChance(reward, itemId, kind = 'drop') {
return itemDropYield(reward, itemId, kind).chance;
}
Expand Down Expand Up @@ -257,10 +305,30 @@ function planFor(state = {}, options = {}) {
if (isCraftService(state)) {
return { status: 'service', strategy: 'none', recipeId: null, materials: [], next: null };
}
if (gradeForLevel(state.level) === 'none' && !options.recipeId) {
const target = preferredDropTarget(state);
if (!GearLifecycle.isGearFocusActive(state)) {
return {
status: 'deferred',
phase: GearLifecycle.phaseFor(state),
strategy: 'none',
recipeId: null,
materials: [],
next: null
};
}
if (!GearLifecycle.allowsCrafting(state) || gradeForLevel(state.level) === 'none') {
const target = preferredNoGradeTarget(state) || preferredDropTarget(state);
const source = target ? bestSourceForState(sourceForItem(target.selfId, options.spots || [], state), state) : null;
return source ? {
const offer = marketOfferForTarget(target, state, options);
const directKills = source ? 1 / Math.max(source.expectedYield, 0.000001) : Infinity;
const buy = offer && marketEffort(offer, state) <= directKills;
return target && buy ? {
status: 'active', phase: GearLifecycle.phaseFor(state), grade: 'none', role: roleFor(state), strategy: 'market', soloSafe: true, requiresParty: false,
rateModelVersion: RATE_MODEL_VERSION,
expectedKills: Math.ceil(marketEffort(offer, state)),
target: { selfId: Number(target.selfId), name: target.template?.name || `Item ${target.selfId}`, slot: Number(target.etc?.slot || 0) },
market: { town: offer.town || 'Giran', price: Number(offer.price), sourceType: offer.sourceType },
recipeId: null, materials: [], next: null
} : source ? {
status: 'active', grade: 'none', role: roleFor(state), strategy: 'direct_drop', soloSafe: soloSafeForSource(state, source), requiresParty: !soloSafeForSource(state, source),
rateModelVersion: RATE_MODEL_VERSION,
expectedKills: Math.ceil(1 / Math.max(source.expectedYield, 0.000001)),
Expand Down Expand Up @@ -288,11 +356,13 @@ function planFor(state = {}, options = {}) {
))[0] || null;
const directKills = direct ? 1 / Math.max(direct.expectedYield, 0.000001) : Infinity;
const craftKills = missingMaterialPlans.reduce((sum, material) => sum + material.missing / Math.max(material.source?.expectedYield || 0.000001, 0.000001), 0);
const offer = marketOfferForTarget(target.item, state, options);
const buy = offer && marketEffort(offer, state) <= Math.min(directKills, craftKills);
const soloSafe = direct && soloSafeForSource(state, direct);
const strategy = soloSafe && directKills <= craftKills * 0.8 ? 'direct_drop' : 'craft';
const strategy = buy ? 'market' : soloSafe && directKills <= craftKills * 0.8 ? 'direct_drop' : 'craft';
const next = strategy === 'direct_drop'
? direct && { ...direct, itemId: Number(target.item.selfId) }
: nextMaterial?.source && { ...nextMaterial.source, itemId: Number(nextMaterial.selfId) };
: strategy === 'craft' ? nextMaterial?.source && { ...nextMaterial.source, itemId: Number(nextMaterial.selfId) } : null;
// Keep final-equipment readiness distinct from an available intermediate
// craft. Both routes go to a station, but reporting a ready Cokes batch
// as "can craft Atuba Mace" made the progression telemetry lie and hid
Expand All @@ -308,7 +378,8 @@ function planFor(state = {}, options = {}) {
&& Boolean(next && !soloSafeForSource(state, next));

return {
status: readyToCraft ? 'ready_to_craft' : componentReady ? 'component_ready' : next ? 'active' : 'blocked',
status: readyToCraft ? 'ready_to_craft' : componentReady ? 'component_ready' : strategy === 'market' || next ? 'active' : 'blocked',
phase: GearLifecycle.phaseFor(state),
grade: String(target.item.etc?.rank || gradeForLevel(state.level)).toLowerCase(),
role: roleFor(state),
rateModelVersion: RATE_MODEL_VERSION,
Expand All @@ -318,6 +389,7 @@ function planFor(state = {}, options = {}) {
soloSafe,
requiresParty,
expectedKills: next ? Math.ceil(strategy === 'direct_drop' ? directKills : craftKills) : 0,
market: buy ? { town: offer.town || 'Giran', price: Number(offer.price), sourceType: offer.sourceType } : null,
materials: materialPlans.map(({ source, ...material }) => ({ ...material, sourceSpotId: source?.spotId || null })),
next: next ? { spotId: next.spotId, npcId: next.npcId, npcName: next.npcName, kind: next.kind, itemId: next.itemId } : null
};
Expand All @@ -344,4 +416,4 @@ function sameObjective(left, right) {
);
}

module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, preferredTarget, preferredDropTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective };
module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective };
31 changes: 31 additions & 0 deletions src/GameServer/Bot/AI/GearLifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const GEAR_FOCUS_LEVEL = 5;
const D_GRADE_LEVEL = 20;

function levelOf(state = {}) {
return Math.max(1, Number(state.level || 1));
}

function phaseFor(state = {}) {
const level = levelOf(state);
if (level < GEAR_FOCUS_LEVEL) return 'starter';
if (level < D_GRADE_LEVEL) return 'no_grade_focus';
return 'grade_progression';
}

function isGearFocusActive(state = {}) {
return levelOf(state) >= GEAR_FOCUS_LEVEL;
}

function allowsCrafting(state = {}) {
return levelOf(state) >= D_GRADE_LEVEL;
}

function slotPriority(slot) {
const value = Number(slot || 0);
if ([7, 14].includes(value)) return 3;
if ([6, 8, 9, 10, 11, 12, 15].includes(value)) return 2;
if ([1, 2, 3, 4, 5].includes(value)) return 1;
return 0;
}

module.exports = { GEAR_FOCUS_LEVEL, D_GRADE_LEVEL, phaseFor, isGearFocusActive, allowsCrafting, slotPriority };
28 changes: 15 additions & 13 deletions src/GameServer/Bot/BotManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory');
const BotBuffs = invoke('GameServer/Bot/AI/BotBuffs');
const BotRoles = invoke('GameServer/Bot/AI/BotRoles');
const BotSkillCapabilities = invoke('GameServer/Bot/AI/BotSkillCapabilities');
const BotGear = invoke('GameServer/Bot/AI/BotGear');
const ShotStock = invoke('GameServer/Inventory/ShotStock');
const PopulationService = invoke('GameServer/Bot/Population/PopulationService');
const SimulationKernel = invoke('GameServer/Bot/Simulation/SimulationKernel');
Expand Down Expand Up @@ -276,17 +275,23 @@ const BotManager = {
register: (registry) => registry.statusProvider(() => ({ initialized: GoalService.initialized }))
});

const bots = [...BOTS_TO_SPAWN.filter((bot) => bot.plan !== 'pk_hunting'), ...MERCHANT_BOTS];
console.info("BotManager :: Starter population: %s", BotPopulation.summarize(BOTS_TO_SPAWN));
// Adventurers now belong exclusively to the persistent population
// seeder. Keeping the old static starters here doubled the fresh-world
// population before wave one had even begun.
const bots = [...MERCHANT_BOTS];
console.info('BotManager :: Static services: merchants=%d; adventure population is seeder-managed', bots.length);

// Wait 5 seconds after startup to let world finish loading
setTimeout(() => {
bots.forEach((botData, idx) => {
this.provisionAndSpawn(botData, idx);
});
this.pkEncounterBots = BotPopulation.pkEncounters();
this.hydratePkEncounterAnchors().finally(() => {
this.startPkEncounterMonitor();
// PK encounter profiles were part of the old static population as
// well. Do not introduce high-level hunters into a newly seeded
// level-one world; they can be reintroduced as a later population
// phase with an explicit lifecycle rule.
this.pkEncounterBots = [];
Promise.resolve().finally(() => {
this.startDynamicScalingMonitor();
this.startStatusLogMonitor();
PopulationService.start();
Expand Down Expand Up @@ -509,21 +514,18 @@ const BotManager = {
classId: firstCharacter.classId,
level: firstCharacter.level
}, firstCharacter.classId);
const gearReady = skillsReady.then(() => Shared.fetchCharacters(username))
const spawnReady = skillsReady.then(() => Shared.fetchCharacters(username))
.then((reconciledCharacters) => {
const reconciledCharacter = reconciledCharacters[0];
if (!reconciledCharacter) return null;
return (firstStoreCfg
? Promise.resolve()
: BotGear.ensureCharacterGear(reconciledCharacter, botData))
.then(() => ShotStock.ensureCharacterStock(reconciledCharacter.id, {
return ShotStock.ensureCharacterStock(reconciledCharacter.id, {
classId: reconciledCharacter.classId,
targetAmount: ShotStock.DEFAULT_TARGET_AMOUNT
}))
})
.then(() => Shared.fetchCharacters(username));
});

gearReady.then((readyCharacters) => {
spawnReady.then((readyCharacters) => {
const character = readyCharacters[0];
if (!character) return;
const storeCfg = merchantConfigFor(botData, character.name);
Expand Down
Loading
Loading