diff --git a/config/default.ini b/config/default.ini index 86bb1bcd..76e62889 100644 --- a/config/default.ini +++ b/config/default.ini @@ -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 diff --git a/scripts/run-tests.js b/scripts/run-tests.js index 8608396b..57a5512e 100644 --- a/scripts/run-tests.js +++ b/scripts/run-tests.js @@ -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', @@ -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', diff --git a/src/Database.js b/src/Database.js index 1f42d3c1..977a55cc 100644 --- a/src/Database.js +++ b/src/Database.js @@ -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', { diff --git a/src/GameServer/Bot/AI/BotGear.js b/src/GameServer/Bot/AI/BotGear.js index 7cfea942..9a1b7437 100644 --- a/src/GameServer/Bot/AI/BotGear.js +++ b/src/GameServer/Bot/AI/BotGear.js @@ -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'); @@ -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 }, @@ -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; diff --git a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js index d9a59f06..a10f150f 100644 --- a/src/GameServer/Bot/AI/GearAcquisitionPlanner.js +++ b/src/GameServer/Bot/AI/GearAcquisitionPlanner.js @@ -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]); @@ -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; } @@ -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)), @@ -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 @@ -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, @@ -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 }; @@ -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 }; diff --git a/src/GameServer/Bot/AI/GearLifecycle.js b/src/GameServer/Bot/AI/GearLifecycle.js new file mode 100644 index 00000000..e06e5a8a --- /dev/null +++ b/src/GameServer/Bot/AI/GearLifecycle.js @@ -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 }; diff --git a/src/GameServer/Bot/BotManager.js b/src/GameServer/Bot/BotManager.js index 9f91d197..2f651f7d 100644 --- a/src/GameServer/Bot/BotManager.js +++ b/src/GameServer/Bot/BotManager.js @@ -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'); @@ -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(); @@ -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); diff --git a/src/GameServer/Bot/BotPopulation.js b/src/GameServer/Bot/BotPopulation.js index 7a07dcf4..eb5f7ad4 100644 --- a/src/GameServer/Bot/BotPopulation.js +++ b/src/GameServer/Bot/BotPopulation.js @@ -14,8 +14,8 @@ const STARTER_REGIONS = [ { name: 'Serra', race: 0, classId: 10, sex: 1 } ], visitors: [ - { name: 'Tovin', race: 4, classId: 53, sex: 0 }, - { name: 'Elandor', race: 1, classId: 18, sex: 0 } + { name: 'Tovin', race: 0, classId: 0, sex: 0 }, + { name: 'Elandor', race: 0, classId: 10, sex: 0 } ], apprentices: [ { name: 'Nolan', race: 0, classId: 0, sex: 0 }, @@ -38,8 +38,8 @@ const STARTER_REGIONS = [ { name: 'Velion', race: 1, classId: 18, sex: 0 } ], visitors: [ - { name: 'Borik', race: 4, classId: 53, sex: 0 }, - { name: 'Rowan', race: 0, classId: 0, sex: 0 } + { name: 'Borik', race: 1, classId: 18, sex: 0 }, + { name: 'Rowan', race: 1, classId: 25, sex: 0 } ], apprentices: [ { name: 'Eirlys', race: 1, classId: 18, sex: 1 }, @@ -62,8 +62,8 @@ const STARTER_REGIONS = [ { name: 'Vorn', race: 2, classId: 31, sex: 0 } ], visitors: [ - { name: 'Korrin', race: 4, classId: 53, sex: 0 }, - { name: 'Selwyn', race: 1, classId: 18, sex: 1 } + { name: 'Korrin', race: 2, classId: 31, sex: 0 }, + { name: 'Selwyn', race: 2, classId: 38, sex: 1 } ], apprentices: [ { name: 'Velyra', race: 2, classId: 31, sex: 1 }, @@ -86,8 +86,8 @@ const STARTER_REGIONS = [ { name: 'Urta', race: 3, classId: 49, sex: 0 } ], visitors: [ - { name: 'Hedin', race: 4, classId: 53, sex: 0 }, - { name: 'Calder', race: 0, classId: 0, sex: 0 } + { name: 'Hedin', race: 3, classId: 44, sex: 0 }, + { name: 'Calder', race: 3, classId: 49, sex: 0 } ], apprentices: [ { name: 'Rugor', race: 3, classId: 44, sex: 0 }, @@ -110,8 +110,8 @@ const STARTER_REGIONS = [ { name: 'Toma', race: 4, classId: 53, sex: 0 } ], visitors: [ - { name: 'Jalen', race: 0, classId: 0, sex: 0 }, - { name: 'Aerin', race: 1, classId: 25, sex: 1 } + { name: 'Jalen', race: 4, classId: 53, sex: 0 }, + { name: 'Aerin', race: 4, classId: 53, sex: 1 } ], apprentices: [ { name: 'Berta', race: 4, classId: 53, sex: 1 }, diff --git a/src/GameServer/Bot/BotSession.js b/src/GameServer/Bot/BotSession.js index eef4b83a..5c98eb73 100644 --- a/src/GameServer/Bot/BotSession.js +++ b/src/GameServer/Bot/BotSession.js @@ -1,5 +1,6 @@ const Actor = invoke('GameServer/Actor/Actor'); const World = invoke('GameServer/World/World'); +const NpcVisibility = invoke('GameServer/World/NpcVisibility'); class BotSession { constructor(username) { @@ -26,6 +27,7 @@ class BotSession { const packet = this.packData(data); World.fetchVisibleUsers(this, creature).forEach((user) => { if (user.socket && typeof user.socket.write === 'function' && user.accountId !== this.accountId) { + NpcVisibility.trackNpcPacket(user, data); if (user.recordOutboundPacket) { user.recordOutboundPacket(data); } diff --git a/src/GameServer/Bot/Economy/ColdMarketListingService.js b/src/GameServer/Bot/Economy/ColdMarketListingService.js index c5b0074d..17590caa 100644 --- a/src/GameServer/Bot/Economy/ColdMarketListingService.js +++ b/src/GameServer/Bot/Economy/ColdMarketListingService.js @@ -10,7 +10,7 @@ const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine'); const DEFAULT_LISTING_MS = 20 * 60 * 1000; const SELL_RETRY_DELAY_MS = 30 * 60 * 1000; -const MARKET_TOWN_ROUTING_VERSION = 4; +const MARKET_TOWN_ROUTING_VERSION = 5; // Captured in-game from the Giran trading square. The inner rectangle is the // central column: it is walkable around, but a private store cannot sit there. const GIRAN_MARKET_PLAZA = Object.freeze({ diff --git a/src/GameServer/Bot/Economy/ItemDisposition.js b/src/GameServer/Bot/Economy/ItemDisposition.js index bee1f5ab..2ed8478f 100644 --- a/src/GameServer/Bot/Economy/ItemDisposition.js +++ b/src/GameServer/Bot/Economy/ItemDisposition.js @@ -4,6 +4,7 @@ const BotEconomyPricing = invoke('GameServer/Bot/Economy/BotEconomyPricing'); const SELLABLE_KINDS = ['Weapon.', 'Armor.', 'Other.Material']; const NPC_LIQUIDATION_MAX_UNIT_PRICE = 1000; const WAREHOUSE_GEAR_MIN_BASE_PRICE = 1000; +const TRADE_MIN_LEVEL = 10; function templateFor(selfId) { return (DataCache.items || []).find((item) => Number(item.selfId) === Number(selfId)) || null; @@ -33,7 +34,26 @@ function reservedCraftAmounts(state) { }, {}); } +function isTradeEligible(state = {}) { + // Purpose-built static merchant/craft services are not adventurers and + // retain their normal storefronts. Generated characters start selling + // only once their first leveling/gear loop has had time to produce useful + // surplus. + if (!state.stats?.generatedCold) return true; + return Number(state.level || 1) >= TRADE_MIN_LEVEL; +} + +function protectedStarterLootAmount(item, kind) { + // Low-level resources remain sellable once the character reaches the + // trading phase: they are a legitimate early Adena source. Ordinary gear + // and drops from level 1-5 mobs are retained instead of becoming instant + // private-store/NPC-liquidation stock. + if (String(kind || '').startsWith('Other.Material')) return 0; + return Math.max(0, Math.min(Number(item?.amount || 0), Number(item?.starterMobLootAmount || 0))); +} + function saleCandidates(state, options = {}) { + if (!isTradeEligible(state)) return []; const limit = Math.max(1, Math.min(20, Number(options.limit) || 8)); const reserved = { ...reservedCraftAmounts(state), ...(options.reserved || {}) }; return Object.values(state?.inventory || {}).flatMap((item) => { @@ -46,15 +66,17 @@ function saleCandidates(state, options = {}) { const kind = item.kind || template?.template?.kind || ''; if (!SELLABLE_KINDS.some((prefix) => kind.startsWith(prefix))) return []; + const protectedAmount = protectedStarterLootAmount(item, kind); + const sellableCount = Math.max(0, sellableAmount - protectedAmount); const base = basePrice(item, template); const price = priceFor(state, item, template); - if (price <= 0) return []; + if (price <= 0 || sellableCount <= 0) return []; return [{ selfId, name: item.name || template?.template?.name || `Item ${selfId}`, kind, rank: item.rank || template?.etc?.rank || 'none', - count: sellableAmount, + count: sellableCount, price, basePrice: base }]; @@ -97,11 +119,14 @@ function saleSummary(state, options = {}) { module.exports = { NPC_LIQUIDATION_MAX_UNIT_PRICE, + TRADE_MIN_LEVEL, WAREHOUSE_GEAR_MIN_BASE_PRICE, basePrice, + isTradeEligible, isWarehouseCandidate, npcLiquidationCandidates, priceFor, + protectedStarterLootAmount, reservedCraftAmounts, saleCandidates, saleSummary, diff --git a/src/GameServer/Bot/Economy/MarketTownPolicy.js b/src/GameServer/Bot/Economy/MarketTownPolicy.js index ab9fe3e8..cfd365cd 100644 --- a/src/GameServer/Bot/Economy/MarketTownPolicy.js +++ b/src/GameServer/Bot/Economy/MarketTownPolicy.js @@ -6,9 +6,6 @@ let rankIndexSource = null; let rankIndexSize = -1; let rankBySelfId = new Map(); -// Add a town here only after its sellable no-grade plaza has been captured. -// This prevents cheap local loot from silently falling back to the Giran hub. -const NO_GRADE_MARKET_TOWNS = new Set(['Talking Island', 'Elven Village', 'Dark Elven Village', 'Orc Village', 'Dwarven Village']); const NO_GRADE_MARKETS = Object.freeze([ { name: 'Talking Island', locX: -84700, locY: 244200, radius: 12000 }, { name: 'Elven Village', locX: 46600, locY: 49700, radius: 12000 }, @@ -28,12 +25,12 @@ function marketTown(name) { }; } -function nearbyNoGradeMarket(loc = {}) { +function nearestNoGradeMarket(loc = {}) { const x = Number(loc.locX || 0); const y = Number(loc.locY || 0); + if (!Number.isFinite(x) || !Number.isFinite(y) || (x === 0 && y === 0)) return null; return NO_GRADE_MARKETS .map((market) => ({ ...market, distance: Math.hypot(x - market.locX, y - market.locY) })) - .filter((market) => market.distance <= market.radius) .sort((a, b) => a.distance - b.distance)[0] || null; } @@ -64,9 +61,13 @@ function targetTownForItems(state, items = []) { // A listed bot now stands at the market, so use its saved departure point // to preserve local no-grade routing during legacy-store migrations. const saleOrigin = state?.stats?.marketReturn?.loc || state?.loc; - const localTown = nearbyNoGradeMarket(saleOrigin)?.name || null; + const localTown = nearestNoGradeMarket(saleOrigin)?.name || null; - if (onlyNoGrade) return NO_GRADE_MARKET_TOWNS.has(localTown) ? localTown : 'Giran'; + // No-grade stock belongs to the starter village nearest the bot's actual + // farming location. Early hunting routes legitimately extend beyond a + // village's immediate square, so a small-radius check funnels Elven, + // Dark Elven, and Talking Island sellers into Giran incorrectly. + if (onlyNoGrade) return localTown || 'Giran'; if (!hasHigherGrade && hasDGrade) return dGradeMarketFor(state); return 'Giran'; } @@ -77,11 +78,10 @@ function targetTownForSale(state) { module.exports = { GLUDIO_D_GRADE_SHARE_PERCENT, - NO_GRADE_MARKET_TOWNS, NO_GRADE_MARKETS, dGradeMarketFor, marketTown, - nearbyNoGradeMarket, + nearestNoGradeMarket, targetTownForItems, targetTownForSale }; diff --git a/src/GameServer/Bot/Goals/GoalExecutor.js b/src/GameServer/Bot/Goals/GoalExecutor.js index ec1800c9..fc662924 100644 --- a/src/GameServer/Bot/Goals/GoalExecutor.js +++ b/src/GameServer/Bot/Goals/GoalExecutor.js @@ -22,7 +22,9 @@ function beginMarketTravel(state, goal, timestamp = Date.now()) { if ((buyingGear || buyingMaterial) && Number(state.stats?.marketRetryAfter || 0) > timestamp) return null; if (sellingInventory && Number(state.stats?.marketSellRetryAfter || 0) > timestamp) return null; - const town = sellingInventory ? marketTown(MarketTownPolicy.targetTownForSale(state)) : marketTown('Giran'); + const town = sellingInventory + ? marketTown(MarketTownPolicy.targetTownForSale(state)) + : marketTown(goal.plan?.marketTown || 'Giran'); if (!town) return null; const from = { ...state.loc }; const nearestTown = TownRespawn.getClosestTown(from.locX, from.locY); diff --git a/src/GameServer/Bot/Goals/NeedsEvaluator.js b/src/GameServer/Bot/Goals/NeedsEvaluator.js index 77400d52..47065078 100644 --- a/src/GameServer/Bot/Goals/NeedsEvaluator.js +++ b/src/GameServer/Bot/Goals/NeedsEvaluator.js @@ -1,6 +1,7 @@ const BotGear = invoke('GameServer/Bot/AI/BotGear'); const DataCache = invoke('GameServer/DataCache'); const ItemDisposition = invoke('GameServer/Bot/Economy/ItemDisposition'); +const GearLifecycle = invoke('GameServer/Bot/AI/GearLifecycle'); const RANK_ORDER = ['none', 'd', 'c', 'b', 'a', 's']; // Weapons make the largest immediate difference, then core armour. The two @@ -31,6 +32,7 @@ function rankIndex(rank) { } function equipmentNeed(state) { + if (!GearLifecycle.isGearFocusActive(state)) return null; const equipment = state.stats?.equipment; if (!Array.isArray(equipment)) return null; @@ -45,23 +47,40 @@ function equipmentNeed(state) { const desiredRank = String(item.rank || build.grade || 'none').toLowerCase(); return !currentItem || rankIndex(currentItem.rank) < rankIndex(desiredRank); }) || null; - if (!desiredItem) return null; - - const currentItem = equipment.find((item) => Number(item.slot) === Number(desiredItem.slot)) || null; - const desiredRank = String(desiredItem.rank || build.grade || 'none').toLowerCase(); - - const template = (DataCache.items || []).find((item) => Number(item.selfId) === Number(desiredItem.selfId)); - const price = Math.max(1, Number(template?.template?.price || 0)); + const acquisitionPlan = state.stats?.equipmentPlan; + if (['direct_drop', 'craft'].includes(acquisitionPlan?.strategy) && acquisitionPlan?.status === 'active') return null; + const plannedTarget = acquisitionPlan?.strategy === 'market' && acquisitionPlan?.target + ? (DataCache.items || []).find((item) => Number(item.selfId) === Number(acquisitionPlan.target.selfId)) + : null; + const plannedSlot = Number(plannedTarget?.etc?.slot || 0); + const plannedAlreadyEquipped = plannedSlot > 0 && equipment.some((item) => ( + Number(item.slot) === plannedSlot && Number(item.selfId) === Number(plannedTarget.selfId) + )); + const selectedItem = plannedTarget && !plannedAlreadyEquipped ? plannedTarget : desiredItem; + if (!selectedItem) return null; + + const currentItem = equipment.find((item) => Number(item.slot) === Number(selectedItem.etc?.slot || selectedItem.slot)) || null; + const desiredRank = String(selectedItem.etc?.rank || selectedItem.rank || build.grade || 'none').toLowerCase(); + + // A just-completed market plan remains on the state until the next + // resolver pass. Once its target is equipped, the generic build may pick + // a different next slot; do not send that new purchase to the old offer's + // town or fund it with the old price. + const usingPlannedTarget = Number(selectedItem.selfId) === Number(plannedTarget?.selfId); + const template = (DataCache.items || []).find((item) => Number(item.selfId) === Number(selectedItem.selfId)); + const plannedMarket = usingPlannedTarget ? acquisitionPlan?.market : null; + const price = Math.max(1, Number(plannedMarket?.price || template?.template?.price || 0)); return { currentItem, desiredRank, - slot: Number(desiredItem.slot), - slotName: EQUIPMENT_SLOT_NAMES[Number(desiredItem.slot)] || `slot_${desiredItem.slot}`, + slot: Number(selectedItem.etc?.slot || selectedItem.slot), + slotName: EQUIPMENT_SLOT_NAMES[Number(selectedItem.etc?.slot || selectedItem.slot)] || `slot_${selectedItem.etc?.slot || selectedItem.slot}`, desiredItem: { - selfId: Number(desiredItem.selfId), - name: desiredItem.name || template?.template?.name || `Item ${desiredItem.selfId}`, + selfId: Number(selectedItem.selfId), + name: selectedItem.name || selectedItem.template?.name || template?.template?.name || `Item ${selectedItem.selfId}`, price - } + }, + marketTown: plannedMarket?.town || null }; } @@ -116,7 +135,8 @@ function evaluate(state = {}, options = {}) { ? weaponUpgrade ? 'adena_for_weapon_upgrade' : 'adena_for_gear_upgrade' : weaponUpgrade ? 'market_search_for_weapon' : 'market_search_for_gear', estimatedCost: gear.desiredItem.price, - requiredAdena + requiredAdena, + marketTown: gear.marketTown }, blockers: spot ? [] : ['missing_spot'], nextReviewAt: timestamp + 10 * 60 * 1000 diff --git a/src/GameServer/Bot/MerchantStoreConfigs.js b/src/GameServer/Bot/MerchantStoreConfigs.js index f3213003..e7de2f17 100644 --- a/src/GameServer/Bot/MerchantStoreConfigs.js +++ b/src/GameServer/Bot/MerchantStoreConfigs.js @@ -2,6 +2,16 @@ const BUY_CAP = 999999; const s = (selfId, priceRate, count) => ({ selfId, priceRate, count }); const b = (selfId, priceRate, count = BUY_CAP) => ({ selfId, priceRate, count }); +const SHOT_IDS_BY_GRADE = [ + [1835, 2509, 3947], // No Grade: Soulshot, Spiritshot, Blessed Spiritshot + [1463, 2510, 3948], // D + [1464, 2511, 3949], // C + [1465, 2512, 3950], // B + [1466, 2513, 3951], // A + [1467, 2514, 3952] // S +]; +const shotsForGrade = (grade) => (SHOT_IDS_BY_GRADE[grade] || SHOT_IDS_BY_GRADE[0]) + .map((selfId) => s(selfId, 1, BUY_CAP)); module.exports = { // Talking Island @@ -193,7 +203,7 @@ module.exports = { title: "B/A materials", town: "Oren", storeType: 1, - locX: 83110, locY: 53327, locZ: -1497, + locX: 82600, locY: 53400, locZ: -1488, items: [ s(1885, 0.66, 2500), s(1886, 0.62, 600), s(1887, 0.62, 1200), s(1888, 0.62, 1200), s(1889, 0.64, 2200), s(1890, 0.60, 700), @@ -204,7 +214,7 @@ module.exports = { title: "Oren gear", town: "Oren", storeType: 1, - locX: 83020, locY: 53245, locZ: -1497, + locX: 82700, locY: 53400, locZ: -1488, items: [ s(79, 0.60, 3), s(97, 0.60, 3), s(98, 0.60, 2), s(442, 0.61, 3), s(473, 0.61, 3), s(603, 0.62, 5), @@ -215,7 +225,7 @@ module.exports = { title: "Buy Oren mats", town: "Oren", storeType: 3, - locX: 83150, locY: 53300, locZ: -1497, + locX: 82800, locY: 53400, locZ: -1488, items: [ b(1885, 0.62), b(1886, 0.58), b(1887, 0.60), b(1888, 0.60), b(1889, 0.62), b(1890, 0.58), b(1893, 0.56), b(1894, 0.60), @@ -226,11 +236,119 @@ module.exports = { title: "Buy Oren drops", town: "Oren", storeType: 3, - locX: 83245, locY: 53270, locZ: -1497, + locX: 82900, locY: 53400, locZ: -1488, items: [ b(1830, 0.60), b(1343, 0.55), b(1539, 0.62), b(91, 0.56), b(212, 0.56), b(284, 0.56), b(79, 0.56), b(97, 0.56), b(856, 0.58), b(887, 0.58), b(918, 0.58) ] + }, + + // Dedicated shot stores sell every player shot type at the town's exact + // progression grade. They deliberately do not carry lower grades. + "Tia": { + title: "Shots: No Grade", + town: "Talking Island", + storeType: 1, + locX: -84250, locY: 244680, locZ: -3730, + items: shotsForGrade(0) + }, + "Elya": { + title: "Shots: No Grade", + town: "Elven Village", + storeType: 1, + locX: 47166, locY: 51511, locZ: -2992, + items: shotsForGrade(0) + }, + "Dena": { + title: "Shots: No Grade", + town: "Dark Elven Village", + storeType: 1, + locX: 9550, locY: 15717, locZ: -4568, + items: shotsForGrade(0) + }, + "Orik": { + title: "Shots: No Grade", + town: "Orc Village", + storeType: 1, + locX: -45264, locY: -112292, locZ: -240, + items: shotsForGrade(0) + }, + "Bran": { + title: "Shots: No Grade", + town: "Dwarven Village", + storeType: 1, + locX: 115072, locY: -177956, locZ: -880, + items: shotsForGrade(0) + }, + "Rolf": { + title: "Shots: D Grade", + town: "Gludin", + storeType: 1, + locX: -80620, locY: 150020, locZ: -3040, + items: shotsForGrade(1) + }, + "Sila": { + title: "Shots: D Grade", + town: "Gludio", + storeType: 1, + locX: -14480, locY: 123730, locZ: -3117, + items: shotsForGrade(1) + }, + "Tara": { + title: "Shots: D Grade", + town: "Dion", + storeType: 1, + locX: 15910, locY: 143200, locZ: -2707, + items: shotsForGrade(1) + }, + "Eris": { + title: "Shots: C Grade", + town: "Giran", + storeType: 1, + locX: 83600, locY: 148300, locZ: -3406, + items: shotsForGrade(2) + }, + "Sera": { + title: "Shots: B Grade", + town: "Oren", + storeType: 1, + locX: 83000, locY: 53400, locZ: -1488, + items: shotsForGrade(3) + }, + "Nora": { + title: "Shots: B Grade", + town: "Hunter's Village", + storeType: 1, + locX: 117129, locY: 77137, locZ: -2688, + items: shotsForGrade(3) + }, + "Lina": { + title: "Shots: B Grade", + town: "Heine", + storeType: 1, + locX: 111500, locY: 219500, locZ: -3544, + items: shotsForGrade(3) + }, + "Mila": { + title: "Shots: A Grade", + town: "Aden", + storeType: 1, + locX: 146497, locY: 25807, locZ: -2008, + items: shotsForGrade(4) + }, + "Sven": { + title: "Shots: S Grade", + town: "Goddard", + storeType: 1, + locX: 148050, locY: -55340, locZ: -2728, + items: shotsForGrade(5) + }, + "Runa": { + title: "Shots: S Grade", + town: "Rune", + storeType: 1, + locX: 43950, locY: -47720, locZ: -792, + items: shotsForGrade(5) } }; diff --git a/src/GameServer/Bot/Population/BackgroundDropResolver.js b/src/GameServer/Bot/Population/BackgroundDropResolver.js index 3148d65d..7e604111 100644 --- a/src/GameServer/Bot/Population/BackgroundDropResolver.js +++ b/src/GameServer/Bot/Population/BackgroundDropResolver.js @@ -45,7 +45,7 @@ function selectItem(items, rng) { return null; } -function itemSnapshot(item, amount) { +function itemSnapshot(item, amount, sourceMobLevel = 0) { const template = (DataCache.items || []).find((entry) => Number(entry.selfId) === Number(item.selfId)); if (!template || template.template?.kind === 'Other.Quest') return null; return { @@ -53,10 +53,16 @@ function itemSnapshot(item, amount) { name: item.name || template.template?.name || `Item ${item.selfId}`, amount, kind: template.template?.kind || '', - rank: template.etc?.rank || 'none' + rank: template.etc?.rank || 'none', + sourceMobLevel: Math.max(0, Number(sourceMobLevel) || 0) }; } +function sourceMobLevel(rewardData, spot) { + const npc = (DataCache.npcs || []).find((entry) => Number(entry.selfId) === Number(rewardData?.selfId)); + return Math.max(0, Number(npc?.template?.level || spot?.avgLevel || 0)); +} + function rollForFight({ spot, killerLevel, rng = Math.random, maxItems = 1 } = {}) { const rewardData = rewardDataForSpot(spot, rng); if (!rewardData) return []; @@ -75,7 +81,7 @@ function rollForFight({ spot, killerLevel, rng = Math.random, maxItems = 1 } = { const item = selectItem(group.items, rng); if (!item || Number(item.selfId) === 57) continue; const amount = ProgressionRates.scaleAmount(randInt(rng, item.min, item.max), groupRoll.amountMultiplier, rng); - const snapshot = itemSnapshot(item, amount); + const snapshot = itemSnapshot(item, amount, sourceMobLevel(rewardData, spot)); if (snapshot) drops.push(snapshot); } return drops; diff --git a/src/GameServer/Bot/Population/BotLifeState.js b/src/GameServer/Bot/Population/BotLifeState.js index daeabb06..d7e27388 100644 --- a/src/GameServer/Bot/Population/BotLifeState.js +++ b/src/GameServer/Bot/Population/BotLifeState.js @@ -412,6 +412,17 @@ function hydrateCache() { }); } +function preserveStarterLootProvenance(previousInventory = {}, observedInventory = {}) { + return Object.entries(observedInventory).reduce((inventory, [key, item]) => { + const protectedAmount = Math.min( + Number(item?.amount || 0), + Math.max(0, Number(previousInventory?.[key]?.starterMobLootAmount || 0)) + ); + inventory[key] = protectedAmount > 0 ? { ...item, starterMobLootAmount: protectedAmount } : item; + return inventory; + }, {}); +} + function classProgressionNeeded(state, classId, level) { const knownLevel = Number(state.stats?.classProgressionLevel || 0); const knownClassId = Number(state.stats?.classProgressionClassId ?? state.stats?.classId); @@ -507,7 +518,7 @@ function mergeSessionIntoLifeState(session, state, phase, reason = '', options = lastHotAt: phase === 'hot' ? timestamp : state.timing?.lastHotAt || null }, stats: { ...(state.stats || {}), ...observedStats, lastReason: reason }, - inventory: observedInventory + inventory: preserveStarterLootProvenance(state.inventory, observedInventory) }; } @@ -1124,12 +1135,25 @@ const BotLifeState = { const inventory = { ...(state.inventory || {}) }; materializedItems.filter((item) => Number(item.selfId) !== 57).forEach((item) => { const key = String(item.selfId); + const amount = Number(item.amount || 0); + const kind = item.kind || inventory[key]?.kind || itemTemplate(item.selfId)?.template?.kind || ''; + const protectedStarterLoot = Number(item.sourceMobLevel || 0) > 0 + && Number(item.sourceMobLevel) <= 5 + && !String(kind).startsWith('Other.Material') + ? amount + : 0; + const nextAmount = Number(inventory[key]?.amount || 0) + amount; + const starterMobLootAmount = Math.min( + nextAmount, + Number(inventory[key]?.starterMobLootAmount || 0) + protectedStarterLoot + ); inventory[key] = { selfId: item.selfId, name: item.name || inventory[key]?.name || itemName(item.selfId), - amount: Number(inventory[key]?.amount || 0) + Number(item.amount || 0), - kind: item.kind || inventory[key]?.kind || itemTemplate(item.selfId)?.template?.kind || '', - rank: item.rank || inventory[key]?.rank || itemTemplate(item.selfId)?.etc?.rank || 'none' + amount: nextAmount, + kind, + rank: item.rank || inventory[key]?.rank || itemTemplate(item.selfId)?.etc?.rank || 'none', + ...(starterMobLootAmount > 0 ? { starterMobLootAmount } : {}) }; }); if (adena > 0) { diff --git a/src/GameServer/Bot/Population/BotNameGenerator.js b/src/GameServer/Bot/Population/BotNameGenerator.js new file mode 100644 index 00000000..6104fdd6 --- /dev/null +++ b/src/GameServer/Bot/Population/BotNameGenerator.js @@ -0,0 +1,45 @@ +// Generated population names use readable CamelCase pairs. The account id +// remains the durable technical identity; display names should look like +// player nicknames rather than a syllable hash with a collision suffix. +const GIVEN_NAMES = [ + 'Aelina', 'Aerin', 'Alira', 'Amara', 'Arlen', 'Arwyn', 'Asher', 'Astrid', + 'Brenna', 'Brina', 'Caelan', 'Carys', 'Cedric', 'Celine', 'Corin', 'Cyra', + 'Daria', 'Dorian', 'Eira', 'Elara', 'Elian', 'Elora', 'Emrys', 'Eryn', + 'Faris', 'Fenna', 'Galen', 'Garen', 'Halen', 'Ilyra', 'Irena', 'Isolde', + 'Jaren', 'Kaela', 'Kieran', 'Liora', 'Lucan', 'Lyra', 'Maelin', 'Mara', + 'Nadia', 'Naren', 'Neris', 'Orin', 'Raina', 'Riven', 'Rowan', 'Sable', + 'Seren', 'Silas', 'Sylva', 'Talia', 'Taren', 'Thalia', 'Torin', 'Vaela', + 'Valen', 'Varyn', 'Vela', 'Wren', 'Xara', 'Yara', 'Zorin' +]; + +const BYNAMES = [ + 'Amber', 'Arbor', 'Ash', 'Birch', 'Bloom', 'Bramble', 'Bright', 'Brook', + 'Cedar', 'Cinder', 'Cloud', 'Clover', 'Coast', 'Crest', 'Dawn', 'Drift', + 'Dusk', 'Echo', 'Ember', 'Falcon', 'Fern', 'Field', 'Flame', 'Frost', + 'Gale', 'Glimmer', 'Grove', 'Harbor', 'Haven', 'Hearth', 'Hill', 'Ivy', + 'Juniper', 'Lake', 'Lantern', 'Lark', 'Light', 'Linden', 'Maple', 'Marsh', + 'Meadow', 'Mist', 'Moon', 'Moss', 'Night', 'North', 'Oak', 'Onyx', 'Pearl', + 'Quartz', 'Rain', 'Raven', 'Reed', 'Ridge', 'River', 'Rose', 'Rowan', + 'Rune', 'Saffron', 'Sage', 'Sand', 'Shore', 'Silver', 'Sky', 'Snow', 'Sol', + 'Sparrow', 'Spring', 'Star', 'Stone', 'Storm', 'Summer', 'Thorn', 'Tide', + 'Umber', 'Vale', 'Velvet', 'Vesper', 'Wave', 'West', 'Wild', 'Willow', + 'Wind', 'Winter', 'Wisp', 'Wolf', 'Wood' +]; + +const NAME_SPACE = GIVEN_NAMES.length * BYNAMES.length; + +function normalizedIndex(value) { + const parsed = Math.trunc(Number(value) || 0); + // 7919 is coprime with the 5,481 available pairs. This keeps the mapping + // one-to-one while spreading adjacent population slots across surnames. + return Number((BigInt(Math.abs(parsed)) * 7919n) % BigInt(NAME_SPACE)); +} + +function nameFor(index) { + const slot = normalizedIndex(index); + const given = GIVEN_NAMES[slot % GIVEN_NAMES.length]; + const byname = BYNAMES[Math.floor(slot / GIVEN_NAMES.length)]; + return `${given}${byname}`; +} + +module.exports = { nameFor }; diff --git a/src/GameServer/Bot/Population/GeneratedColdSeeder.js b/src/GameServer/Bot/Population/GeneratedColdSeeder.js index ffcab3bc..ea85a0b2 100644 --- a/src/GameServer/Bot/Population/GeneratedColdSeeder.js +++ b/src/GameServer/Bot/Population/GeneratedColdSeeder.js @@ -9,6 +9,10 @@ const GearSkillHints = invoke('GameServer/Bot/AI/GearSkillHints'); const ShotStock = invoke('GameServer/Inventory/ShotStock'); const BotClassProgression = invoke('GameServer/Bot/BotClassProgression'); const CraftShopService = invoke('GameServer/Bot/Economy/CraftShopService'); +const SeedPlanner = invoke('GameServer/Bot/Population/PopulationSeedPlanner'); +const BotNameGenerator = invoke('GameServer/Bot/Population/BotNameGenerator'); + +const NAME_GENERATOR_VERSION = 2; const CLASS_POOL = [ { race: 0, classId: 0, sex: 0, role: 'dps' }, @@ -22,16 +26,18 @@ const CLASS_POOL = [ { race: 4, classId: 53, sex: 0, role: 'dps' } ]; +const STARTER_REGION_RACES = Object.freeze({ + human: 0, + elf: 1, + dark_elf: 2, + orc: 3, + dwarf: 4 +}); + const CRAFT_SERVICE_PROFILE = { race: 4, classId: 57, sex: 0, role: 'crafter', serviceCrafter: true }; const CRAFT_SERVICE_COUNT = CraftShopService.CraftStations.length; const CRAFT_SERVICE_INDEX_BASE = 10000; -const NAME_STEMS = [ - 'Arin', 'Bren', 'Cail', 'Dorin', 'Elen', 'Faren', 'Garin', 'Halen', - 'Irin', 'Joren', 'Kael', 'Lorin', 'Miren', 'Noren', 'Orin', 'Pavel', - 'Quen', 'Ralen', 'Saren', 'Tarin', 'Ulric', 'Varen', 'Welyn', 'Yorin' -]; - function pick(index, list) { return list[index % list.length]; } @@ -50,14 +56,24 @@ function expForLevel(level) { return Number(table[Math.max(0, Number(level || 1) - 1)] || 0); } -function baseForIndex(index) { - return pick(index, CLASS_POOL); +function baseForIndex(index, starterRegion = null) { + const race = STARTER_REGION_RACES[starterRegion]; + const pool = Number.isInteger(race) + ? CLASS_POOL.filter((entry) => entry.race === race) + : CLASS_POOL; + return pick(index, pool); } -function profileForIndex(index, base = baseForIndex(index)) { +function profileForIndex(index, base = baseForIndex(index), seedProfile = null) { if (base.serviceCrafter) { return { level: 70, band: 'craft_service' }; } + if (seedProfile?.level) { + return { + level: Math.max(1, Number(seedProfile.level)), + band: seedProfile.band || 'population_wave' + }; + } const roll = index % 20; if (roll < 2) return { level: 2 + (index % 2), band: 'newbie' }; if (roll < 11) return { level: 4 + (index % 5), band: 'low' }; @@ -119,7 +135,37 @@ function usernameFor(index) { } function nameFor(index) { - return `${pick(index, NAME_STEMS)}${String(index).padStart(3, '0')}`.slice(0, 35); + return BotNameGenerator.nameFor(index); +} + +function uniqueNameFor(index, attempt = 0, characterId = null) { + // A collision advances to the next readable pair instead of appending a + // technical suffix to the visible character name. + const candidate = nameFor(Math.max(0, Number(index) || 0) + attempt); + return Database.fetchCharacterName(candidate).then((rows) => { + if (!rows[0] || Number(rows[0].id) === Number(characterId)) return candidate; + return uniqueNameFor(index, attempt + 1, characterId); + }); +} + +function migratePopulationNames(states = []) { + const candidates = states.filter((state) => state.accountName?.startsWith('bot_pop_') + && state.stats?.generatedCold + && Number(state.stats?.nameGeneratorVersion || 0) < NAME_GENERATOR_VERSION + && Number.isFinite(Number(state.stats?.generatedIndex))); + return candidates.reduce((chain, state) => chain.then(() => ( + uniqueNameFor(state.stats.generatedIndex, 0, state.characterId).then((name) => { + const nextState = { + ...state, + name, + stats: { ...(state.stats || {}), nameGeneratorVersion: NAME_GENERATOR_VERSION } + }; + const rename = name === state.name + ? Promise.resolve() + : Database.updateCharacterName(state.characterId, name); + return rename.then(() => LifeState.upsertState(nextState, 'generated_name_migration')); + }) + )), Promise.resolve()).then(() => candidates.length); } function awardBaseGear(characterId, classId) { @@ -200,11 +246,11 @@ function ensureAccount(username) { }); } -function ensureCharacter(username, index, base = baseForIndex(index)) { +function ensureCharacter(username, index, base = baseForIndex(index), seedProfile = null) { return Database.fetchCharacters(username).then((characters) => { if (characters[0]) { const character = characters[0]; - const profile = profileForIndex(index, base); + const profile = profileForIndex(index, base, seedProfile); const level = base.serviceCrafter ? profile.level : Number(character.level || profile.level); const adena = Number(character.adena || Math.round(level * 85)); const classId = base.serviceCrafter ? base.classId : character.classId; @@ -227,33 +273,35 @@ function ensureCharacter(username, index, base = baseForIndex(index)) { } const template = classInfo(base.classId); - const levelProfile = profileForIndex(index, base); + const levelProfile = profileForIndex(index, base, seedProfile); const level = levelProfile.level; - const spot = targetSpot(level, index, base); + const spot = seedProfile?.spot || targetSpot(level, index, base); const loc = randomNear(spot?.center || { locX: 0, locY: 0, locZ: 0 }, index); const vitals = vitalsFor(template, level); - const charData = { - name: nameFor(index), - race: base.race, - classId: base.classId, - ...appearance(index, base.sex), - ...vitals, - ...loc - }; - - return Database.createCharacter(username, charData).then((packet) => { - const character = { - id: Number(packet.insertId), - username, - ...charData, - level, - exp: expForLevel(level), - sp: Math.round(level * level * 3), - adena: Math.round(level * 85) + return uniqueNameFor(index).then((name) => { + const charData = { + name, + race: base.race, + classId: base.classId, + ...appearance(index, base.sex), + ...vitals, + ...loc }; - return Database.updateCharacterExperience(character.id, level, character.exp, character.sp) - .then(() => ensureBaseLoadout(character.id, base.classId, character.adena, level)) - .then(() => ({ character, created: true, base, spot, levelProfile, vitals, loc })); + + return Database.createCharacter(username, charData).then((packet) => { + const character = { + id: Number(packet.insertId), + username, + ...charData, + level, + exp: expForLevel(level), + sp: Math.round(level * level * 3), + adena: Math.round(level * 85) + }; + return Database.updateCharacterExperience(character.id, level, character.exp, character.sp) + .then(() => ensureBaseLoadout(character.id, base.classId, character.adena, level)) + .then(() => ({ character, created: true, base, spot, levelProfile, vitals, loc })); + }); }); }); } @@ -261,7 +309,8 @@ function ensureCharacter(username, index, base = baseForIndex(index)) { function stateFor(character, index, seedMeta = {}) { const base = seedMeta.base || baseForIndex(index); const classId = Number(character.classId || base.classId); - const level = Number(character.level || profileForIndex(index, base).level); + const levelProfile = seedMeta.levelProfile || profileForIndex(index, base, seedMeta.seedProfile); + const level = Number(character.level || levelProfile.level); const spot = base.serviceCrafter ? null : seedMeta.spot || targetSpot(level, index, { ...base, classId }); const loc = seedMeta.loc || randomNear(spot?.center || { locX: character.locX, @@ -304,7 +353,10 @@ function stateFor(character, index, seedMeta = {}) { classProgressionClassId: classId, generatedCold: true, generatedIndex: index, - levelBand: profileForIndex(index, base).band + nameGeneratorVersion: NAME_GENERATOR_VERSION, + levelBand: levelProfile.band, + populationWave: seedMeta.populationWave || null, + starterRegion: seedMeta.starterRegion || null }, inventory: { 57: { @@ -365,9 +417,14 @@ function sameRecipeEntries(left = [], right = []) { const GeneratedColdSeeder = { running: false, + // Millisecond-based slots keep generated accounts distinct across a + // restart; the base-36 form still fits the sixteen-character account name. + nextPopulationIndex: Date.now(), awardProfileSkills, craftServiceSeedState, + baseForIndex, + nameFor, ensureCraftServices() { let created = 0; @@ -418,62 +475,76 @@ const GeneratedColdSeeder = { return chain.then(() => ({ created, seeded })); }, - seedToTarget(target = Config.generatedColdTarget) { - const desired = Math.max(0, Number(target || 0)); - if (!desired || this.running) return Promise.resolve({ created: 0, desired, total: 0 }); + seedPopulation() { + const limit = Math.max(0, Number(Config.maxPlayingPopulation || 0)); + if (!limit || this.running) return Promise.resolve({ created: 0, seeded: 0, total: 0, limit }); this.running = true; - return LifeState.levelHistogram().then((histogram) => { - const total = Number(histogram.total || 0); - const needed = Math.max(0, desired - total); - const batch = Math.min(needed, Config.generatedColdBatchSize); - if (batch <= 0) { - return this.ensureCraftServices().then((services) => ({ - created: services.created, - seeded: services.seeded, - desired, - total: total + services.seeded - })); - } - + return Promise.resolve().then(() => migratePopulationNames(LifeState.allStates(limit + 100))).then(() => { + const plan = SeedPlanner.plan( + SpotProfiles.ensure(), + LifeState.allStates(limit + 100), + limit, + Config.starterBotsPerRace + ); + const batch = plan.missing.slice(0, SeedPlanner.seedBatchSize(plan, Config.generatedColdBatchSize)); let created = 0; let seeded = 0; let chain = Promise.resolve(); - const startIndex = total + 1; - - for (let offset = 0; offset < batch; offset++) { - const index = startIndex + offset; - chain = chain.then(() => { - const username = usernameFor(index); - return ensureAccount(username) - .then(() => ensureCharacter(username, index)) - .then((result) => { - const state = stateFor(result.character, index, result); - const craftShop = state.stats?.craftShop; - const recipesReady = craftShop - ? CraftShopService.ensureRecipes(state.characterId, craftShop) - : Promise.resolve(null); - return recipesReady.then(() => LifeState.upsertState(state, 'generated_seed')).then((saved) => { - if (saved && result.created) created += 1; - if (saved) seeded += 1; - return saved; - }); + + batch.forEach((spot) => { + const index = this.nextPopulationIndex++; + const username = `bot_pop_${index.toString(36)}`.slice(0, 16); + const base = baseForIndex(index, spot.starterRegion); + const seedProfile = { + spot, + level: Math.max(1, Number(spot.minLevel || 1)), + band: `wave_${plan.wave}` + }; + chain = chain.then(() => ensureAccount(username) + .then(() => ensureCharacter(username, index, base, seedProfile)) + .then((result) => { + const state = stateFor(result.character, index, { + ...result, + seedProfile, + populationWave: plan.wave, + starterRegion: spot.starterRegion, + spot, + loc: result.loc || randomNear(spot.center, index) }); - }); - } + return LifeState.upsertState(state, 'population_wave_seed').then((saved) => { + if (saved && result.created) created += 1; + if (saved) seeded += 1; + return saved; + }); + })); + }); return chain.then(() => this.ensureCraftServices()).then((services) => ({ created: created + services.created, - seeded: seeded + services.seeded, - desired, - total: total + seeded + services.seeded + seeded, + // `population` includes generated merchants, unlike the + // hunting-only count used to pace and backfill each wave. + total: plan.population + seeded, + limit, + targetPopulation: plan.targetPopulation, + averageLevel: plan.averageLevel, + wave: plan.wave, + eligible: plan.eligible.length, + remaining: Math.max(0, plan.missing.length - seeded) })); }).catch((err) => { utils.infoWarn('BotSeed', 'generated cold seed failed: %s', err.message); - return { created: 0, seeded: 0, desired, total: 0, error: err.message }; + return { created: 0, seeded: 0, total: 0, limit, error: err.message }; }).finally(() => { this.running = false; }); + }, + + // Compatibility for callers outside PopulationService. The old target was + // a one-shot count; the server now always follows the staged population cap. + seedToTarget() { + return this.seedPopulation(); } }; diff --git a/src/GameServer/Bot/Population/PopulationConfig.js b/src/GameServer/Bot/Population/PopulationConfig.js index 04adff3f..7b4d0448 100644 --- a/src/GameServer/Bot/Population/PopulationConfig.js +++ b/src/GameServer/Bot/Population/PopulationConfig.js @@ -19,12 +19,15 @@ const DEFAULTS = { partyFormationIntervalMs: 45000, phasePolicyIntervalMs: 10000, directorIntervalMs: 30000, - generatedColdTarget: 100, - generatedColdBatchSize: 25, + // Start with every level-one hunting sector. Waves open every five levels + // at x1-x10, or every ten levels at x50 and above. This is a cap for + // generated adventurers only; shop services are not part of it. + maxPlayingPopulation: 1700, + starterBotsPerRace: 30, + generatedColdBatchSize: 50, generatedColdSeedDelayMs: 45000, - // The persistent world can contain substantially more than the initial - // generated target after a restart. Twenty-five sequential resolves still - // fit well inside the five-second scheduler interval. + // The persistent world is resolved in bounded batches so population + // expansion never becomes a database spike after a restart. maxResolvesPerTick: 25, maxPartyResolvesPerTick: 3, maxMarketGoalReconcilesPerTick: 8, @@ -77,7 +80,8 @@ const ENV_KEYS = { backgroundPartyEnabled: 'BOT_BACKGROUND_PARTY_ENABLED', phasePolicyEnabled: 'BOT_POPULATION_PHASE_POLICY_ENABLED', directorEnabled: 'BOT_POPULATION_DIRECTOR_ENABLED', - generatedColdTarget: 'BOT_POPULATION_TARGET', + maxPlayingPopulation: 'BOT_POPULATION_MAX_PLAYING', + starterBotsPerRace: 'BOT_POPULATION_STARTER_BOTS_PER_RACE', generatedColdBatchSize: 'BOT_POPULATION_BATCH_SIZE', generatedColdSeedDelayMs: 'BOT_POPULATION_SEED_DELAY_MS', cooldownGraceMs: 'BOT_COOLDOWN_GRACE_MS', diff --git a/src/GameServer/Bot/Population/PopulationSeedPlanner.js b/src/GameServer/Bot/Population/PopulationSeedPlanner.js new file mode 100644 index 00000000..789cbc6a --- /dev/null +++ b/src/GameServer/Bot/Population/PopulationSeedPlanner.js @@ -0,0 +1,175 @@ +const ProgressionRates = invoke('GameServer/ProgressionRates'); + +function number(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +const STARTER_REGIONS = [ + { id: 'human', center: { locX: -80000, locY: 250000 }, radius: 30000 }, + { id: 'elf', center: { locX: 46000, locY: 40000 }, radius: 30000 }, + { id: 'dark_elf', center: { locX: 27000, locY: 11000 }, radius: 30000 }, + { id: 'orc', center: { locX: -57000, locY: -113000 }, radius: 30000 }, + { id: 'dwarf', center: { locX: 108000, locY: -175000 }, radius: 30000 } +]; + +function regionalTargetCounts(targetPopulation) { + const base = Math.floor(Math.max(0, number(targetPopulation)) / STARTER_REGIONS.length); + const remainder = Math.max(0, number(targetPopulation)) % STARTER_REGIONS.length; + return STARTER_REGIONS.reduce((counts, region, index) => ({ + ...counts, + [region.id]: base + (index < remainder ? 1 : 0) + }), {}); +} + +function distanceSquared(left = {}, right = {}) { + const dx = number(left.locX) - number(right.locX); + const dy = number(left.locY) - number(right.locY); + return (dx * dx) + (dy * dy); +} + +function isPlaying(state = {}) { + return !['merchant', 'crafting'].includes(state.activity); +} + +function snapshot(states = []) { + const playing = states.filter(isPlaying); + // Wave pacing deliberately follows hunting bots: a bot that opens a + // private store has left its farming spot and should be backfilled there. + // The hard cap, however, covers every generated character, including the + // ones temporarily selling in town. + const population = states.filter((state) => Number(state.stats?.populationWave || 0) > 0); + const playingPopulation = playing.filter((state) => Number(state.stats?.populationWave || 0) > 0); + const populationByStarterRegion = playingPopulation.reduce((counts, state) => { + const region = String(state.stats?.starterRegion || ''); + if (STARTER_REGIONS.some((entry) => entry.id === region)) { + counts[region] = number(counts[region]) + 1; + } + return counts; + }, {}); + const latestWave = population.reduce((highest, state) => Math.max(highest, Number(state.stats?.populationWave || 0)), 0); + const latestCohort = playingPopulation.filter((state) => Number(state.stats?.populationWave || 0) === latestWave); + const levelTotal = playing.reduce((sum, state) => sum + Math.max(1, number(state.level, 1)), 0); + const spots = playing.reduce((counts, state) => { + if (!state.spotId) return counts; + counts[state.spotId] = number(counts[state.spotId]) + 1; + return counts; + }, {}); + + return { + playing: playing.length, + playingPopulation: playingPopulation.length, + averageLevel: playing.length ? levelTotal / playing.length : 0, + population: population.length, + latestWave, + latestCohortAverageLevel: latestCohort.length + ? latestCohort.reduce((sum, state) => sum + Math.max(1, number(state.level, 1)), 0) / latestCohort.length + : 0, + populationByStarterRegion, + hasRegionalPopulation: Object.keys(populationByStarterRegion).length > 0, + spots, + hasPopulationSeed: population.length > 0 + }; +} + +function waveLevelThreshold(multiplier = ProgressionRates.profile().multiplier) { + return number(multiplier, 1) >= 50 ? 10 : 5; +} + +function nextWave(snapshot = {}, levelThreshold = waveLevelThreshold()) { + if (!snapshot.hasPopulationSeed) return 1; + // Advance exactly one cohort at a time. Legacy/static bots must neither + // suppress the first wave nor jump several waves on server restart. + return snapshot.latestCohortAverageLevel >= levelThreshold + ? snapshot.latestWave + 1 + : snapshot.latestWave; +} + +function eligibleSpots(profiles = [], maxMobLevel = 1) { + return profiles + .filter((spot) => number(spot.minLevel, 0) >= 1 && number(spot.minLevel, 0) <= maxMobLevel) + .sort((left, right) => number(left.minLevel, 0) - number(right.minLevel, 0) + || number(left.avgLevel, 0) - number(right.avgLevel, 0) + || String(left.id).localeCompare(String(right.id))); +} + +function starterSlots(spots = [], botsPerRace = 30, waves = 1) { + const starters = spots.filter((spot) => number(spot.minLevel, 0) === 1); + const slotsPerRace = Math.max(0, number(botsPerRace, 30)) * Math.max(1, number(waves, 1)); + const fallback = starters.length ? starters : spots; + + return Array.from({ length: slotsPerRace }).flatMap((_, slot) => STARTER_REGIONS.flatMap((region) => { + const candidates = fallback + .map((spot) => ({ spot, distance: distanceSquared(spot.center, region.center) })) + .filter((candidate) => candidate.distance <= region.radius * region.radius) + .sort((left, right) => left.distance - right.distance || String(left.spot.id).localeCompare(String(right.spot.id))); + const selected = candidates[slot % Math.max(1, candidates.length)]?.spot; + return selected ? [{ ...selected, starterRegion: region.id }] : []; + })); +} + +function seedBatchSize(plan = {}, configuredBatch = 1) { + const normalBatch = Math.max(1, number(configuredBatch, 1)); + // A cohort must land as one wave. Splitting it lets the new level-one bots + // lower the mean before the remaining members are created, which cancels + // the wave on the following check. + return Math.max(normalBatch, Number(plan.newBotsNeeded || plan.missing?.length || 0)); +} + +function plan(profiles = [], states = [], maxPopulation = 1700, botsPerRace = 30, options = {}) { + const current = snapshot(states); + const limit = Math.max(0, number(maxPopulation)); + const levelThreshold = waveLevelThreshold(options.progressionMultiplier); + const wave = nextWave(current, levelThreshold); + const eligible = eligibleSpots(profiles, 1); + const available = Math.max(0, limit - current.population); + const plannedSlots = starterSlots(eligible, botsPerRace, wave); + const targetPopulation = Math.min(limit, STARTER_REGIONS.length * Math.max(0, number(botsPerRace, 30)) * wave); + // Static merchant and craft services are outside the generated-population + // cap, and do not replace the requested 30-per-race starter cohort. + const regionalTargets = regionalTargetCounts(targetPopulation); + const regionalMissing = STARTER_REGIONS.reduce((counts, region) => ({ + ...counts, + [region.id]: Math.max(0, number(regionalTargets[region.id]) - number(current.populationByStarterRegion[region.id])) + }), {}); + const newBotsNeeded = current.hasRegionalPopulation + ? Object.values(regionalMissing).reduce((sum, count) => sum + number(count), 0) + : Math.max(0, targetPopulation - current.population); + const occupied = { ...current.spots }; + const missing = plannedSlots + .filter((spot) => { + if (current.hasRegionalPopulation && number(regionalMissing[spot.starterRegion]) <= 0) return false; + const count = number(occupied[spot.id]); + occupied[spot.id] = count + 1; + const available = count < plannedSlots.filter((candidate) => candidate.id === spot.id).length; + if (available && current.hasRegionalPopulation) regionalMissing[spot.starterRegion] -= 1; + return available; + }) + .slice(0, Math.min(available, newBotsNeeded)); + + return { + ...current, + maxPopulation: limit, + levelThreshold, + wave, + targetPopulation, + newBotsNeeded, + regionalTargets, + regionalMissing, + eligible, + plannedSlots, + missing + }; +} + +module.exports = { + isPlaying, + snapshot, + STARTER_REGIONS, + waveLevelThreshold, + nextWave, + eligibleSpots, + starterSlots, + seedBatchSize, + plan +}; diff --git a/src/GameServer/Bot/Population/PopulationService.js b/src/GameServer/Bot/Population/PopulationService.js index 3a7f2241..5db494ed 100644 --- a/src/GameServer/Bot/Population/PopulationService.js +++ b/src/GameServer/Bot/Population/PopulationService.js @@ -26,7 +26,8 @@ const CraftTelemetry = invoke('GameServer/Bot/Economy/CraftTelemetry'); function groupBySpot(states) { const grouped = new Map(); states.forEach((state) => { - const planSpotId = state.stats?.equipmentPlan?.status === 'active' + const planSpotId = !SpotProfiles.isProtectedStarterCohort(state) + && state.stats?.equipmentPlan?.status === 'active' ? state.stats.equipmentPlan.next?.spotId : null; const spotId = planSpotId || state.spotId; @@ -44,6 +45,23 @@ function groupBySpot(states) { }); } +function partySpotForLeader(leader) { + const preserveStarterSpot = SpotProfiles.isProtectedStarterCohort(leader); + return SpotProfiles.findForState({ + ...leader, + spotId: preserveStarterSpot ? leader.spotId : null, + party: { + ...(leader.party || {}), + partyId: 'forming', + role: PartyComposition.roleForState(leader) + }, + stats: { + ...(leader.stats || {}), + routeMode: 'party' + } + }, { mode: 'party', role: PartyComposition.roleForState(leader) }) || SpotProfiles.findById(leader.spotId); +} + function canTakePartyMarketBreak(party, members, member, timestamp = Date.now()) { if (timestamp - Number(party.stats?.formedAt || party.startedAt || timestamp) < Config.partyMarketBreakMinSessionMs) return false; if (Number(party.stats?.fightsResolved || 0) < Config.partyMarketBreakMinFights) return false; @@ -100,6 +118,8 @@ function nearbyHotCount(sessions, player) { } const PopulationService = { + groupBySpot, + partySpotForLeader, initialized: false, started: false, summaryTimer: null, @@ -257,22 +277,28 @@ const PopulationService = { }, scheduleGeneratedColdSeed(delayMs = Config.generatedColdSeedDelayMs) { - if (Config.enabled === false || Config.generatedColdTarget <= 0 || this.seedTimer) return; + if (Config.enabled === false || Config.maxPlayingPopulation <= 0 || this.seedTimer) return; this.seedTimer = setTimeout(() => { this.seedTimer = null; - GeneratedColdSeeder.seedToTarget(Config.generatedColdTarget).then((result) => { + GeneratedColdSeeder.seedPopulation().then((result) => { if (result.seeded > 0) { console.info( - 'BotPopulation :: generated cold seed seeded=%d created=%d total=%d target=%d', + 'BotPopulation :: population wave=%d seeded=%d created=%d total=%d/%d target=%d avgLevel=%s starterSpots=%d', + result.wave || 1, result.seeded, result.created, result.total, - result.desired + result.limit, + result.targetPopulation || result.limit, + Number(result.averageLevel || 0).toFixed(1), + result.eligible || 0 ); } - if (this.started && result.desired > 0 && result.total < result.desired && !result.error) { + // Keep checking the next wave: once the mean bot level crosses + // another five-level threshold, newly opened grounds are filled. + if (this.started && result.limit > 0 && result.total < result.limit && !result.error) { this.scheduleGeneratedColdSeed(Config.generatedColdSeedDelayMs); } }); @@ -546,19 +572,7 @@ const PopulationService = { if (members.length < Config.partyMinSize) return null; const leader = PartyComposition.chooseLeader(members); - const partySpot = SpotProfiles.findForState({ - ...leader, - spotId: null, - party: { - ...(leader.party || {}), - partyId: 'forming', - role: PartyComposition.roleForState(leader) - }, - stats: { - ...(leader.stats || {}), - routeMode: 'party' - } - }, { mode: 'party', role: PartyComposition.roleForState(leader) }) || SpotProfiles.findById(leader.spotId); + const partySpot = partySpotForLeader(leader); const partyId = `bgp_${Date.now().toString(36)}_${leader.characterId}`; const nextResolveAt = Date.now() + 45000 + Math.round(Math.random() * 90000); const party = { diff --git a/src/GameServer/Bot/Population/SpotProfiles.js b/src/GameServer/Bot/Population/SpotProfiles.js index b21846eb..ab409427 100644 --- a/src/GameServer/Bot/Population/SpotProfiles.js +++ b/src/GameServer/Bot/Population/SpotProfiles.js @@ -42,9 +42,17 @@ function profileFromSpot(spot) { }; } +function isProtectedStarterCohort(state) { + return Number(state?.level || 1) < 5 + && Number(state?.stats?.populationWave || 0) > 0 + && !!state?.stats?.starterRegion; +} + const SpotProfiles = { cache: null, + isProtectedStarterCohort, + reset() { this.cache = null; }, @@ -61,6 +69,20 @@ const SpotProfiles = { findForState(state, options = {}) { const acquisitionPlan = state?.stats?.equipmentPlan; + const protectedStarterCohort = isProtectedStarterCohort(state); + const keepCurrentSpot = state?.spotId && (!acquisitionPlan || protectedStarterCohort); + + // Fresh racial cohorts stay at their physical level-one spot until + // they advance. A gear plan otherwise remains the normal route choice + // for established bots. + if (keepCurrentSpot) { + const existing = this.findById(state.spotId); + if (existing) { + const match = LevelingRoutes.scoreSpot(existing, state, options); + return LevelingRoutes.decorateSpot(existing, match); + } + } + if (acquisitionPlan?.status === 'active') { const planned = this.ensure() .map((spot) => ({ spot, score: GearAcquisitionPlanner.scoreSpot(spot, acquisitionPlan) })) @@ -68,6 +90,7 @@ const SpotProfiles = { .sort((a, b) => b.score - a.score)[0]; if (planned) return planned.spot; } + if (state?.spotId) { const existing = this.findById(state.spotId); if (existing) { diff --git a/src/GameServer/Quest/QuestService.js b/src/GameServer/Quest/QuestService.js index bb1a48a7..f7459c91 100644 --- a/src/GameServer/Quest/QuestService.js +++ b/src/GameServer/Quest/QuestService.js @@ -145,6 +145,14 @@ function handlesNpc(npc) { return quests.some((quest) => quest.npcs.includes(npcId)); } +// Gatekeepers can offer both travel and quest progress. The caller needs to +// decide whether to expose the quest branch without opening it (talking to a +// quest NPC may itself advance a quest), so keep this check read-only. +async function hasTalk(session, npc) { + await ensureLoaded(session); + return Boolean(questForNpc(npc, session)); +} + function render(session, npc, html) { session.dataSendToMe(ServerResponse.npcHtml(npc.fetchId(), html)); session.dataSendToMe(ServerResponse.actionFailed()); @@ -334,6 +342,7 @@ module.exports = { onEvent, onKill, handlesNpc, + hasTalk, mutate, stateFor, active, diff --git a/src/GameServer/Session.js b/src/GameServer/Session.js index b14a4a9c..bd02c875 100644 --- a/src/GameServer/Session.js +++ b/src/GameServer/Session.js @@ -1,6 +1,7 @@ const Opcodes = invoke('GameServer/Network/Opcodes'); const Actor = invoke('GameServer/Actor/Actor'); const World = invoke('GameServer/World/World'); +const NpcVisibility = invoke('GameServer/World/NpcVisibility'); const TRACE_LIMIT = 40; @@ -194,6 +195,7 @@ class Session { } dataSendToMe(data) { + NpcVisibility.trackNpcPacket(this, data); this.recordOutboundPacket(data); const packet = this.packData(data); this.socket.write(packet); @@ -202,6 +204,7 @@ class Session { dataSendToOthers(data, creature) { const packet = this.packData(data); World.fetchVisibleUsers(this, creature).forEach((user) => { + NpcVisibility.trackNpcPacket(user, data); if (user.recordOutboundPacket) { user.recordOutboundPacket(data); } diff --git a/src/GameServer/World/C4GatekeeperTeleports.js b/src/GameServer/World/C4GatekeeperTeleports.js index 2885bd91..0a7531f2 100644 --- a/src/GameServer/World/C4GatekeeperTeleports.js +++ b/src/GameServer/World/C4GatekeeperTeleports.js @@ -47,4 +47,12 @@ function html(npcId) { return `Region where teleporting is possible
${links.join('')}
`; } -module.exports = { destination, html, lists: LISTS }; +function menu(npcId, hasQuest) { + if (!LISTS[npcId]) return null; + const quest = hasQuest + ? 'Quest' + : ''; + return `How can I help you?

Teleport${quest}`; +} + +module.exports = { destination, html, menu, lists: LISTS }; diff --git a/src/GameServer/World/C4LateTownGatekeepers.js b/src/GameServer/World/C4LateTownGatekeepers.js index 92ad89bb..10debd4b 100644 --- a/src/GameServer/World/C4LateTownGatekeepers.js +++ b/src/GameServer/World/C4LateTownGatekeepers.js @@ -1,7 +1,7 @@ -function gatekeeper(selfId, name) { +function townNpc(selfId, name, title, kind = 'Teleporter') { return { selfId, - template: { kind: 'Teleporter', name, title: 'Gatekeeper', level: 70, hostile: false }, + template: { kind, name, title, level: 70, hostile: false }, base: { str: 40, dex: 30, con: 43, int: 21, wit: 20, men: 10 }, stats: { pAtk: 688.863725587608, pAtkRnd: 30, pDef: 295.91597408024, mAtk: 470.404627426724, mDef: 216.538467292763, accur: 4.75, atkSpd: 253, castSpd: 333, atkRadius: 40 }, speed: { walk: 80, run: 120 }, @@ -13,13 +13,20 @@ function gatekeeper(selfId, name) { }; } -const npcs = [gatekeeper(8275, 'Tatiana'), gatekeeper(8320, 'Ilyana')]; +const npcs = [ + townNpc(8275, 'Tatiana', 'Gatekeeper'), + townNpc(8320, 'Ilyana', 'Gatekeeper'), + townNpc(8256, 'Leon', 'Trader', 'Merchant'), + townNpc(8300, 'Drumond', 'Trader', 'Merchant') +]; const spawns = [{ selfId: 'c4_late_town_gatekeepers', bounds: [{ locX: 43700, locY: -55300, minZ: -2800, maxZ: -700 }], spawns: [ { selfId: 8275, name: 'Tatiana', coords: [{ locX: 147966, locY: -55228, locZ: -2728, head: 48000 }], total: 1, respawn: 60, bias: 0 }, - { selfId: 8320, name: 'Ilyana', coords: [{ locX: 43824, locY: -47664, locZ: -792, head: 50000 }], total: 1, respawn: 60, bias: 0 } + { selfId: 8320, name: 'Ilyana', coords: [{ locX: 43824, locY: -47664, locZ: -792, head: 50000 }], total: 1, respawn: 60, bias: 0 }, + { selfId: 8256, name: 'Leon', coords: [{ locX: 148832, locY: -58960, locZ: -2968, head: 22000 }], total: 1, respawn: 60, bias: 0 }, + { selfId: 8300, name: 'Drumond', coords: [{ locX: 44692, locY: -47312, locZ: -792, head: 0 }], total: 1, respawn: 60, bias: 0 } ] }]; diff --git a/src/GameServer/World/Generics/NpcBypasses/GatekeeperQuest.js b/src/GameServer/World/Generics/NpcBypasses/GatekeeperQuest.js new file mode 100644 index 00000000..51d734fb --- /dev/null +++ b/src/GameServer/World/Generics/NpcBypasses/GatekeeperQuest.js @@ -0,0 +1,20 @@ +const ServerResponse = invoke('GameServer/Network/Response'); + +module.exports = function gatekeeperQuest(session) { + const active = session.activeNpcTalk; + if (!active) return; + + const QuestService = invoke('GameServer/Quest/QuestService'); + const npc = { + fetchSelfId: () => active.selfId, + fetchId: () => active.objectId + }; + QuestService.onTalk(session, npc).then((handled) => { + if (handled) return; + session.dataSendToMe(ServerResponse.npcHtml(active.objectId, 'There are no quests available.')); + session.dataSendToMe(ServerResponse.actionFailed()); + }).catch((error) => { + utils.infoWarn('Quest', 'failed to open gatekeeper quest dialog: %s', error.message); + session.dataSendToMe(ServerResponse.actionFailed()); + }); +}; diff --git a/src/GameServer/World/Generics/NpcBypasses/GatekeeperTeleport.js b/src/GameServer/World/Generics/NpcBypasses/GatekeeperTeleport.js index a4795b03..70de097e 100644 --- a/src/GameServer/World/Generics/NpcBypasses/GatekeeperTeleport.js +++ b/src/GameServer/World/Generics/NpcBypasses/GatekeeperTeleport.js @@ -2,6 +2,14 @@ const ServerResponse = invoke('GameServer/Network/Response'); const C4GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports'); module.exports = function gatekeeperTeleport(session, parts) { + if (!parts?.[1]) { + const html = C4GatekeeperTeleports.html(session?.activeNpcTalk?.selfId); + if (html) { + session.dataSendToMe(ServerResponse.npcHtml(session.activeNpcTalk.objectId, html)); + session.dataSendToMe(ServerResponse.actionFailed()); + } + return; + } const actor = session?.actor; const destination = C4GatekeeperTeleports.destination(session?.activeNpcTalk?.selfId, Number(parts?.[1])); if (!actor || !destination) return session?.dataSendToMe?.(ServerResponse.actionFailed()); diff --git a/src/GameServer/World/Generics/NpcShopBuyLists.js b/src/GameServer/World/Generics/NpcShopBuyLists.js index d4d304ef..97835e7c 100644 --- a/src/GameServer/World/Generics/NpcShopBuyLists.js +++ b/src/GameServer/World/Generics/NpcShopBuyLists.js @@ -128,6 +128,12 @@ const ADEN_GROCER_BASE = [ [5195, 400] ]; +const D_GROCER_BASE = ADVANCED_GROCER_BASE; +const C_GROCER_BASE = ADVANCED_GROCER_BASE; +const B_GROCER_BASE = ADVANCED_GROCER_BASE; +const A_GROCER_BASE = ADEN_GROCER_BASE; +const S_GROCER_BASE = ADVANCED_GROCER_BASE; + const CEMA_GROCER_BASE = [ [1835, 7], [2509, 15], @@ -820,9 +826,9 @@ const LISTS = { { selfId: 4492, price: 14400 } ], - gludioGrocer: withTax(ADVANCED_GROCER_BASE, 1.2), - floranGrocer: withTax(ADVANCED_GROCER_BASE, 1.5), - hunterGrocer: withTax(ADVANCED_GROCER_BASE, 1.3), + gludioGrocer: withTax(D_GROCER_BASE, 1.2), + floranGrocer: withTax(D_GROCER_BASE, 1.5), + hunterGrocer: withTax(B_GROCER_BASE, 1.3), dwarvenGrocer: withTax(DWARVEN_GROCER_BASE, 1.15), dwarvenArmor: withTax(DWARVEN_ARMOR_BASE, 1.15), hunterWeapons: withTax(STANDARD_PHYSICAL_WEAPON_BASE, 1.3), @@ -839,20 +845,20 @@ const LISTS = { giranBodyArmor: withTax(GIRAN_BODY_ARMOR_BASE, 1.1), giranRobeAndAccessoryArmor: withTax(GIRAN_ROBE_AND_ACCESSORY_ARMOR_BASE, 1.1), giranJewelry: withTax(GIRAN_JEWELRY_BASE, 1.1), - giranGrocer: withTax(ADVANCED_GROCER_BASE, 1.1), + giranGrocer: withTax(C_GROCER_BASE, 1.1), giranDyes: withTax(GIRAN_DYE_BASE, 1.1), giranMagicBooks: withTax(GIRAN_MAGIC_BOOK_BASE, 1.1), orenWeapons: withTax(STANDARD_PHYSICAL_WEAPON_BASE, 1.15), orenMysticWeapons: withTax(MYSTIC_WEAPON_BASE, 1.15), orenArmor: withTax(GLUDIO_ARMOR_BASE, 1.15), - orenGrocer: withTax(ADVANCED_GROCER_BASE, 1.15), + orenGrocer: withTax(B_GROCER_BASE, 1.15), orenDyes: withTax(BASIC_DYE_BASE, 1.15), orenJewelry: withTax(ADVANCED_JEWELRY_BASE, 1.15), orenMagicBooks: withTax(MAGIC_BOOK_BASE, 1.15), adenWeapons: withTax(STANDARD_PHYSICAL_WEAPON_BASE, 1.2), adenMysticWeapons: withTax(MYSTIC_WEAPON_BASE, 1.2), adenArmor: withTax(GLUDIO_ARMOR_BASE, 1.2), - adenGrocer: withTax(ADEN_GROCER_BASE, 1.2), + adenGrocer: withTax(A_GROCER_BASE, 1.2), adenDyes: withTax(BASIC_DYE_BASE, 1.2), adenJewelry: withTax(ADVANCED_JEWELRY_BASE, 1.2), adenMagicBooks: withTax(MAGIC_BOOK_BASE, 1.2), @@ -862,6 +868,8 @@ const LISTS = { cemaMysticWeapons: withTax(GIRAN_MYSTIC_WEAPON_BASE, 1.2), cemaRobeAndAccessoryArmor: withTax(GIRAN_ROBE_AND_ACCESSORY_ARMOR_BASE, 1.2), cemaGrocer: withTax(CEMA_GROCER_BASE, 1.2), + goddardGrocer: withTax(S_GROCER_BASE, 1.2), + runeGrocer: withTax(S_GROCER_BASE, 1.2), talkingIslandGrocer: [ { selfId: 1835, price: 8 }, @@ -892,7 +900,7 @@ const LISTS = { { selfId: 4628, price: 575 } ], - grocery: [1060, 1061, 1831, 1833, 736, 737, 1835, 2509, 3947, 735, 1062, 1863, 17], + grocery: [[1060], [1061], [1831], [1833], [736], [737], [1835], [3947], [735], [1062], [1863], [17]], talkingIslandJewelry: [ { selfId: 118, price: 76 }, @@ -1074,6 +1082,8 @@ const NPC_LISTS = { 7684: ['hunterWeapons', 'hunterMysticWeapons'], 7831: ['petSupplies'], 7834: ['cemaMysticWeapons', 'cemaRobeAndAccessoryArmor', 'cemaGrocer'], + 8256: ['goddardGrocer'], + 8300: ['runeGrocer'], 7253: ['gludinArmor'], 7254: ['gludioGrocer', 'gludinDyes'], diff --git a/src/GameServer/World/Generics/NpcTalk.js b/src/GameServer/World/Generics/NpcTalk.js index 6af9f864..85bf2ca4 100644 --- a/src/GameServer/World/Generics/NpcTalk.js +++ b/src/GameServer/World/Generics/NpcTalk.js @@ -12,6 +12,20 @@ function npcTalk(session, npc) { title }; + const C4GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports'); + if (C4GatekeeperTeleports.html(npc.fetchSelfId())) { + // A gatekeeper can simultaneously be a quest NPC. Do not let quest + // progress replace travel: offer the player both branches first. + const QuestService = invoke('GameServer/Quest/QuestService'); + QuestService.hasTalk(session, npc).then((hasQuest) => { + showGatekeeperTalk(session, npc, hasQuest); + }).catch((error) => { + utils.infoWarn('Quest', 'failed to inspect gatekeeper quests: %s', error.message); + showGatekeeperTalk(session, npc, false); + }); + return; + } + // Quest dialogue has priority over generic NPC HTML. The service loads // persistent state before selecting a quest, so an unrelated NPC keeps its // normal dialog while a quest NPC resumes exactly where the player stopped. @@ -28,6 +42,15 @@ function npcTalk(session, npc) { }); } +function showGatekeeperTalk(session, npc, hasQuest) { + const C4GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports'); + session.dataSendToMe(ServerResponse.npcHtml( + npc.fetchId(), + C4GatekeeperTeleports.menu(npc.fetchSelfId(), hasQuest) + )); + session.dataSendToMe(ServerResponse.actionFailed()); +} + function showDefaultTalk(session, npc) { const path = 'data/Html/'; const filename = path + npc.fetchSelfId() + '.html'; @@ -43,14 +66,6 @@ function showDefaultTalk(session, npc) { return; } - const C4GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports'); - const gatekeeperHtml = C4GatekeeperTeleports.html(npc.fetchSelfId()); - if (gatekeeperHtml) { - session.dataSendToMe(ServerResponse.npcHtml(npc.fetchId(), gatekeeperHtml)); - session.dataSendToMe(ServerResponse.actionFailed()); - return; - } - session.dataSendToMe( ServerResponse.npcHtml(npc.fetchId(), utils.parseRawFile( utils.fileExists(filename) ? filename : path + 'noquest.html' diff --git a/src/GameServer/World/Generics/RemoveNpc.js b/src/GameServer/World/Generics/RemoveNpc.js index bcb9b563..897e6941 100644 --- a/src/GameServer/World/Generics/RemoveNpc.js +++ b/src/GameServer/World/Generics/RemoveNpc.js @@ -1,6 +1,6 @@ -const ServerResponse = invoke('GameServer/Network/Response'); const SpoilSweep = invoke('GameServer/Npc/SpoilSweep'); const SpawnNpcs = invoke('GameServer/World/Generics/SpawnNpcs'); +const NpcVisibility = invoke('GameServer/World/NpcVisibility'); function removeNpc(session, npc) { const npcId = npc.fetchId(); @@ -19,7 +19,7 @@ function removeNpc(session, npc) { // Delete NPC from world setTimeout(() => { - session.dataSendToMeAndOthers(ServerResponse.deleteOb(npcId), npc); + NpcVisibility.deleteKnownNpc(this, session, npcId); this.npc.spawns = this.npc.spawns.filter(ob => ob.fetchId() !== npcId); this.indexSpawnsInGrid(); }, SpoilSweep.corpseTime(npc)); diff --git a/src/GameServer/World/NpcVisibility.js b/src/GameServer/World/NpcVisibility.js new file mode 100644 index 00000000..05fbb2fa --- /dev/null +++ b/src/GameServer/World/NpcVisibility.js @@ -0,0 +1,58 @@ +const ServerResponse = invoke('GameServer/Network/Response'); + +const NPC_INFO_OPCODE = 0x16; +const DELETE_OBJECT_OPCODE = 0x12; + +function objectId(packet) { + if (!packet || packet.length < 5 || typeof packet.readInt32LE !== 'function') { + return null; + } + + return packet.readInt32LE(1); +} + +function trackNpcPacket(session, packet) { + const id = objectId(packet); + if (!session || id === null) return; + + if (packet[0] === NPC_INFO_OPCODE) { + session.knownNpcIds ||= new Set(); + session.knownNpcIds.add(id); + } + else if (packet[0] === DELETE_OBJECT_OPCODE) { + session.knownNpcIds?.delete(id); + } +} + +function npcRemovalRecipients(world, sourceSession, npcId) { + const recipients = new Set(); + + if (typeof sourceSession?.dataSendToMe === 'function') { + recipients.add(sourceSession); + } + + (world.user?.sessions || []).forEach((session) => { + if ( + session?.actor?.fetchIsOnline?.() === true && + typeof session.dataSendToMe === 'function' && + session.knownNpcIds?.has(npcId) + ) { + recipients.add(session); + } + }); + + return recipients; +} + +function deleteKnownNpc(world, sourceSession, npcId, response = ServerResponse) { + const packet = response.deleteOb(npcId); + const recipients = npcRemovalRecipients(world, sourceSession, npcId); + recipients.forEach((session) => session.dataSendToMe(packet)); + return recipients.size; +} + +module.exports = { + deleteKnownNpc, + npcRemovalRecipients, + trackNpcPacket +}; diff --git a/tests/test_bot_background_drops.js b/tests/test_bot_background_drops.js index 06de316f..d0175f02 100644 --- a/tests/test_bot_background_drops.js +++ b/tests/test_bot_background_drops.js @@ -23,6 +23,7 @@ const direct = BackgroundDropResolver.rollForFight({ spot, killerLevel: 1, rng: assert.strictEqual(direct.length, 1); assert.strictEqual(direct[0].selfId, 1121, 'the selected item must come from the real Gremlin rewards'); assert.strictEqual(direct[0].kind, 'Armor.Wear'); +assert.strictEqual(direct[0].sourceMobLevel, 1, 'background loot must retain the source-mob level for sale policy'); const nameOnly = BackgroundDropResolver.rollForFight({ spot: { ...spot, npcSelfIds: [], npcNames: ['Gremlin'] }, diff --git a/tests/test_bot_class_progression.js b/tests/test_bot_class_progression.js index 7d8cf859..d866fd0a 100644 --- a/tests/test_bot_class_progression.js +++ b/tests/test_bot_class_progression.js @@ -9,6 +9,11 @@ const BotRoles = invoke('GameServer/Bot/AI/BotRoles'); DataCache.init(); +const firstProfessionChoices = new Set(Array.from({ length: 30 }, (_, index) => ( + BotClassProgression.nextClass(0, 20, `starter_${index}`) +))); +assert(firstProfessionChoices.size > 1, 'first-profession choices must vary across a generated fighter cohort'); + const original = { fetchSkill: Database.fetchSkill, fetchSkills: Database.fetchSkills, diff --git a/tests/test_bot_cold_market_listing.js b/tests/test_bot_cold_market_listing.js index efae00ee..d1c821aa 100644 --- a/tests/test_bot_cold_market_listing.js +++ b/tests/test_bot_cold_market_listing.js @@ -53,7 +53,10 @@ async function run() { timing: {}, inventory: { 57: { selfId: 57, name: 'Adena', amount: 500 }, - 1: { selfId: 1, name: 'Short Sword', amount: 1, equipped: false, slot: 7, kind: 'Weapon.Sword' }, + // Keep this as a C-grade listing: the fixture exercises the Giran + // plaza, while no-grade stock is deliberately routed to the + // nearest starter village. + 1: { selfId: 1, name: 'Short Sword', amount: 1, equipped: false, slot: 7, kind: 'Weapon.Sword', rank: 'c' }, 2: { selfId: 2, name: 'Long Sword', amount: 1, equipped: true, slot: 7, kind: 'Weapon.Sword' } }, stats: { @@ -65,6 +68,30 @@ async function run() { const candidates = ItemDisposition.saleCandidates(state); assert.deepStrictEqual(candidates.map((item) => item.selfId), [1], 'equipped gear must never be listed'); + const preTradeState = { + ...state, + level: 9, + stats: { ...state.stats, generatedCold: true } + }; + assert.deepStrictEqual(ItemDisposition.saleCandidates(preTradeState), [], 'generated bots must not sell before level ten'); + const preTradeListing = await ListingService.open(preTradeState, { now: 1000 }); + assert.strictEqual(preTradeListing.reason, 'nothing_to_sell', 'pre-ten generated bots must never open a private store'); + + const starterMobLootState = { + ...state, + stats: { ...state.stats, generatedCold: true }, + inventory: { + 57: { selfId: 57, name: 'Adena', amount: 500 }, + 1: { selfId: 1, name: 'Short Sword', amount: 1, equipped: false, slot: 7, kind: 'Weapon.Sword', starterMobLootAmount: 1 }, + 1864: { selfId: 1864, name: 'Stem', amount: 4, kind: 'Other.Material', starterMobLootAmount: 4 } + } + }; + assert.deepStrictEqual( + ItemDisposition.saleCandidates(starterMobLootState).map((item) => item.selfId), + [1864], + 'ordinary level-one-to-five loot must stay out of sales while materials remain sellable' + ); + const opened = await ListingService.open(state, { now: 1000, durationMs: 60000, random: () => 0.1 }); assert.strictEqual(opened.listed, true); assert.strictEqual(opened.state.activity, 'merchant'); diff --git a/tests/test_bot_gear.js b/tests/test_bot_gear.js index ef0671d4..880a977b 100644 --- a/tests/test_bot_gear.js +++ b/tests/test_bot_gear.js @@ -9,6 +9,9 @@ const BotGear = invoke('GameServer/Bot/AI/BotGear'); const BotEquipmentUpgrade = invoke('GameServer/Bot/AI/BotEquipmentUpgrade'); const Item = invoke('GameServer/Item/Item'); +assert.strictEqual(BotGear.ensureCharacterGear, undefined, + 'level-based gear plans must guide acquisition, not create free equipment on spawn'); + function bySlot(plan, slot) { return plan.items.find((item) => Number(item.slot) === Number(slot)); } diff --git a/tests/test_bot_gear_acquisition.js b/tests/test_bot_gear_acquisition.js index 26e2d6a4..41cc2c9e 100644 --- a/tests/test_bot_gear_acquisition.js +++ b/tests/test_bot_gear_acquisition.js @@ -44,10 +44,24 @@ if (previousProgressionRate === undefined) delete process.env.L2NODE_PROGRESSION else process.env.L2NODE_PROGRESSION_RATE = previousProgressionRate; const noGradePlan = GearAcquisitionPlanner.planFor({ level: 10, stats: { classId: 0, role: 'dps' }, inventory: {} }, { spots: [stoneGolemSpot] }); -assert.strictEqual(noGradePlan.strategy, 'direct_drop', 'no-grade bots must use drop goals rather than recipes'); +assert(['direct_drop', 'market'].includes(noGradePlan.strategy), 'no-grade bots must choose a drop or market route, never recipes'); assert.strictEqual(noGradePlan.recipeId, null, 'no-grade bots must never receive a crafting recipe'); assert.strictEqual(noGradePlan.rateModelVersion, GearAcquisitionPlanner.RATE_MODEL_VERSION, 'all acquisition plans must persist the drop-rate model used for their estimates'); +const preFocusPlan = GearAcquisitionPlanner.planFor({ level: 4, stats: { classId: 0, role: 'dps' }, inventory: {} }, { spots: [stoneGolemSpot] }); +assert.strictEqual(preFocusPlan.status, 'deferred', 'starter bots must level naturally before gear acquisition begins'); +assert.strictEqual(preFocusPlan.strategy, 'none'); + +const forcedRecipeBeforeTwenty = GearAcquisitionPlanner.planFor({ level: 19, stats: { classId: 0, role: 'dps' }, inventory: {} }, { spots: [stoneGolemSpot], recipeId: 189 }); +assert.notStrictEqual(forcedRecipeBeforeTwenty.strategy, 'craft', 'no-grade bots must never enter a craft route before level twenty'); + +const marketNoGradePlan = GearAcquisitionPlanner.planFor({ level: 5, stats: { classId: 0, role: 'dps' }, inventory: {} }, { + spots: [], + findMarketOffer: (item) => ({ selfId: item.selfId, price: 1, town: 'Giran', sourceType: 'npc' }) +}); +assert.strictEqual(marketNoGradePlan.strategy, 'market', 'an affordable no-grade market offer must beat an unavailable drop route'); +assert.strictEqual(marketNoGradePlan.recipeId, null, 'no-grade market purchases must never request crafting'); + const serviceCrafter = { level: 70, activity: 'crafting', @@ -61,6 +75,12 @@ const mage = { level: 40, stats: { classId: 10, role: 'mage' }, inventory: {} }; const target = GearAcquisitionPlanner.preferredTarget(mage); assert(target, 'a C-grade mage without gear must receive a craftable target'); assert(['Weapon.Sword', 'Weapon.Blunt'].includes(target.item.template.kind), 'mage target must use a caster weapon family'); + +const dMarketPlan = GearAcquisitionPlanner.planFor({ ...mage, level: 20 }, { + spots: [], + findMarketOffer: (item) => ({ selfId: item.selfId, price: 1, town: 'Giran', sourceType: 'npc' }) +}); +assert.strictEqual(dMarketPlan.strategy, 'market', 'D-grade bots must compare a ready market offer with crafting and drops'); assert(Number(target.item.template.price) <= 2290000, 'a new C-grade bot must begin with an entry-tier weapon target'); const station = ColdCraftingService.stationForRecipe(target.recipe.recipeId); assert(station, 'a selected equipment recipe must be published by a Giran crafting station'); diff --git a/tests/test_bot_goal_planner.js b/tests/test_bot_goal_planner.js index e3fd1df1..601be4af 100644 --- a/tests/test_bot_goal_planner.js +++ b/tests/test_bot_goal_planner.js @@ -52,6 +52,7 @@ assert.strictEqual(equipmentGoal.target.itemId, expectedWeapon.selfId); assert.strictEqual(equipmentGoal.plan.expectedBenefit, 'adena_for_weapon_upgrade'); const expectedChest = invoke('GameServer/Bot/AI/BotGear').planFor({ classId: 0, level: 40 }).items.find((item) => Number(item.slot) === 10); +const expectedChestPrice = Number((DataCache.items || []).find((item) => Number(item.selfId) === Number(expectedChest.selfId))?.template?.price || 0); const armorGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({ ...base, adena: 1000000, @@ -66,9 +67,42 @@ assert.strictEqual(armorGoal.target.equipmentSlot, 'chest'); assert.strictEqual(armorGoal.target.itemId, expectedChest.selfId); assert.strictEqual(armorGoal.plan.expectedBenefit, 'market_search_for_gear'); +const staleMarketPlanGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({ + ...base, + adena: 1000000, + stats: { + classId: 0, + build: { grade: 'c', classId: 0, level: 40 }, + // The weapon was just purchased. The resolver has not rebuilt the + // equipment plan yet, so the next goal must use the chest's own + // template data instead of the completed weapon offer. + equipment: [{ selfId: expectedWeapon.selfId, slot: 7, rank: 'c', name: expectedWeapon.name }], + equipmentPlan: { + status: 'active', + strategy: 'market', + target: { selfId: expectedWeapon.selfId }, + market: { town: 'Dion', price: 7 } + } + } +}, { spot, now: timestamp }), timestamp); +assert.strictEqual(staleMarketPlanGoal.target.itemId, expectedChest.selfId, 'the next build slot must replace a completed market target'); +assert.strictEqual(staleMarketPlanGoal.target.adena, expectedChestPrice, 'the next item must use its own price rather than the completed offer'); +assert.strictEqual(staleMarketPlanGoal.plan.marketTown, null, 'the next item must be replanned before choosing a market town'); + const noSnapshot = GoalPlanner.plan(NeedsEvaluator.evaluate({ ...base, stats: { classId: 0, build: { grade: 'c' } } }, { spot, now: timestamp }), timestamp); assert.notStrictEqual(noSnapshot.type, 'upgrade_gear', 'missing equipment data must not invent a gear deficit'); +const preFocusGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({ + ...base, + level: 4, + stats: { + classId: 0, + build: { grade: 'none', classId: 0, level: 4 }, + equipment: [] + } +}, { spot, now: timestamp }), timestamp); +assert.strictEqual(preFocusGoal.type, 'progress_level', 'bots below level five must not abandon starter leveling for equipment goals'); + const saleGoal = GoalPlanner.plan(NeedsEvaluator.evaluate({ ...base, inventory: { diff --git a/tests/test_bot_market_town_routing.js b/tests/test_bot_market_town_routing.js index 4fcdd325..12b74cca 100644 --- a/tests/test_bot_market_town_routing.js +++ b/tests/test_bot_market_town_routing.js @@ -90,7 +90,7 @@ const dionStall = ListingService.chooseDionDMarketStall(() => 0.5, []); assert(ListingService.isDionDMarketStallLocation(dionStall), 'Dion D-grade listings must remain inside the captured trading square'); const gludioStaticStalls = ListingService.staticMerchantStalls('Gludio', ListingService.isGludioDMarketStallLocation); -assert.strictEqual(gludioStaticStalls.length, 4, 'all fixed Gludio merchants must reserve their market stalls'); +assert.strictEqual(gludioStaticStalls.length, 5, 'all fixed Gludio merchants must reserve their market stalls'); const gludioCandidateNearLysa = ListingService.chooseGludioDMarketStall( (() => { const values = [60 / 390, 970 / 1080]; @@ -122,8 +122,8 @@ const noGradeOverflowState = { }; assert.strictEqual( ListingService.targetMarketTownName(noGradeOverflowState, [{ rank: 'none' }]), - 'Giran', - 'a level-appropriate no-grade-only listing must not be mistaken for D-grade overflow' + 'Elven Village', + 'a no-grade-only listing must use the nearest starter market even when its farming spot is outside the village radius' ); assert.strictEqual( ListingService.targetMarketTownName({ @@ -142,7 +142,7 @@ const talkingIslandStall = ListingService.chooseTalkingIslandNoGradeStall(() => assert(ListingService.isTalkingIslandNoGradeStallLocation(talkingIslandStall), 'Talking Island no-grade listings must remain inside the captured trading square'); assert.strictEqual( ListingService.staticMerchantStalls('Talking Island', ListingService.isTalkingIslandNoGradeStallLocation).length, - 4, + 5, 'fixed Talking Island merchants must reserve their market stalls' ); diff --git a/tests/test_gatekeeper_teleports.js b/tests/test_gatekeeper_teleports.js index 9ac865a5..dd695f8d 100644 --- a/tests/test_gatekeeper_teleports.js +++ b/tests/test_gatekeeper_teleports.js @@ -4,6 +4,7 @@ require('../src/Global'); const GatekeeperTeleports = invoke('GameServer/World/C4GatekeeperTeleports'); const LateTownGatekeepers = invoke('GameServer/World/C4LateTownGatekeepers'); +const QuestService = invoke('GameServer/Quest/QuestService'); const cityGatekeepers = [7006, 7059, 7080, 7134, 7146, 7162, 7177, 7233, 7256, 7320, 7540, 7576, 7848, 8275, 8320]; for (const npcId of cityGatekeepers) { @@ -12,6 +13,10 @@ for (const npcId of cityGatekeepers) { for (const [id] of GatekeeperTeleports.lists[npcId]) { assert.ok(GatekeeperTeleports.destination(npcId, id), `gatekeeper ${npcId} destination ${id} must resolve`); } + assert.match(GatekeeperTeleports.menu(npcId, false), /gatekeeper-teleport/, `gatekeeper ${npcId} must always offer teleport from its main dialog`); + assert.doesNotMatch(GatekeeperTeleports.menu(npcId, false), /gatekeeper-quest/, `gatekeeper ${npcId} must not offer an unavailable quest`); + assert.match(GatekeeperTeleports.menu(npcId, true), /gatekeeper-teleport/, `quest-capable gatekeeper ${npcId} must keep teleport in its main dialog`); + assert.match(GatekeeperTeleports.menu(npcId, true), /gatekeeper-quest/, `quest-capable gatekeeper ${npcId} must offer the quest branch`); } assert.strictEqual(GatekeeperTeleports.destination(7006, 18), null, 'a gatekeeper must not expose another city’s route by raw id'); @@ -25,4 +30,20 @@ for (const npcId of [8275, 8320]) { assert.ok(LateTownGatekeepers.npcs.some((npc) => npc.selfId === npcId), `NPC template ${npcId} must be present`); assert.ok(LateTownGatekeepers.spawns.some((group) => group.spawns.some((spawn) => spawn.selfId === npcId)), `NPC ${npcId} must spawn`); } -console.log('gatekeeper teleport checks passed'); +for (const npcId of [8256, 8300]) { + assert.ok(LateTownGatekeepers.npcs.some((npc) => npc.selfId === npcId && npc.template.kind === 'Merchant'), `late-town merchant ${npcId} must be present`); + assert.ok(LateTownGatekeepers.spawns.some((group) => group.spawns.some((spawn) => spawn.selfId === npcId)), `late-town merchant ${npcId} must spawn`); +} +(async () => { + const session = { + actor: { fetchLevel: () => 10, fetchRace: () => 0 }, + questStatesLoaded: true, + questStates: new Map() + }; + assert.strictEqual(await QuestService.hasTalk(session, { fetchSelfId: () => 7006 }), true, 'Roxxy must expose the quest branch when Step into the Future can start'); + assert.strictEqual(await QuestService.hasTalk(session, { fetchSelfId: () => 7059 }), false, 'a gatekeeper without a relevant quest must stay teleport-only'); + console.log('gatekeeper teleport checks passed'); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/test_npc_known_object_lifecycle.js b/tests/test_npc_known_object_lifecycle.js new file mode 100644 index 00000000..4563dd5c --- /dev/null +++ b/tests/test_npc_known_object_lifecycle.js @@ -0,0 +1,56 @@ +const assert = require('assert'); + +require('../src/Global'); + +const NpcVisibility = invoke('GameServer/World/NpcVisibility'); + +function npcInfo(id) { + const packet = Buffer.alloc(5); + packet[0] = 0x16; + packet.writeInt32LE(id, 1); + return packet; +} + +function session(online = true) { + return { + actor: { fetchIsOnline: () => online }, + sent: [], + dataSendToMe(packet) { + this.sent.push(packet); + NpcVisibility.trackNpcPacket(this, packet); + } + }; +} + +const oldNpcId = 1016465; +const newNpcId = 1016466; +const killer = session(); +const sawOldNpc = session(); +const neverSawNpc = session(); +const offlineViewer = session(false); + +NpcVisibility.trackNpcPacket(sawOldNpc, npcInfo(oldNpcId)); +NpcVisibility.trackNpcPacket(offlineViewer, npcInfo(oldNpcId)); + +const delivered = NpcVisibility.deleteKnownNpc({ + user: { sessions: [killer, sawOldNpc, neverSawNpc, offlineViewer] } +}, killer, oldNpcId, { + deleteOb: (id) => { + const packet = Buffer.alloc(5); + packet[0] = 0x12; + packet.writeInt32LE(id, 1); + return packet; + } +}); + +assert.strictEqual(delivered, 2, 'the killer and every online viewer of the old object must receive DeleteObject'); +assert.strictEqual(killer.sent.length, 1, 'the killer must retain the previous direct cleanup behavior'); +assert.strictEqual(sawOldNpc.sent.length, 1, 'a viewer outside the corpse radius must still lose the stale NPC object'); +assert.strictEqual(neverSawNpc.sent.length, 0, 'clients that never received NpcInfo must not receive unrelated deletes'); +assert.strictEqual(offlineViewer.sent.length, 0, 'offline clients must not receive packets'); +assert.strictEqual(sawOldNpc.knownNpcIds.has(oldNpcId), false, 'DeleteObject must remove the stale id from the known-object set'); + +NpcVisibility.trackNpcPacket(sawOldNpc, npcInfo(newNpcId)); +assert.strictEqual(sawOldNpc.knownNpcIds.has(newNpcId), true, 'the respawned object must be tracked under its new World ID'); + +console.log('NPC known-object lifecycle regression checks passed'); diff --git a/tests/test_npc_shop_stock.js b/tests/test_npc_shop_stock.js index cfd1bb4c..39ae98f4 100644 --- a/tests/test_npc_shop_stock.js +++ b/tests/test_npc_shop_stock.js @@ -4,6 +4,9 @@ require('../src/Global'); const DataCache = invoke('GameServer/DataCache'); const BuyShop = invoke('GameServer/World/Generics/NpcBypasses/BuyShop'); +const NpcShopBuyLists = invoke('GameServer/World/Generics/NpcShopBuyLists'); +const MerchantStoreConfigs = invoke('GameServer/Bot/MerchantStoreConfigs'); +const GeodataEngine = invoke('GameServer/Geodata/GeodataEngine'); DataCache.items = require('../data/Items/Others/others.json'); @@ -44,3 +47,49 @@ assert.strictEqual(rows.get(2509).amount, 0, 'NPC Spiritshot stock should be unl assert.strictEqual(rows.get(17).amount, 0, 'NPC arrow stock should be unlimited in BuyList'); assert.strictEqual(rows.get(1060).amount, 0, 'NPC scroll stock should be unlimited in BuyList'); assert.strictEqual(rows.get(1835).price, 8, 'NPC shop should preserve audited per-NPC prices'); + +const shopSpiritshots = (npcId) => NpcShopBuyLists.fetchForNpc(npcId) + .map((entry) => entry.selfId) + .filter((selfId) => selfId >= 2509 && selfId <= 2514); + +for (const npcId of [7004, 7137, 7150, 7519, 7561, 7063, 7254, 7315, 7081, 7180, 7301, 7834, 7839, 8256, 8300]) { + assert.deepStrictEqual(shopSpiritshots(npcId), [2509], `ordinary NPC merchant ${npcId} must only retain its no-grade Spiritshot`); +} + +const shotStores = [ + ['Tia', 'Talking Island', 0], ['Elya', 'Elven Village', 0], ['Dena', 'Dark Elven Village', 0], + ['Orik', 'Orc Village', 0], ['Bran', 'Dwarven Village', 0], ['Rolf', 'Gludin', 1], + ['Sila', 'Gludio', 1], ['Tara', 'Dion', 1], ['Eris', 'Giran', 2], ['Sera', 'Oren', 3], + ['Nora', "Hunter's Village", 3], ['Lina', 'Heine', 3], ['Mila', 'Aden', 4], + ['Sven', 'Goddard', 5], ['Runa', 'Rune', 5] +]; +const shotIdsByGrade = [ + [1835, 2509, 3947], [1463, 2510, 3948], [1464, 2511, 3949], + [1465, 2512, 3950], [1466, 2513, 3951], [1467, 2514, 3952] +]; +for (const [name, town, grade] of shotStores) { + const store = MerchantStoreConfigs[name]; + assert.ok(store, `${town} must have a dedicated shot merchant`); + assert.strictEqual(store.storeType, 1, `${name} must be a selling private store`); + assert.strictEqual(store.town, town, `${name} must be placed in ${town}`); + assert.deepStrictEqual(store.items.map((item) => item.selfId), shotIdsByGrade[grade], `${name} must stock every shot type at its town grade only`); + store.items.forEach((item) => { + assert.strictEqual(item.priceRate, 1, `${name} must use the standard shot price`); + assert.strictEqual(item.count, 999999, `${name} must have a practical unlimited shot stock`); + }); +} + +assert(Math.hypot(MerchantStoreConfigs.Rolf.locX + 80826, MerchantStoreConfigs.Rolf.locY - 149775) < 1000, + 'Gludin shot merchant must be placed inside the town square'); + +// These stalls were captured beside each town's gatekeeper and checked against +// the loaded geodata. Keeping the Z value on the actual floor prevents private +// stores from being hidden in a building or on another vertical layer. +const accessibleStalls = [ + 'Elya', 'Dena', 'Orik', 'Bran', 'Iris', 'Helga', 'Oskar', 'Selin', 'Sera', 'Nora', 'Mila' +]; +for (const name of accessibleStalls) { + const store = MerchantStoreConfigs[name]; + const ground = GeodataEngine.getHeight(store.locX, store.locY, store.locZ); + assert.strictEqual(store.locZ, ground, `${name} must stand on the visible geodata floor`); +} diff --git a/tests/test_population_seed_planner.js b/tests/test_population_seed_planner.js new file mode 100644 index 00000000..4e090503 --- /dev/null +++ b/tests/test_population_seed_planner.js @@ -0,0 +1,158 @@ +const assert = require('assert'); + +require('../src/Global'); + +const Planner = invoke('GameServer/Bot/Population/PopulationSeedPlanner'); +const GeneratedColdSeeder = invoke('GameServer/Bot/Population/GeneratedColdSeeder'); +const BotPopulation = invoke('GameServer/Bot/BotPopulation'); + +const profiles = Planner.STARTER_REGIONS.map((region) => ({ + id: `starter_${region.id}`, + minLevel: 1, + avgLevel: 1, + center: { ...region.center } +})); + +const initial = Planner.plan(profiles, [], 1700, 30); +assert.strictEqual(initial.averageLevel, 0); +assert.strictEqual(initial.wave, 1); +assert.strictEqual(initial.missing.length, 150, 'first start must create 30 bots at each of five racial spawns'); +assert.deepStrictEqual( + initial.missing.reduce((counts, spot) => ({ ...counts, [spot.starterRegion]: (counts[spot.starterRegion] || 0) + 1 }), {}), + { human: 30, elf: 30, dark_elf: 30, orc: 30, dwarf: 30 }, + 'the first wave must be balanced between racial spawn regions' +); +assert.strictEqual(Planner.seedBatchSize(initial, 2), 150, + 'the first wave must not be split by the normal seed batch limit'); +assert.strictEqual(Planner.waveLevelThreshold(1), 5, 'x1 must open the next wave at level 5'); +assert.strictEqual(Planner.waveLevelThreshold(10), 5, 'x10 must retain the level-5 wave threshold'); +assert.strictEqual(Planner.waveLevelThreshold(50), 10, 'x50 must defer the next wave to level 10'); +assert.strictEqual(Planner.waveLevelThreshold(100), 10, 'rates above x50 must retain the level-10 wave threshold'); + +const starterRaces = { human: 0, elf: 1, dark_elf: 2, orc: 3, dwarf: 4 }; +Object.entries(starterRaces).forEach(([starterRegion, race]) => { + Array.from({ length: 20 }, (_, index) => index).forEach((index) => { + assert.strictEqual(GeneratedColdSeeder.baseForIndex(index, starterRegion).race, race, + `${starterRegion} population slots must use only that race`); + }); +}); + +const staticStarterRaces = { + 'Talking Island': 0, + 'Elven Village': 1, + 'Dark Elven Village': 2, + 'Orc Village': 3, + 'Dwarven Village': 4 +}; +BotPopulation.buildStarterBots() + .filter((bot) => Object.hasOwn(staticStarterRaces, bot.homeRegion)) + .forEach((bot) => { + assert.strictEqual(bot.race, staticStarterRaces[bot.homeRegion], + `${bot.homeRegion} static starter cohort must use the local race`); + }); + +const legacyPopulation = Planner.plan(profiles, Array.from({ length: 132 }, (_, index) => ({ + characterId: index + 1, + level: 16, + spotId: `legacy_${index}`, + activity: 'hunting', + stats: {} +})), 1700, 30); +assert.strictEqual(legacyPopulation.missing.length, 150, + 'legacy bots must not replace any member of the first 30-per-race cohort'); + +const regionalSlots = Planner.starterSlots([ + ...profiles, + { id: 'remote_starter', minLevel: 1, avgLevel: 1, center: { locX: 0, locY: 0 } } +], 30, 1); +regionalSlots.forEach((spot) => { + const region = Planner.STARTER_REGIONS.find((entry) => entry.id === spot.starterRegion); + const dx = spot.center.locX - region.center.locX; + const dy = spot.center.locY - region.center.locY; + assert.ok((dx * dx) + (dy * dy) <= region.radius * region.radius, + `${spot.starterRegion} slots must remain inside their racial starter region`); +}); + +const progressed = Planner.plan(profiles, [ + ...initial.missing.map((spot, index) => ({ + characterId: index + 1, + level: 5, + spotId: `moved_on_${index}`, + activity: 'hunting', + stats: { populationWave: 1, starterRegion: spot.starterRegion } + })), + { characterId: 2, level: 70, spotId: null, activity: 'crafting' } +], 1700, 30, { progressionMultiplier: 1 }); +assert.strictEqual(progressed.averageLevel, 5, 'craft services must not accelerate population waves'); +assert.strictEqual(progressed.wave, 2); +assert.strictEqual(progressed.targetPopulation, 300); +assert.strictEqual(progressed.missing.length, 150, + 'at average level 5 exactly one additional 150-bot starter cohort opens'); +assert.strictEqual(Planner.seedBatchSize(progressed, 2), 150, + 'a later 150-bot cohort must not be split by the normal seed batch limit'); + +const highRateNotReady = Planner.plan(profiles, initial.missing.map((spot, index) => ({ + characterId: index + 1, + level: 5, + spotId: `high_rate_${index}`, + activity: 'hunting', + stats: { populationWave: 1, starterRegion: spot.starterRegion } +})), 1700, 30, { progressionMultiplier: 50 }); +assert.strictEqual(highRateNotReady.levelThreshold, 10); +assert.strictEqual(highRateNotReady.wave, 1, 'x50 must not open the second wave at level 5'); +assert.strictEqual(highRateNotReady.missing.length, 0, 'x50 must wait for the first cohort to reach level 10'); + +const highRateProgressed = Planner.plan(profiles, initial.missing.map((spot, index) => ({ + characterId: index + 1, + level: 10, + spotId: `high_rate_${index}`, + activity: 'hunting', + stats: { populationWave: 1, starterRegion: spot.starterRegion } +})), 1700, 30, { progressionMultiplier: 50 }); +assert.strictEqual(highRateProgressed.wave, 2, 'x50 must open the second wave at level 10'); +assert.strictEqual(highRateProgressed.missing.length, 150, 'x50 level-10 progress must add one full cohort'); + +const merchantBackfill = Planner.plan(profiles, initial.missing.map((spot, index) => ({ + characterId: index + 1, + level: 2, + spotId: spot.id, + activity: spot.starterRegion === 'human' && index < 10 ? 'merchant' : 'hunting', + stats: { populationWave: 1, starterRegion: spot.starterRegion } +})), 1700, 30); +assert.strictEqual(merchantBackfill.missing.length, 2, 'merchant departures must only backfill their own racial cohort'); +assert(merchantBackfill.missing.every((spot) => spot.starterRegion === 'human'), 'racial backfill must not drift into another starter region'); + +const merchantCapped = Planner.plan(profiles, Array.from({ length: 1700 }, (_, index) => ({ + characterId: index + 1, + level: 12, + spotId: `merchant_cap_${index}`, + activity: index < 150 ? 'merchant' : 'hunting', + stats: { populationWave: 1, starterRegion: index < 150 ? 'human' : 'elf' } +})), 1700, 30); +assert.strictEqual(merchantCapped.playingPopulation, 1550, + 'merchant states must remain outside the hunting population used for wave pacing'); +assert.strictEqual(merchantCapped.population, 1700, + 'the global cap must include generated merchants'); +assert.strictEqual(merchantCapped.missing.length, 0, + 'no replacement may be created once all generated bot slots are occupied by hunters or merchants'); + +const capped = Planner.plan(profiles, [ + ...Array.from({ length: 1690 }, (_, index) => ({ + characterId: index + 1, + level: 55, + spotId: `moved_${index}`, + activity: 'hunting', + stats: { populationWave: 11 } + })) +], 1700, 30); +assert.strictEqual(capped.missing.length, 10, 'the final partial wave must stop exactly at the hard population cap'); + +const generatedNames = Array.from({ length: 5000 }, (_, index) => GeneratedColdSeeder.nameFor(Date.now() + index)); +assert.ok(generatedNames.every((name) => name.length >= 3 && name.length <= 16), 'generated names must fit the character-name column'); +assert.ok(generatedNames.every((name) => /^[A-Za-z]+$/.test(name)), 'generated names must remain client-safe alphabetic nicknames'); +assert.ok(new Set(generatedNames).size > 4500, 'the local nickname corpus must provide a varied population'); +assert.ok(generatedNames.every((name) => !/[0-9]/.test(name)), 'ordinary generated names must not expose population counters'); +assert.ok(generatedNames.every((name) => /^[A-Z][a-z]+[A-Z][a-z]+$/.test(name)), 'generated names must remain readable CamelCase name pairs'); +assert.strictEqual(new Set(generatedNames).size, generatedNames.length, 'readable names must remain unique across a full population sample'); + +console.log('Population seed planner checks passed'); diff --git a/tests/test_population_starter_party_grouping.js b/tests/test_population_starter_party_grouping.js new file mode 100644 index 00000000..b0807727 --- /dev/null +++ b/tests/test_population_starter_party_grouping.js @@ -0,0 +1,43 @@ +const assert = require('assert'); + +require('../src/Global'); + +const PopulationService = invoke('GameServer/Bot/Population/PopulationService'); +const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles'); + +function starter(characterId, starterRegion, spotId) { + return { + characterId, + level: 1, + spotId, + party: { role: 'dps' }, + stats: { + populationWave: 1, + starterRegion, + equipmentPlan: { status: 'active', next: { spotId: '7_40' } } + } + }; +} + +const human = starter(1, 'human', 'starter_human'); +const elf = starter(2, 'elf', 'starter_elf'); +const groups = PopulationService.groupBySpot([human, elf]); +assert.deepStrictEqual(groups.map((group) => group.map((state) => state.characterId)), [[1], [2]], + 'starter cohorts with the same gear target must remain grouped by their physical spot'); + +const originalFindForState = SpotProfiles.findForState; +let partyLeader = null; +SpotProfiles.findForState = (state) => { + partyLeader = state; + return { id: state.spotId }; +}; +try { + const partySpot = PopulationService.partySpotForLeader(human); + assert.strictEqual(partyLeader.spotId, 'starter_human', + 'starter party formation must retain the leader physical spot'); + assert.strictEqual(partySpot.id, 'starter_human'); +} finally { + SpotProfiles.findForState = originalFindForState; +} + +console.log('Starter party grouping checks passed'); diff --git a/tests/test_spot_profile_state_priority.js b/tests/test_spot_profile_state_priority.js new file mode 100644 index 00000000..1e4057f7 --- /dev/null +++ b/tests/test_spot_profile_state_priority.js @@ -0,0 +1,47 @@ +const assert = require('assert'); + +require('../src/Global'); + +const SpotProfiles = invoke('GameServer/Bot/Population/SpotProfiles'); + +const originalCache = SpotProfiles.cache; +SpotProfiles.cache = [ + { + id: 'starter_human_01', + avgLevel: 1, + minLevel: 1, + maxLevel: 3, + density: 10, + center: { locX: -84000, locY: 245000, locZ: -3729 } + }, + { + id: 'gear_source_01', + avgLevel: 8, + minLevel: 6, + maxLevel: 10, + density: 10, + center: { locX: -110000, locY: 76000, locZ: -2800 } + } +]; + +try { + const equipmentPlan = { + status: 'active', + next: { spotId: 'gear_source_01' } + }; + const atStarter = SpotProfiles.findForState({ + level: 1, + spotId: 'starter_human_01', + stats: { equipmentPlan, populationWave: 1, starterRegion: 'human' } + }); + assert.strictEqual(atStarter.id, 'starter_human_01', + 'an active equipment plan must not replace a persisted physical starter spot'); + + const unplaced = SpotProfiles.findForState({ level: 1, stats: { equipmentPlan } }); + assert.strictEqual(unplaced.id, 'gear_source_01', + 'a state without a physical spot may still select its equipment source'); +} finally { + SpotProfiles.cache = originalCache; +} + +console.log('Spot profile state-priority checks passed');