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
32 changes: 32 additions & 0 deletions database/sql/sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,20 @@ CREATE TABLE IF NOT EXISTS bot_goal_state (
updatedAt INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS bot_personas (
characterId INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE,
version INTEGER NOT NULL DEFAULT 1,
seed TEXT NOT NULL,
primaryDrive TEXT NOT NULL,
archetype TEXT NOT NULL,
traitsJson TEXT NOT NULL,
textCard TEXT NOT NULL DEFAULT '',
createdAt INTEGER NOT NULL DEFAULT 0,
updatedAt INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS bot_personas_primaryDrive ON bot_personas(primaryDrive);
CREATE INDEX IF NOT EXISTS bot_personas_archetype ON bot_personas(archetype);

CREATE TABLE IF NOT EXISTS bot_social_memory (
playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
Expand All @@ -206,6 +220,24 @@ CREATE TABLE IF NOT EXISTS bot_social_memory (
PRIMARY KEY(playerId, botId)
);

CREATE TABLE IF NOT EXISTS bot_friendships (
playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'accepted',
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
PRIMARY KEY (playerId, botId)
);
CREATE INDEX IF NOT EXISTS bot_friendships_player ON bot_friendships(playerId, status);

CREATE TABLE IF NOT EXISTS bot_friend_roster (
playerId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
botId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
selectedAt INTEGER NOT NULL,
PRIMARY KEY (playerId, botId)
);
CREATE INDEX IF NOT EXISTS bot_friend_roster_player ON bot_friend_roster(playerId, selectedAt);

CREATE TABLE IF NOT EXISTS bot_life_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
characterId INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
Expand Down
4 changes: 2 additions & 2 deletions scripts/migrate-mariadb-to-sqlite.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const rootDir = path.resolve(__dirname, '..');
const TABLE_ORDER = [
'accounts', 'characters', 'clans', 'clan_crests', 'items',
'character_recipes', 'character_quests', 'warehouse_items', 'skills',
'shortcuts', 'macros', 'bot_life_state', 'bot_goal_state',
'shortcuts', 'macros', 'bot_life_state', 'bot_goal_state', 'bot_personas',
'bot_social_memory', 'bot_life_events', 'bot_background_parties'
];
const SEQUENCE_TABLES = ['characters', 'clans', 'clan_crests', 'items', 'warehouse_items', 'bot_life_events'];
Expand Down Expand Up @@ -97,7 +97,7 @@ function normalizeValue(value) {
function sourceSelect(table) {
const characterChildren = new Set([
'items', 'character_recipes', 'character_quests', 'warehouse_items',
'skills', 'shortcuts', 'macros', 'bot_life_state', 'bot_goal_state', 'bot_life_events'
'skills', 'shortcuts', 'macros', 'bot_life_state', 'bot_goal_state', 'bot_personas', 'bot_life_events'
]);
if (characterChildren.has(table)) {
return `SELECT source.* FROM \`${table}\` source INNER JOIN \`characters\` character_ref ON character_ref.id = source.characterId`;
Expand Down
9 changes: 9 additions & 0 deletions scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ const tests = [
'tests/test_spot_profile_state_priority.js',
'tests/test_population_starter_party_grouping.js',
'tests/test_bot_goal_state.js',
'tests/test_bot_persona.js',
'tests/test_bot_persona_background_intent.js',
'tests/test_bot_persona_economic_policy.js',
'tests/test_wealth_investment_policy.js',
'tests/test_bot_spot_risk_baseline.js',
'tests/test_bot_persona_party_decision.js',
'tests/test_bot_remote_chat_persona.js',
'tests/test_bot_friendship.js',
'tests/test_bot_goal_planner.js',
'tests/test_bot_goal_market_priority.js',
'tests/test_bot_market_goal_reconcile.js',
Expand All @@ -34,6 +42,7 @@ const tests = [
'tests/test_bot_craft_telemetry.js',
'tests/test_bot_cold_market_purchase.js',
'tests/test_bot_cold_market_listing.js',
'tests/test_bot_static_buyer_sale.js',
'tests/test_bot_market_town_routing.js',
'tests/test_bot_craft_shop.js',
'tests/test_bot_warehouse.js',
Expand Down
2 changes: 1 addition & 1 deletion scripts/world-wipe.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function wipeWithConnection(db, scope) {
try {
if (normalizedScope === 'all') {
[
'bot_life_events', 'bot_life_state', 'bot_goal_state', 'bot_social_memory',
'bot_life_events', 'bot_life_state', 'bot_goal_state', 'bot_personas', 'bot_social_memory',
'character_recipes', 'character_quests', 'warehouse_items', 'macros',
'shortcuts', 'skills', 'items', 'bot_background_parties', 'clan_crests', 'clans'
].forEach((table) => db.exec(`DELETE FROM ${table}`));
Expand Down
66 changes: 49 additions & 17 deletions src/GameServer/Bot/AI/BotAvailability.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const BotSocialMemory = invoke('GameServer/Bot/AI/BotSocialMemory');
const PersonaPartyDecisionPolicy = invoke('GameServer/Bot/AI/PersonaPartyDecisionPolicy');
const SpeckMath = invoke('GameServer/SpeckMath');
const Config = invoke('GameServer/Bot/Population/PopulationConfig');

Expand Down Expand Up @@ -30,6 +31,7 @@ function reasonText(reason) {
low_trust: 'low trust',
recently_abandoned: 'recently abandoned',
level_gap_too_large: 'level gap too large',
prefers_solo: 'prefers a solo run for now',
hunting_target: 'busy fighting'
};
return text[reason] || reason;
Expand All @@ -47,6 +49,18 @@ function sameClan(player, botSubject) {
return playerClanId === clanIdOf(botSubject);
}

function isStaticService(subject = {}) {
const stats = subject?.stats || subject?.coldCraftState?.stats || {};
if (stats.craftStationId || stats.craftShop || subject?.manufactureShop) return true;

// Permanent private-store bots do not have a cold state. A market store
// or craft state marks an adventurer that may still be called by a friend.
return subject?.plan === 'merchant'
&& !subject?.coldMarketState
&& !subject?.coldCraftState
&& !subject?.coldLifeState;
}

function emptyResult(playerSession, botSubject) {
const memory = BotSocialMemory.getSnapshot(playerSession, botSubject);
return {
Expand All @@ -63,7 +77,7 @@ function emptyResult(playerSession, botSubject) {
const BotAvailability = {
inviteRange: Config.partyInviteRange,

evaluate(playerSession, botSession) {
evaluate(playerSession, botSession, options = {}) {
const player = playerSession?.actor;
const bot = botSession?.actor;
const result = emptyResult(playerSession, botSession);
Expand All @@ -72,45 +86,63 @@ const BotAvailability = {

result.distance = distance(actorLocation(player), actorLocation(bot));
result.clanmate = sameClan(player, bot);
const staticService = isStaticService(botSession);

let reason = 'available';
if (result.clanmate) reason = 'available';
else if (player.isDead && player.isDead()) reason = 'player_dead';
else if (bot.isDead && bot.isDead()) reason = 'bot_dead';
else if (botSession.plan === 'merchant') reason = 'merchant_duty';
else if (botSession.partyCompanion === true && botSession.followPlayerSession) reason = 'already_grouped';
else if (result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
else if (result.memory.trust <= -6) reason = 'low_trust';
else if (result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
else if (Math.abs(bot.fetchLevel() - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';

else if (staticService) reason = 'merchant_duty';
else if (!options.forceFriend && botSession.plan === 'merchant') reason = 'merchant_duty';
else if (!options.forceFriend && botSession.partyCompanion === true && botSession.followPlayerSession) reason = 'already_grouped';
else if (!options.ignoreDistance && result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
else if (!options.forceFriend && result.memory.trust <= -6) reason = 'low_trust';
else if (!options.forceFriend && result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
else if (!options.forceFriend && Math.abs(bot.fetchLevel() - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';

if (reason === 'available' && !result.clanmate && !options.forceFriend) {
result.partyDecision = PersonaPartyDecisionPolicy.evaluate(botSession, result.memory);
if (!result.partyDecision.accept) {
reason = result.partyDecision.reason;
}
}
result.available = reason === 'available';
result.reason = reason;
result.reasonText = reasonText(reason);
result.reasonText = result.partyDecision?.reason === reason
? result.partyDecision.reasonText : reasonText(reason);
return result;
},

evaluateState(playerSession, state) {
evaluateState(playerSession, state, options = {}) {
const player = playerSession?.actor;
const result = emptyResult(playerSession, state);
if (!player || !state) return result;

result.distance = distance(actorLocation(player), state.loc);
result.clanmate = sameClan(player, state);
const staticService = isStaticService(state);

let reason = 'available';
if (result.clanmate) reason = 'available';
else if (player.isDead && player.isDead()) reason = 'player_dead';
else if (state.activity === 'dead' || Number(state.vitals?.hp || 1) <= 0) reason = 'bot_dead';
else if (state.activity === 'merchant' || state.activity === 'crafting') reason = 'merchant_duty';
else if (result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
else if (result.memory.trust <= -6) reason = 'low_trust';
else if (result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
else if (Math.abs(Number(state.level || 1) - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';

else if (staticService) reason = 'merchant_duty';
else if (!options.forceFriend && (state.activity === 'merchant' || state.activity === 'crafting')) reason = 'merchant_duty';
else if (!options.ignoreDistance && result.distance !== null && result.distance > Config.partyInviteRange) reason = 'too_far';
else if (!options.forceFriend && result.memory.trust <= -6) reason = 'low_trust';
else if (!options.forceFriend && result.memory.recentlyAbandonedAt && Date.now() - result.memory.recentlyAbandonedAt < RECENT_ABANDON_MS) reason = 'recently_abandoned';
else if (!options.forceFriend && Math.abs(Number(state.level || 1) - player.fetchLevel()) > MAX_LEVEL_GAP) reason = 'level_gap_too_large';

if (reason === 'available' && !result.clanmate && !options.forceFriend) {
result.partyDecision = PersonaPartyDecisionPolicy.evaluate(state, result.memory);
if (!result.partyDecision.accept) {
reason = result.partyDecision.reason;
}
}
result.available = reason === 'available';
result.reason = reason;
result.reasonText = reasonText(reason);
result.reasonText = result.partyDecision?.reason === reason
? result.partyDecision.reasonText : reasonText(reason);
return result;
},

Expand Down
1 change: 1 addition & 0 deletions src/GameServer/Bot/AI/BotBrain.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function systemPrompt() {
'follow_player only means approach a visible player unless the bot is already an invited party companion.',
'For buff_target and heal_target, choose a visible player and let the server validate class, learned skill, MP, range, and safety.',
'Do not claim that buffs or heals are ready in a plain chat reply. Use buff_target or heal_target; only the validated server action may confirm a cast.',
'The persona describes tone and high-level preferences only. It never overrides safety, current game state, or the allowed actions.',
'Do not offer trading, selling, price negotiation, or private stores; those tools are intentionally unavailable for now.',
'Never invent unavailable actions, players, items, or spells.'
].join(' ');
Expand Down
1 change: 1 addition & 0 deletions src/GameServer/Bot/AI/BotBrainContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ function compactStatus(session, status, text = '') {
inventory: inventorySnapshot(actor, text),
skills: skillsSnapshot(actor, text),
roleDecision: status.roleDecision || null,
persona: status.persona || null,
social: status.social || null
};
}
Expand Down
49 changes: 40 additions & 9 deletions src/GameServer/Bot/AI/BotConversation.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,40 @@ function areaFor(session) {
return session?.botStatus?.home?.region || session?.homeRegion || session?.spotId || 'town';
}

function personaFor(session) {
return session?.persona?.traits ? session.persona : null;
}

function trait(session, name, fallback = 0.5) {
const value = Number(personaFor(session)?.traits?.[name]);
return Number.isFinite(value) ? value : fallback;
}

function roleLine(session) {
const role = String(session?.botStatus?.role || session?.role || '').toLowerCase();
if (role === 'healer' || role === 'buffer') return 'I will keep an eye on everyone\'s health.';
if (role === 'tank') return 'I can take the first hits if things get rough.';
if (role === 'archer') return 'I will keep some distance and watch the edges.';
if (role === 'dagger') return 'I will look for a clean opening behind them.';
return 'I could use a steadier run than the last one.';
let line = 'I could use a steadier run than the last one.';
if (role === 'healer' || role === 'buffer') line = 'I will keep an eye on everyone\'s health.';
else if (role === 'tank') line = 'I can take the first hits if things get rough.';
else if (role === 'archer') line = 'I will keep some distance and watch the edges.';
else if (role === 'dagger') line = 'I will look for a clean opening behind them.';

if (trait(session, 'empathy') >= 0.78) return `${line} No one gets left behind.`;
if (trait(session, 'assertiveness') >= 0.78) return `${line} Just give the word.`;
return line;
}

function restOpener(session, area) {
const drive = personaFor(session)?.primaryDrive;
if (drive === 'wealth') return `Quiet around ${area} for once. A steady run could pay for the next upgrade.`;
if (drive === 'social') return `Quiet around ${area} for once. It is better to head out with familiar company.`;
if (drive === 'progression') return `Quiet around ${area} for once. Ready to make the next run count?`;
return `Quiet around ${area} for once. Heading out when you are recovered?`;
}

function restCloser(session) {
if (trait(session, 'caution') >= 0.70) return 'Sounds good. We can keep it steady and avoid rushing back in.';
if (trait(session, 'sociability') >= 0.70) return 'Sounds good. Better than rushing back in alone.';
return 'Sounds good. Better than rushing back in alone.';
}
Comment on lines +38 to 42

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the dead sociability branch in restCloser.

The trait(session, 'sociability') >= 0.70 branch on Line 40 returns the same string as the default branch on Line 41. This branch can never produce a different result than omitting it, unlike restOpener and roleLine, which each vary text per trait or drive.

Give the sociability branch distinct text, consistent with the drive/trait-based variety used elsewhere in this file.

🐛 Proposed fix
 function restCloser(session) {
     if (trait(session, 'caution') >= 0.70) return 'Sounds good. We can keep it steady and avoid rushing back in.';
-    if (trait(session, 'sociability') >= 0.70) return 'Sounds good. Better than rushing back in alone.';
+    if (trait(session, 'sociability') >= 0.70) return 'Sounds good. It is better with company than rushing back in alone.';
     return 'Sounds good. Better than rushing back in alone.';
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function restCloser(session) {
if (trait(session, 'caution') >= 0.70) return 'Sounds good. We can keep it steady and avoid rushing back in.';
if (trait(session, 'sociability') >= 0.70) return 'Sounds good. Better than rushing back in alone.';
return 'Sounds good. Better than rushing back in alone.';
}
function restCloser(session) {
if (trait(session, 'caution') >= 0.70) return 'Sounds good. We can keep it steady and avoid rushing back in.';
if (trait(session, 'sociability') >= 0.70) return 'Sounds good. It is better with company than rushing back in alone.';
return 'Sounds good. Better than rushing back in alone.';
}
🤖 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 `@src/GameServer/Bot/AI/BotConversation.js` around lines 38 - 42, Update the
sociability branch in restCloser so trait(session, 'sociability') >= 0.70
returns distinct text from the default branch, preserving the existing caution
response and matching the trait-based conversational variety used by restOpener
and roleLine.


function chooseTopic(initiator, responder) {
Expand All @@ -21,16 +48,18 @@ function chooseTopic(initiator, responder) {

candidates.push({
id: 'rest',
opener: `Quiet around ${area} for once. Heading out when you are recovered?`,
opener: restOpener(initiator, area),
reply: roleLine(responder),
closer: 'Sounds good. Better than rushing back in alone.'
closer: restCloser(initiator)
});

if (responder?.botStatus?.role === 'tank' || initiator?.botStatus?.role === 'tank' ||
responder?.botStatus?.role === 'healer' || initiator?.botStatus?.role === 'healer') {
candidates.push({
id: 'party',
opener: `We have the right roles for a small group around ${area}.`,
opener: personaFor(initiator)?.primaryDrive === 'social'
? `We have the right roles for a small group around ${area}. It would be good to keep the team together.`
: `We have the right roles for a small group around ${area}.`,
reply: roleLine(responder),
closer: 'Then let us watch for someone who wants to join the next run.'
});
Expand All @@ -40,7 +69,9 @@ function chooseTopic(initiator, responder) {
candidates.push({
id: 'trade',
opener: `The market around ${area} has been busy. Did you find what you needed?`,
reply: 'Enough to get by. I would rather spend the next hour earning than browsing.',
reply: personaFor(responder)?.primaryDrive === 'wealth'
? 'Enough to get by. I would rather spend the next hour earning than browsing.'
: 'Enough to get by. I would rather spend the next hour fighting than browsing.',
closer: 'Same. A little more adena always makes the next trip easier.'
});
}
Expand Down
Loading
Loading