VoidVOID
DocsBot Chat

Bot Chat

Bots reply to nearby players' public chat and Quick Chat, mostly with built-in Quick Chat phrases and sometimes by typing. Players can type anything (up to 80 characters), bots work out what was meant and pick a fitting phrase based on the conversation so far, what they're doing and their personality.

Overview

"whats ur minnig lvl?"
         │
  ┌──────▼──────┐   slang, stretched words, smileys
  │ Normaliser  │   -> [what, is, your, minnig, level, ?]
  └──────┬──────┘
  ┌──────▼──────┐   items, npcs, skills, locations, quests, minigames
  │EntityTagger │   -> [what, is, your, {skill}, level, ?] + Skill=mining
  └──────┬──────┘
  ┌──────▼──────┐   fastText-style classifier
  │ IntentModel │   -> ask_level (0.99)
  └──────┬──────┘
  ┌──────▼──────┐   follow ups, pending yes/no questions, annoyance, topic
  │Conversation │
  └──────┬──────┘
  ┌──────▼──────┐   botChat("ask_level") { say(...) } handlers
  │ BotChatApi  │   -> weighted candidates, adjusted by persona
  └──────┬──────┘
  ┌──────▼──────┐   phrase text -> id, slot values -> enum indices
  │QuickChat    │   -> QuickChatPublic(phrase = 12, data = [])
  │Phrases      │
  └─────────────┘
         │
Bot: "My Mining level is 45."

The only machine learnt part is the intent classifier, everything else is rules and data, so it's straightforward to see why a bot said something and to change it.

Runtime (game/src/main/kotlin/content/bot/chat/)

FilePurpose
BotChatModel.ktLoads the examples, normaliser and entity tagger on startup, retraining the intent model if its training data changed.
BotChat.ktScript with the entry point BotChat.heard(player, text, phrase) called from public chat and quick chat. Picks which bot replies and sends it after a short "typing" delay.
QuickChatIntents.ktMaps quick chat phrases straight to intents using the quick_chat patterns in the intents files.
Normaliser.ktLowercases, expands slang (u → you, im → i am), turns smileys into tokens and collapses stretched words (heyyyy → hey).
EntityTagger.ktFinds entity names and replaces them with placeholders like {item}.
IntentModel.ktRuns the intent classifier and reads/writes the cached model.
IntentTrainer.ktTrains the intent classifier.
ChatProcessor.ktNormaliser → EntityTagger → IntentModel, producing an Utterance.
Conversation.ktShort-term memory for each bot/player pair.
Persona.ktPersonality derived from the bot's account name.
BotChatApi.ktScript interface for registering replies.
ChatContext.ktReceiver for reply handlers: say, silence, asking, slot, activity.
QuickChatPhrases.ktLooks up phrases by text and encodes slot values into quick chat data.

Who replies

When a player talks, only one bot replies:

  1. Whose name was mentioned
  2. The player spoke with in the last 30 seconds
  3. Closest within 6 tiles, chattiness dependent.

Bots never reply to other bots, and ignore players on their ignore list.

If the wrong bot replies the player can say "not you" (not_you intent), the bot apologises and is skipped for a minute, or until the player talks to it by name.

Entities

The entity tagger is built at startup from game data, so new content is understood without retraining:

TypeSource
Skillchat_skills table (row id + aka)
LocationQuick chat location enum 1504 + locations table aka
MinigameQuick chat minigame enum 1503
Questquests.toml names
ItemItem definition names + aka
NpcNPC definition names + aka
NumberAny number

Aliases are matched longest first, then by type priority (the order above). Skills, locations, minigames and quests allow a one letter typo (minnig, varrok), items and npcs only match exactly (or plural) as there are too many for fuzzy matching to be safe. Words from the training data are never fuzzy matched so "share" doesn't become "shark", and single words too common in chat can be excluded in chat_stop_words.

Intent model

A fastText style linear classifier:

  • Features are hashed into 65,536 buckets: words, word pairs (keeps "your level" vs "my level") and 3-5 letter character n-grams (so typos land near the correct word)
  • Features are averaged into a 32 dimension vector which a linear layer maps to intent probabilities
  • Cached as gzipped half-precision floats in data/.temp/bot_chat.model (setting bots.chat.model), see Training

Messages classified with less than 40% confidence are handled by the unknown intent, where Quick Chat itself provides the perfect excuse: "I can't answer that on Quick Chat."

Quick chat

Quick chat phrases have a fixed meaning so they skip the model, each intent lists the phrases it covers with * wildcards:

[ask_level]
quick_chat = ["What is your level in *?"]

The phrase text is still tagged for entities, so "What is your level in Mining?" gives ask_level with Skill=mining. Phrases without a pattern fall back to the model. BotChatTest checks every pattern matches at least one phrase.

Conversations

Each bot/player pair has a Conversation which remembers:

  • The last 8 turns
  • The current topic (last entity of each type), so "and wc?" after "whats your mining level" reuses the previous question with the new skill
  • A pending yes/no question, "Do you need help?" -> "yes"
  • Annoyance from insults or repeated messages, escalating to annoyed and eventually the ignore list
  • The number of separate sessions (5 minutes apart), "Nice to meet you." vs "Welcome back."

Personas

Every bot has a Persona derived from its account name, so it stays the same between logins:

  • friendliness — Warm vs Curt replies
  • helpfulness — agreeing to requests
  • chattiness — joining in when not addressed, laughing along
  • slang — "np", "lol" vs "No problem.", "Haha!"
  • patience — rude messages tolerated before ignoring
  • typing — typed replies vs sticking to quick chat

Writing replies

Replies are registered in scripts implementing BotChatApi:

class SocialChat : Script, BotChatApi {
    init {
        botChat("thanks") {
            say("You're welcome.", style = Style.Formal)
            say("No problem.", style = Style.Formal)
            say("np", style = Style.Slang)
        }
    }
}

Every say adds a weighted candidate and one is picked at random. style scales the weight by the bot's persona, e.g. Style.Slang is twice as likely for a bot with slang = 1.0 and never picked with slang = 0.0.

type adds a reply typed in normal chat instead of quick chat, useful when no phrase fits. Its weight is also scaled by the persona's typing:

botChat("are_you_bot") {
    say("I can only use Quick Chat.")
    type("no lol", style = Style.Slang)
}

Multiple handlers can respond to the same intent, content can add to replies without touching existing scripts:

botChat("greet") {
    if (now.month == Month.DECEMBER && now.dayOfMonth in 20..26) {
        say("Merry Christmas!", weight = 4f)
    }
}

Note

Phrases are the exact Quick Chat text with typed placeholders, as printed by ./gradlew :tools:model:dumpQuickChat. Unknown phrases are logged and skipped, BotChatTest checks every phrase used exists.

Slots

Placeholders are filled in different ways:

  • Numbers — <SkillLevel>, <CombatLevel>, <Varp> etc. are filled automatically from the bot's own stats when sent
  • <MultipleChoice> — pass a string id and it's matched against the enum values, e.g. "iron_ore" → iron, "seers_village" → Seers' Village
  • <AllItems>/<TradeItems> — pass an item id
botChat("ask_level") {
    val skill = slot(SlotType.Skill) ?: return@botChat
    say("My ${skill.key.replaceFirstChar { it.uppercase() }} level is <SkillLevel>.")
}
botChat("level_up") {
    val skill = utterance.first(SlotType.Skill) ?: return@botChat
    say("Nice level in: <MultipleChoice>.", skill.key)
}

slot(type) returns the entity from this message, otherwise the conversation topic. utterance.first(type) only checks this message.

Context available

PropertyDescription
bot / speakerThe bot and the player who spoke
utteranceText, tokens, intent, confidence and entities
conversationTurns, topic, sessions, annoyance
personaThe bot's personality
activityThe behaviour of the bot's current activity (skill/product read from its produces)
destinationArea the bot is walking to (from its running go_to action), use ChatLocations.nearest(area) for a quick chat location
stuckWalking somewhere but hasn't moved in 20 ticks
nowReal date and time

Questions and side effects

asking waits for the player's yes or no:

botChat("lost") {
    asking("offer_help", yes = { say("Follow me.") }, no = { say("Okay.") }) {
        say("Do you need help?")
    }
}

then runs only if that candidate is the one said:

say("I have added you to my ignore list.", then = { bot.ignores.add(speaker.accountName) })

Knowledge tables (data/bot/chat/)

TableContents
chat_slang, chat_smileysNormalisation, row id is the replacement ([.i_am] words = ["im"])
chat_stop_wordsSingle words never treated as items/npcs
chat_skillsSkill aka, activity phrase, advice phrase with spots [level, enum value], and tips [level, phrase]
locationsLocation aka and tile used for directions (also used by tele)
chat_item_sourcesWhere to get items

Advice is based on the asker's level, and bots choose between the two best spots so they don't all say the same thing.

Training

The model is trained from example messages in data/bot/chat/bot_chat.intents.toml (any *.intents.toml file, so content can keep its own examples):

[ask_level]
examples = [
    "what is your mining level",
    "whats ur wc lvl",
    "you got 99 fishing?",
]

Examples use real names, they're run through the same Normaliser and EntityTagger as live chat, so the model learns {skill} rather than individual skills.

Automatic retraining

The trained model is cached in data/.temp/bot_chat.model along with a fingerprint of its training data. On startup the examples are normalised, tagged and hashed (~20ms) along with the trainer settings. If the fingerprint doesn't match the cache the model is retrained (< 1 second) and saved, otherwise the cached model is loaded (~10ms).

This catches everything which affects training:

  • Examples added, removed or edited
  • Slang or smiley table changes
  • New entity names that change how an example is tagged (e.g. an item called "Will" turning "will" into {item})
  • Trainer settings, or IntentTrainer.VERSION bumped for code changes

New content which doesn't appear in the examples doesn't trigger retraining, the model already understands {item}.

Training is seeded so the same examples always produce the same model on every machine.

BotChatTest holds out 15% of the examples, trains on the rest and fails if accuracy drops below 80%, so changes which hurt understanding are caught in CI.

Tools (tools/model)

  • ./gradlew :tools:model:evaluateBotChat — accuracy on held-out examples with every mistake listed, plus training words being tagged as items/npcs (candidates for chat_stop_words)
  • ./gradlew :tools:model:botChatConsole — type messages and see the intent, confidence and entities
  • ./gradlew :tools:model:dumpQuickChat — print every Quick Chat phrase and its enum options

Adding an intent

  1. Add a [new_intent] section with examples
  2. Run evaluateBotChat and check the accuracy
  3. Add a botChat("new_intent") { ... } handler