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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions database/sql/sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,25 @@ CREATE INDEX IF NOT EXISTS bot_life_state_phase_nextResolveAt ON bot_life_state(
CREATE INDEX IF NOT EXISTS bot_life_state_phase_partyId ON bot_life_state(phase, partyId);
CREATE INDEX IF NOT EXISTS bot_life_state_accountName ON bot_life_state(accountName);
CREATE INDEX IF NOT EXISTS bot_life_state_characterName ON bot_life_state(characterName COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS bot_life_state_party_request_filter
ON bot_life_state(
phase,
partyId,
activity,
json_extract(statsJson, '$.partyRequest.status'),
json_extract(statsJson, '$.partyRequest.priority')
);
CREATE INDEX IF NOT EXISTS bot_life_state_party_objective_spot
ON bot_life_state(
phase,
partyId,
activity,
COALESCE(
json_extract(statsJson, '$.partyRequest.spotId'),
json_extract(statsJson, '$.equipmentPlan.next.spotId'),
spotId
)
);
Comment on lines +181 to +199

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

db=${1:?Usage: $0 /path/to/bot.sqlite}

sqlite3 "$db" <<'SQL'
SELECT sqlite_version() AS sqlite_version;
SELECT json_extract('{"probe":1}', '$.probe') AS json1_probe;

SELECT COUNT(*) AS invalid_stats_json
FROM bot_life_state
WHERE statsJson IS NOT NULL
  AND json_valid(statsJson) = 0;

PRAGMA index_list('bot_life_state');
SQL

rg -n -C 6 \
  'sqlite\.sql|CREATE INDEX IF NOT EXISTS|migration' \
  --glob '*.js' \
  --glob '*.sql' \
  .

Repository: pmbstyle/L2Solo

Length of output: 202


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(database/sql/sqlite\.sql|.*(migration|schema|sqlite).*\.(js|ts|sql))$' | head -200
printf '%s\n' '--- schema context ---'
sed -n '150,215p' database/sql/sqlite.sql
printf '%s\n' '--- schema runner references ---'
rg -n -C 5 'database/sql/sqlite\.sql|sqlite\.sql|CREATE INDEX IF NOT EXISTS|executescript|migration' . \
  --glob '*.js' --glob '*.ts' --glob '*.mjs' --glob '*.cjs' --glob '*.sql' --glob '*.json' \
  | head -300
printf '%s\n' '--- SQLite tooling ---'
command -v sqlite3 || true
sqlite3 --version 2>/dev/null || true

Repository: pmbstyle/L2Solo

Length of output: 27493


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- migration implementation ---'
sed -n '175,215p' src/Database.js
printf '%s\n' '--- runtime declarations ---'
rg -n -C 4 '"engines"|node:sqlite|better-sqlite|sqlite' package.json package-lock.json npm-shrinkwrap.json 2>/dev/null | head -200 || true
printf '%s\n' '--- behavioral SQLite probe ---'
python3 - <<'PY'
import sqlite3

print("sqlite_version", sqlite3.sqlite_version)
con = sqlite3.connect(":memory:")
for sql in [
    "SELECT json_extract('{\"probe\":1}', '$.probe')",
    "SELECT json_valid('{\"probe\":1}')",
    "SELECT json_valid('{bad}')",
]:
    try:
        print(sql, "=>", con.execute(sql).fetchone()[0])
    except Exception as e:
        print(sql, "=> ERROR:", type(e).__name__, str(e))

con.executescript("""
CREATE TABLE bot_life_state (
    phase TEXT NOT NULL DEFAULT 'cold',
    partyId TEXT,
    activity TEXT NOT NULL DEFAULT 'hunting',
    spotId TEXT,
    statsJson TEXT
);
INSERT INTO bot_life_state(statsJson) VALUES
    ('{\"partyRequest\":{\"status\":\"open\",\"priority\":3}}'),
    (NULL),
    ('{bad}');
""")
for name, expression in [
    ("party_request_filter", """phase, partyId, activity,
        json_extract(statsJson, '$.partyRequest.status'),
        json_extract(statsJson, '$.partyRequest.priority')"""),
    ("party_objective_spot", """phase, partyId, activity,
        COALESCE(
            json_extract(statsJson, '$.partyRequest.spotId'),
            json_extract(statsJson, '$.equipmentPlan.next.spotId'),
            spotId
        )"""),
]:
    try:
        con.execute(f"CREATE INDEX {name} ON bot_life_state({expression})")
        print("CREATE INDEX", name, "=> success")
    except Exception as e:
        print("CREATE INDEX", name, "=> ERROR:", type(e).__name__, str(e))
PY

Repository: pmbstyle/L2Solo

Length of output: 12506


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- statsJson writes and JSON guards ---'
rg -n -C 4 'statsJson|json_valid|json_extract|JSON\.parse' src tests database --glob '*.js' --glob '*.sql' | head -400
printf '%s\n' '--- startup order ---'
sed -n '235,270p' src/Database.js
printf '%s\n' '--- schema migration metadata references ---'
rg -n -C 5 'schema_migrations|applySchemaMigrations|connection\.exec\(fs\.readFileSync' src scripts tests --glob '*.js'

Repository: pmbstyle/L2Solo

Length of output: 40524


Validate legacy statsJson before creating these indexes.

Database.init() creates these indexes before applySchemaMigrations(). If any existing non-null statsJson value is malformed, SQLite raises malformed JSON and initialization fails. Move index creation into a migration that repairs or rejects invalid rows first, or clean the data before loading database/sql/sqlite.sql.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@database/sql/sqlite.sql` around lines 181 - 199, The JSON-expression indexes
bot_life_state_party_request_filter and bot_life_state_party_objective_spot are
created before legacy statsJson validation, causing initialization to fail on
malformed data. Remove these index definitions from the initial schema load and
create them in an appropriate schema migration after repairing or rejecting
invalid non-null statsJson rows; preserve both index expressions for validated
data.


CREATE TABLE IF NOT EXISTS bot_goal_state (
characterId INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
Expand Down
61 changes: 55 additions & 6 deletions src/GameServer/Bot/AI/GearAcquisitionPlanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ function combatReadiness(state = {}) {

return {
role,
hasWeapon: Boolean(weapon),
armorCount: armor.length,
weaponRank,
armorRank,
effectiveLevel: Math.max(1, Number(state.level || 1))
Expand Down Expand Up @@ -451,13 +453,49 @@ function soloSafeForSource(state = {}, source = {}) {
// A target can be much stronger than the average of a mixed-level grid.
// Safety must be evaluated against the NPC that actually drops the item,
// not against incidental low-level mobs around it.
return combatReadiness(state).effectiveLevel >= Number(source.npcLevel || source.spotLevel || Infinity) + 2;
return partyNeedForSource(state, source) === 'solo_ok';
}

function partyNeedAssessmentForSource(state = {}, source = {}) {
const readiness = combatReadiness(state);
const targetLevel = Number(source?.npcLevel || source?.spotLevel || Infinity);
const margin = readiness.effectiveLevel - targetLevel;

// A support with no weapon/armour cannot be treated as a safe solo farmer,
// even when the level arithmetic happens to look favourable. This is a
// hard party need, while a normally equipped bot near the target level can
// still progress alone and merely advertise a preferred party.
const unpreparedSupport = ['healer', 'buffer'].includes(readiness.role)
&& readiness.armorCount < 2;
if (!readiness.hasWeapon) return { need: 'required', reason: 'missing_weapon' };
if (unpreparedSupport) return { need: 'required', reason: 'unprepared_support' };
if (margin < -2) return { need: 'required', reason: 'underleveled' };
if (margin < 0) return { need: 'preferred', reason: 'tight_level_margin' };
return { need: 'solo_ok', reason: 'solo_ready' };
}

function partyNeedForSource(state = {}, source = {}) {
return partyNeedAssessmentForSource(state, source).need;
}

function partyNeedReasonForSource(state = {}, source = {}) {
return partyNeedAssessmentForSource(state, source).reason;
}

function bestSourceForState(sources = [], state = {}) {
return sources.find((source) => soloSafeForSource(state, source)) || sources[0] || null;
}

function safeFallbackForPlan(state = {}, plan = {}, spots = []) {
if (!plan || !['active', 'blocked'].includes(plan.status)) return null;
const itemId = plan.strategy === 'direct_drop'
? Number(plan.target?.selfId || 0)
: Number(plan.next?.itemId || 0);
if (!itemId) return null;
return sourceForItem(itemId, spots, state)
.find((source) => partyNeedForSource(state, source) === 'solo_ok') || null;
}

function sourceIndexFor(spots = []) {
const rewards = DataCache.npcRewards || [];
if (sourceIndexCache.spots === spots && sourceIndexCache.rewards === rewards) {
Expand Down Expand Up @@ -578,6 +616,7 @@ function planFor(state = {}, options = {}) {
const offer = marketOfferForTarget(target, state, options);
const directKills = source ? 1 / Math.max(source.expectedYield, 0.000001) : Infinity;
const buy = offer && marketEffort(offer, state) <= directKills;
const sourceAssessment = source ? partyNeedAssessmentForSource(state, source) : null;
return target && buy ? {
status: 'active', phase: GearLifecycle.phaseFor(state), grade: 'none', role: roleFor(state), strategy: 'market', soloSafe: true, requiresParty: false,
rateModelVersion: RATE_MODEL_VERSION,
Expand All @@ -586,7 +625,10 @@ function planFor(state = {}, options = {}) {
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),
status: 'active', grade: 'none', role: roleFor(state), strategy: 'direct_drop', soloSafe: sourceAssessment.need === 'solo_ok',
partyNeed: sourceAssessment.need,
partyNeedReason: sourceAssessment.reason,
requiresParty: sourceAssessment.need === 'required',
rateModelVersion: RATE_MODEL_VERSION,
expectedKills: Math.ceil(1 / Math.max(source.expectedYield, 0.000001)),
target: { selfId: Number(target.selfId), name: target.template?.name || `Item ${target.selfId}`, slot: Number(target.etc?.slot || 0) },
Expand Down Expand Up @@ -617,7 +659,8 @@ function planFor(state = {}, options = {}) {
: Infinity;
const offer = marketOfferForTarget(target.item, state, options);
const buy = offer && marketEffort(offer, state) <= Math.min(directKills, craftKills);
const soloSafe = direct && soloSafeForSource(state, direct);
const directAssessment = direct ? partyNeedAssessmentForSource(state, direct) : null;
const soloSafe = direct && directAssessment.need === 'solo_ok';
const strategy = buy ? 'market'
: direct && (!target.recipe || soloSafe && directKills <= craftKills * 0.8) ? 'direct_drop'
: target.recipe ? 'craft' : 'blocked';
Expand All @@ -635,8 +678,12 @@ function planFor(state = {}, options = {}) {
// A ready final recipe or component is a station action, not a request to
// fight at the next (possibly unsafe) material source. Let it leave the
// party gate and finish the prepared manufacture first.
const requiresParty = !readyToCraft && !componentReady
&& Boolean(next && !soloSafeForSource(state, next));
const nextAssessment = !readyToCraft && !componentReady && next
? partyNeedAssessmentForSource(state, next)
: { need: 'solo_ok', reason: 'solo_ready' };
const partyNeed = nextAssessment.need;
const partyNeedReason = nextAssessment.reason;
const requiresParty = partyNeed === 'required';

return {
status: readyToCraft ? 'ready_to_craft' : componentReady ? 'component_ready' : strategy === 'market' || next ? 'active' : 'blocked',
Expand All @@ -648,6 +695,8 @@ function planFor(state = {}, options = {}) {
recipeId: target.recipe ? Number(target.recipe.recipeId) : null,
strategy,
soloSafe,
partyNeed,
partyNeedReason,
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,
Expand Down Expand Up @@ -677,4 +726,4 @@ function sameObjective(left, right) {
);
}

module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, roleFor, itemScore, isRealCatalogItem, suitable, isSlotUpgrade, combatReadiness, progressionPriceCap, equipInventoryUpgrades, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, soloSafeForSource, bestSourceForState, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective };
module.exports = { RATE_MODEL_VERSION, gradeForLevel, isCraftService, roleFor, itemScore, isRealCatalogItem, suitable, isSlotUpgrade, combatReadiness, progressionPriceCap, equipInventoryUpgrades, preferredTarget, preferredDropTarget, preferredNoGradeTarget, marketOfferForTarget, itemDropChance, itemDropYield, partyNeedForSource, partyNeedReasonForSource, soloSafeForSource, bestSourceForState, safeFallbackForPlan, sourceForItem, farmSourceForMaterial, missingMaterials, planFor, shouldFinishPreviousPlan, scoreSpot, sameObjective };
1 change: 1 addition & 0 deletions src/GameServer/Bot/Economy/CraftTelemetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function progressEvents(before = {}, plan = {}, after = {}) {
}

function stationTravelEvent(state, travel = {}) {
travel = travel || {};
return {
type: 'craft_station_travel',
summary: `${state.name} is traveling to ${travel.stationId} to ${travel.reason === 'component_craft' ? 'craft a component' : 'craft equipment'}`,
Expand Down
21 changes: 19 additions & 2 deletions src/GameServer/Bot/Population/BackgroundPartyState.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const Database = invoke('Database');
const Config = invoke('GameServer/Bot/Population/PopulationConfig');

const TABLE = 'bot_background_parties';
const cache = new Map();
Expand All @@ -23,19 +24,35 @@ function parseJson(raw, fallback) {
}
}

function rotationExpiry(partyId, startedAt) {
const maxAge = Math.max(0, Number(Config.partySessionMaxMs) || 0);
const jitter = Math.min(maxAge, Math.max(0, Number(Config.partySessionJitterMs) || 0));
if (!maxAge || !startedAt) return 0;
let hash = 0;
for (const char of String(partyId || '')) hash = ((hash * 31) + char.charCodeAt(0)) | 0;
const span = jitter * 2 + 1;
const offset = jitter ? Math.abs(hash) % span - jitter : 0;
return Number(startedAt) + maxAge + offset;
}

function normalize(row) {
const startedAt = Number(row.startedAt || 0);
const stats = parseJson(row.statsJson, {});
if (row.status === 'active' && startedAt && !Number(stats.sessionExpiresAt || 0)) {
stats.sessionExpiresAt = rotationExpiry(row.partyId, startedAt);
}
return {
partyId: row.partyId || '',
leaderId: Number(row.leaderId || 0),
memberIds: parseJson(row.memberIdsJson, []).map((id) => Number(id)).filter(Boolean),
spotId: row.spotId || null,
startedAt: Number(row.startedAt || 0),
startedAt,
nextResolveAt: row.nextResolveAt ? Number(row.nextResolveAt) : null,
cohesion: Number(row.cohesion || 0),
risk: Number(row.risk || 0),
status: row.status || 'active',
roleCoverage: parseJson(row.roleCoverageJson, {}),
stats: parseJson(row.statsJson, {}),
stats,
updatedAt: Number(row.updatedAt || 0)
};
}
Expand Down
Loading
Loading