Initial commit of opening the AI game code.

Now that it is somewhat presentable.
This commit is contained in:
projectmoon 2023-11-07 13:53:42 +01:00
commit 00fe3650a8
31 changed files with 7117 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target
surreal.db/
todo.org
config.toml

2756
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
Cargo.toml Normal file
View File

@ -0,0 +1,36 @@
[package]
name = "ai-game"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tokio = { version = "1", features = ["full"] }
anyhow = "1.0.75"
futures = "0.3"
eventsource-client = "0.11.0"
progenitor = { git = "https://github.com/oxidecomputer/progenitor" }
progenitor-client = { git = "https://github.com/oxidecomputer/progenitor" }
reqwest = { version = "0.11", features = ["json", "stream"] }
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
async-trait = "0.1.74"
reedline = "0.27.1"
async-recursion = "1.0.5"
thiserror = "1.0.53"
strum = {version = "0.25", features = [ "derive" ] }
uuid = {version = "1.6.1", features = [ "std", "v7", "fast-rng" ] }
polodb_core = "4.4.0"
arangors = "0.5.4"
itertools = "0.12.0"
crossterm = "0.27.0"
textwrap = "0.16.0"
config = "0.13.4"
[build-dependencies]
prettyplease = "0.1.25"
progenitor = { git = "https://github.com/oxidecomputer/progenitor" }
progenitor-client = { git = "https://github.com/oxidecomputer/progenitor" }
serde_json = "1.0"
syn = "1.0"

18
build.rs Normal file
View File

@ -0,0 +1,18 @@
fn main() {
let src = "./koboldcpp.json";
println!("cargo:rerun-if-changed={}", src);
let file = std::fs::File::open(src).unwrap();
let spec = serde_json::from_reader(file).unwrap();
let mut generator = progenitor::Generator::default();
let tokens = generator.generate_tokens(&spec).unwrap();
let ast = syn::parse2(tokens).unwrap();
let content = prettyplease::unparse(&ast);
let mut out_file = std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).to_path_buf();
out_file.push("codegen.rs");
std::fs::write(out_file, content).unwrap();
}

BIN
game.db Normal file

Binary file not shown.

777
koboldcpp.json Normal file
View File

@ -0,0 +1,777 @@
{
"components": {
"schemas": {
"BasicError": {
"properties": {
"msg": {
"type": "string"
},
"type": {
"type": "string"
}
},
"required": [
"msg",
"type"
],
"type": "object"
},
"BasicResult": {
"properties": {
"result": {
"$ref": "#/components/schemas/BasicResultInner"
}
},
"required": [
"result"
],
"type": "object"
},
"BasicResultInner": {
"properties": {
"result": {
"type": "string"
}
},
"required": [
"result"
],
"type": "object"
},
"GenerationInput": {
"properties": {
"max_context_length": {
"description": "Maximum number of tokens to send to the model.",
"minimum": 1,
"type": "integer"
},
"max_length": {
"description": "Number of tokens to generate.",
"minimum": 1,
"type": "integer"
},
"prompt": {
"description": "This is the submission.",
"type": "string"
},
"rep_pen": {
"description": "Base repetition penalty value.",
"minimum": 1,
"type": "number"
},
"rep_pen_range": {
"description": "Repetition penalty range.",
"minimum": 0,
"type": "integer"
},
"sampler_order": {
"description": "Sampler order to be used. If N is the length of this array, then N must be greater than or equal to 6 and the array must be a permutation of the first N non-negative integers.",
"items": {
"type": "integer"
},
"minItems": 6,
"type": "array"
},
"sampler_seed": {
"description": "RNG seed to use for sampling. If not specified, the global RNG will be used.",
"maximum": 999999,
"minimum": 1,
"type": "integer"
},
"stop_sequence": {
"description": "An array of string sequences where the API will stop generating further tokens. The returned text WILL contain the stop sequence.",
"items": {
"type": "string"
},
"type": "array"
},
"temperature": {
"description": "Temperature value.",
"exclusiveMinimum": false,
"type": "number"
},
"tfs": {
"description": "Tail free sampling value.",
"maximum": 1,
"minimum": 0,
"type": "number"
},
"top_a": {
"description": "Top-a sampling value.",
"minimum": 0,
"type": "number"
},
"top_k": {
"description": "Top-k sampling value.",
"minimum": 0,
"type": "integer"
},
"top_p": {
"description": "Top-p sampling value.",
"maximum": 1,
"minimum": 0,
"type": "number"
},
"min_p": {
"description": "Min-p sampling value.",
"maximum": 1,
"minimum": 0,
"type": "number"
},
"typical": {
"description": "Typical sampling value.",
"maximum": 1,
"minimum": 0,
"type": "number"
},
"use_default_badwordsids": {
"default": false,
"description": "If true, prevents the EOS token from being generated (Ban EOS). For unbantokens, set this to false.",
"type": "boolean"
},
"mirostat": {
"description": "KoboldCpp ONLY. Sets the mirostat mode, 0=disabled, 1=mirostat_v1, 2=mirostat_v2",
"minimum": 0,
"maximum": 2,
"type": "number"
},
"mirostat_tau": {
"description": "KoboldCpp ONLY. Mirostat tau value.",
"exclusiveMinimum": false,
"type": "number"
},
"mirostat_eta": {
"description": "KoboldCpp ONLY. Mirostat eta value.",
"exclusiveMinimum": false,
"type": "number"
},
"genkey": {
"description": "KoboldCpp ONLY. A unique genkey set by the user. When checking a polled-streaming request, use this key to be able to fetch pending text even if multiuser is enabled.",
"type": "string"
},
"grammar": {
"description": "KoboldCpp ONLY. A string containing the GBNF grammar to use.",
"type": "string"
},
"grammar_retain_state": {
"default": false,
"description": "If true, retains the previous generation's grammar state, otherwise it is reset on new generation.",
"type": "boolean"
}
},
"required": [
"prompt"
],
"type": "object"
},
"GenerationOutput": {
"properties": {
"results": {
"description": "Array of generated outputs.",
"items": {
"$ref": "#/components/schemas/GenerationResult"
},
"type": "array"
}
},
"required": [
"results"
],
"type": "object"
},
"GenerationResult": {
"properties": {
"text": {
"description": "Generated output as plain text.",
"type": "string"
}
},
"required": [
"text"
],
"type": "object"
},
"MaxContextLengthSetting": {
"properties": {
"value": {
"minimum": 8,
"type": "integer"
}
},
"required": [
"value"
],
"type": "object"
},
"MaxLengthSetting": {
"properties": {
"value": {
"minimum": 1,
"type": "integer"
}
},
"required": [
"value"
],
"type": "object"
},
"ServerBusyError": {
"properties": {
"detail": {
"$ref": "#/components/schemas/BasicError"
}
},
"required": [
"detail"
],
"type": "object"
},
"ValueResult": {
"properties": {
"value": {
"type": "integer"
}
},
"required": [
"value"
],
"type": "object"
},
"KcppVersion": {
"properties": {
"result": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"version"
],
"type": "object"
},
"KcppPerf": {
"properties": {
"last_process": {
"type": "number",
"description": "Last processing time in seconds."
},
"last_eval": {
"type": "number",
"description": "Last evaluation time in seconds."
},
"last_token_count": {
"type": "integer",
"description": "Last token count."
},
"stop_reason": {
"type": "integer",
"description": "Reason the generation stopped. INVALID=-1, OUT_OF_TOKENS=0, EOS_TOKEN=1, CUSTOM_STOPPER=2"
},
"queue": {
"type": "integer",
"description": "Length of generation queue."
},
"idle": {
"type": "integer",
"description": "Status of backend, busy or idle."
}
},
"required": [
"version"
],
"type": "object"
}
}
},
"info": {
"title": "KoboldCpp API",
"version": "1.46"
},
"openapi": "3.0.3",
"paths": {
"/v1/config/max_context_length": {
"get": {
"operationId": "getConfigMaxContentLength",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"value": 2048
},
"schema": {
"$ref": "#/components/schemas/MaxContextLengthSetting"
}
}
},
"description": "Successful request"
}
},
"summary": "Retrieve the current max context length setting value that horde sees",
"tags": [
"v1"
]
}
},
"/v1/config/max_length": {
"get": {
"operationId": "getConfigMaxLength",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"value": 80
},
"schema": {
"$ref": "#/components/schemas/MaxLengthSetting"
}
}
},
"description": "Successful request"
}
},
"summary": "Retrieve the current max length (amount to generate) setting value",
"tags": [
"v1"
]
}
},
"/v1/generate": {
"post": {
"operationId": "generate",
"description": "Generates text given a prompt and generation settings.\n\nUnspecified values are set to defaults.",
"requestBody": {
"content": {
"application/json": {
"example": {
"prompt": "Niko the kobold stalked carefully down the alley, his small scaly figure obscured by a dusky cloak that fluttered lightly in the cold winter breeze.",
"temperature": 0.5,
"top_p": 0.9
},
"schema": {
"$ref": "#/components/schemas/GenerationInput"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"results": [
{
"text": " Holding up his tail to keep it from dragging in the dirty snow that covered the cobblestone, he waited patiently for the butcher to turn his attention from his stall so that he could pilfer his next meal: a tender-looking chicken."
}
]
},
"schema": {
"$ref": "#/components/schemas/GenerationOutput"
}
}
},
"description": "Successful request"
},
"503": {
"content": {
"application/json": {
"example": {
"detail": {
"msg": "Server is busy; please try again later.",
"type": "service_unavailable"
}
},
"schema": {
"$ref": "#/components/schemas/ServerBusyError"
}
}
},
"description": "Server is busy"
}
},
"summary": "Generate text with a specified prompt",
"tags": [
"v1"
]
}
},
"/v1/info/version": {
"get": {
"operationId": "getVersion",
"description": "Returns the matching *KoboldAI* (United) version of the API that you are currently using. This is not the same as the KoboldCpp API version - this is used to feature match against KoboldAI United.",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"result": "1.2.5"
},
"schema": {
"$ref": "#/components/schemas/BasicResult"
}
}
},
"description": "Successful request"
}
},
"summary": "Current KoboldAI United API version",
"tags": [
"v1"
]
}
},
"/v1/model": {
"get": {
"operationId": "getModel",
"description": "Gets the current model display name, set with hordeconfig.",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"result": "koboldcpp/airoboros-l2-7b-2.2"
},
"schema": {
"$ref": "#/components/schemas/BasicResult"
}
}
},
"description": "Successful request"
}
},
"summary": "Retrieve the current model string from hordeconfig",
"tags": [
"v1"
]
}
},
"/extra/true_max_context_length": {
"get": {
"operationId": "extraTrueMaxContentLength",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"value": 2048
},
"schema": {
"$ref": "#/components/schemas/MaxContextLengthSetting"
}
}
},
"description": "Successful request"
}
},
"summary": "Retrieve the actual max context length setting value set from the launcher",
"description": "Retrieve the actual max context length setting value set from the launcher",
"tags": [
"extra"
]
}
},
"/extra/version": {
"get": {
"operationId": "extraVersion",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"result": "KoboldCpp",
"version": "1.46"
},
"schema": {
"$ref": "#/components/schemas/KcppVersion"
}
}
},
"description": "Successful request"
}
},
"description": "Retrieve the KoboldCpp backend version",
"summary": "Retrieve the KoboldCpp backend version",
"tags": [
"extra"
]
}
},
"/extra/perf": {
"get": {
"operationId": "extraPerf",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"last_process": 5,
"last_eval": 7,
"last_token_count": 80,
"stop_reason": 1,
"queue": 0,
"idle": 1
},
"schema": {
"$ref": "#/components/schemas/KcppPerf"
}
}
},
"description": "Successful request"
}
},
"description": "Retrieve the KoboldCpp recent performance information",
"summary": "Retrieve the KoboldCpp recent performance information",
"tags": [
"extra"
]
}
},
"/extra/generate/stream": {
"post": {
"operationId": "generateStream",
"description": "Generates text given a prompt and generation settings, with SSE streaming.\n\nUnspecified values are set to defaults.\n\nSSE streaming establishes a persistent connection, returning ongoing process in the form of message events.\n\n``` \nevent: message\ndata: {data}\n\n```",
"requestBody": {
"content": {
"application/json": {
"example": {
"prompt": "Niko the kobold stalked carefully down the alley, his small scaly figure obscured by a dusky cloak that fluttered lightly in the cold winter breeze.",
"temperature": 0.5,
"top_p": 0.9
},
"schema": {
"$ref": "#/components/schemas/GenerationInput"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"results": [
{
"text": " Holding up his tail to keep it from dragging in the dirty snow that covered the cobblestone, he waited patiently for the butcher to turn his attention from his stall so that he could pilfer his next meal: a tender-looking chicken."
}
]
},
"schema": {
"$ref": "#/components/schemas/GenerationOutput"
}
}
},
"description": "Successful request"
},
"503": {
"content": {
"application/json": {
"example": {
"detail": {
"msg": "Server is busy; please try again later.",
"type": "service_unavailable"
}
},
"schema": {
"$ref": "#/components/schemas/ServerBusyError"
}
}
},
"description": "Server is busy"
}
},
"summary": "Generate text with a specified prompt. SSE streamed results.",
"tags": [
"extra"
]
}
},
"/extra/generate/check": {
"get": {
"operationId": "checkStreamGet",
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"results": [
{
"text": ", my name is Nik"
}
]
},
"schema": {
"$ref": "#/components/schemas/GenerationOutput"
}
}
},
"description": "Successful request"
}
},
"summary": "Poll the incomplete results of the currently ongoing text generation.",
"description": "Poll the incomplete results of the currently ongoing text generation. Will not work when multiple requests are in queue.",
"tags": [
"extra"
]
},
"post": {
"operationId": "checkStreamPost",
"description": "Poll the incomplete results of the currently ongoing text generation. A unique genkey previously submitted allows polling even in multiuser mode.",
"requestBody": {
"content": {
"application/json": {
"example": {
"genkey": "KCPP2342"
},
"schema": {
"properties": {
"genkey": {
"type": "string",
"description": "A unique key used to identify this generation while it is in progress."
}
},
"type": "object"
}
}
},
"required": false
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"results": [
{
"text": ", my name is Nik"
}
]
},
"schema": {
"$ref": "#/components/schemas/GenerationOutput"
}
}
},
"description": "Successful request"
}
},
"summary": "Poll the incomplete results of the currently ongoing text generation. Supports multiuser mode.",
"tags": [
"extra"
]
}
},
"/extra/tokencount": {
"post": {
"operationId": "countTokens",
"description": "Counts the number of tokens in a string.",
"requestBody": {
"content": {
"application/json": {
"example": {
"prompt": "Hello, my name is Niko."
},
"schema": {
"properties": {
"prompt": {
"type": "string",
"description": "The string to be tokenized."
}
},
"type": "object"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"value": 11
},
"schema": {
"$ref": "#/components/schemas/ValueResult"
}
}
},
"description": "Successful request"
}
},
"summary": "Counts the number of tokens in a string.",
"tags": [
"extra"
]
}
},
"/extra/abort": {
"post": {
"operationId": "abort",
"description": "Aborts the currently ongoing text generation. Does not work when multiple requests are in queue.",
"requestBody": {
"content": {
"application/json": {
"example": {
"genkey": "KCPP2342"
},
"schema": {
"properties": {
"genkey": {
"type": "string",
"description": "A unique key used to identify this generation while it is in progress."
}
},
"type": "object"
}
}
},
"required": false
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"success": true
},
"schema": {
"properties": {
"success": {
"type": "boolean",
"description": "Whether the abort was successful."
}
}
}
}
},
"description": "Successful request"
}
},
"summary": "Aborts the currently ongoing text generation.",
"tags": [
"extra"
]
}
}
},
"servers": [
{
"url": "/api"
}
],
"tags": [
{
"description": "KoboldAI United compatible API core endpoints",
"name": "v1"
},
{
"description": "Extended API unique to KoboldCpp",
"name": "extra"
}
]
}

213
src/ai/coherence.rs Normal file
View File

@ -0,0 +1,213 @@
use anyhow::{anyhow, Result};
use std::cell::RefCell;
use std::mem;
use std::rc::Rc;
use itertools::Itertools;
use crate::models::{
coherence::{CoherenceFailure, SceneFix},
world::scenes::{root_scene_id, Exit, Scene, SceneStub},
Content, ContentContainer, ContentRelation,
};
use super::generator::AiClient;
const DIRECTIONS: [&str; 15] = [
"north",
"south",
"east",
"west",
"northeast",
"northwest",
"southeast",
"southwest",
"up",
"down",
"in",
"out",
"to",
"from",
"back",
];
fn is_direction(value: &str) -> bool {
DIRECTIONS.contains(&value.to_lowercase().as_ref())
}
pub fn reverse_direction(direction: &str) -> String {
match direction.to_lowercase().as_ref() {
// compass directions
"north" => "south".to_string(),
"south" => "north".to_string(),
"east" => "west".to_string(),
"west" => "east".to_string(),
// more compass directions
"northwest" => "southeast".to_string(),
"northeast" => "southwest".to_string(),
"southeast" => "northwest".to_string(),
"southwest" => "northeast".to_string(),
// abstract directions
"up" => "down".to_string(),
"down" => "up".to_string(),
"in" => "out".to_string(),
"out" => "in".to_string(),
_ => "back".to_string(),
}
}
/// If LLM generates something odd, reject it.
fn is_weird_exit_name(value: &str) -> bool {
value.to_lowercase().contains("connected scene")
|| value.to_lowercase() == root_scene_id().as_ref()
}
fn is_duplicate_recorded(failures: &[CoherenceFailure], exit: &Exit) -> bool {
for failure in failures {
match failure {
CoherenceFailure::DuplicateExits(exits) => {
if exits.iter().find(|e| e.name == exit.name).is_some() {
return true;
}
}
_ => (),
}
}
false
}
pub(super) struct AiCoherence {
generator: Rc<AiClient>,
}
impl AiCoherence {
pub fn new(generator: Rc<AiClient>) -> AiCoherence {
AiCoherence { generator }
}
fn check_scene_coherence<'a>(&self, scene: &'a Scene) -> Vec<CoherenceFailure<'a>> {
let mut failures: Vec<CoherenceFailure> = vec![];
for exit in scene.exits.as_slice() {
// Exit names cannot be directions, "weird", or the name of
// the current scene itself.
if is_direction(&exit.name) || is_weird_exit_name(&exit.name) || exit.name == scene.name
{
failures.push(CoherenceFailure::InvalidExitName(exit));
}
// Also need to detect duplicate exits by direction. Stub
// creation can have two exits that lead the same way.
let duplicate_exits: Vec<_> =
scene.exits.iter().filter(|e| e.name == exit.name).collect();
if duplicate_exits.len() > 1 && !is_duplicate_recorded(&failures, exit) {
failures.push(CoherenceFailure::DuplicateExits(duplicate_exits));
}
}
failures
}
/// Attempt to reconnect back to the connected scene. The model is not
/// always good at this. Here, we correct it by attempting to find the
/// exit and making sure the direction is coherently reversed. A
/// linkback exit is created from scratch if one cannot be found.
pub fn make_scene_from_stub_coherent(
&self,
content: &mut ContentContainer,
connected_scene: &Scene,
) {
let new_scene = content.owner.as_scene_mut();
let connected_key = connected_scene._key.as_deref().unwrap();
let connected_id = connected_scene._id.as_deref().unwrap();
let direction_from = connected_scene
.exits
.iter()
.find(|exit| &exit.scene_key == new_scene._key.as_ref().unwrap())
.map(|exit| exit.direction.as_ref())
.unwrap_or("from");
let reversed_direction = reverse_direction(direction_from);
// 1. Delete any exit that is from the reversed direction, or
// has the name/ID/key of the connected scene.
let mut stubs_to_delete = vec![];
let keep_exit = |exit: &Exit| {
!(exit.direction == reversed_direction
|| Some(exit.scene_key.as_ref()) == connected_scene._key.as_deref()
|| exit.scene_id.as_deref() == connected_scene._id.as_deref()
|| exit.name.to_lowercase() == connected_scene.name.to_lowercase()
|| exit.name == connected_key
|| exit.name == connected_id)
};
new_scene.exits.retain_mut(|exit| {
let keep = keep_exit(exit);
if !keep {
stubs_to_delete.push(mem::take(&mut exit.scene_key));
}
keep
});
// 2. Delete corresponding scene stubs
content.contained.retain(|c| match &c.content {
Content::SceneStub(stub) => match stub._key.as_ref() {
Some(key) => !stubs_to_delete.contains(key),
_ => true,
},
_ => true,
});
// 3. Add new linkback exit
let exit = Exit::from_connected_scene(connected_scene, &reversed_direction);
new_scene.exits.push(exit);
}
pub async fn make_scene_coherent(&self, content: &mut ContentContainer) -> Result<()> {
let scene = content.owner.as_scene_mut();
let failures = self.check_scene_coherence(&scene);
let fixes = self.generator.fix_scene(&scene, failures).await?;
let mut deletes = vec![]; // Needed for call to Vec::retain after the fact
for fix in fixes {
match fix {
SceneFix::FixedExit {
index,
new: fixed_exit,
} => {
let old_exit_key = scene.exits[index].scene_key.as_str();
content.contained.retain(|c| match &c.content {
Content::SceneStub(stub) => stub._key.as_deref() != Some(old_exit_key),
_ => true,
});
scene.exits[index] = fixed_exit.into();
let fixed_exit = &scene.exits[index];
content
.contained
.push(ContentRelation::scene_stub(SceneStub::from(fixed_exit)));
}
SceneFix::DeleteExit(index) => {
deletes.push(index);
}
};
}
// Deletes
let mut index: usize = 0;
scene.exits.retain(|_| {
let keep_it = !deletes.contains(&index);
index += 1;
keep_it
});
Ok(())
}
}

223
src/ai/convo.rs Normal file
View File

@ -0,0 +1,223 @@
use crate::kobold_api::{create_input, Client as KoboldClient, SseGenerationExt};
use crate::models::new_uuid_string;
use anyhow::Result;
use async_recursion::async_recursion;
use serde::de::DeserializeOwned;
use serde_json::error::Category;
use serde_json::Value;
use std::cell::RefCell;
use std::rc::Rc;
/// Characters which can break the JSON deserialization. Do not rely
/// on model to NOT print these (though it shouldn't). Make sure they
/// are removed from JSON responses.
const ILLEGAL_CHARACTERS: [char; 1] = ['\\'];
fn sanitize_json_response(mut json: String) -> String {
for illegal_char in ILLEGAL_CHARACTERS {
json = json.replace(illegal_char, "");
}
json
}
struct AiExecution<'a> {
client: &'a KoboldClient,
gen_key: &'a str,
prompt: &'a AiPrompt,
prompt_so_far: &'a mut String,
}
async fn converse<'a, T: DeserializeOwned>(details: &mut AiExecution<'a>) -> Result<T> {
// Handle Mistral-instruct begin instruct mode.
// https://huggingface.co/mistralai/Mistral-7B-Instruct-v0.2
// Only the very first one begins with <s>, not subsequent.
if details.prompt_so_far.is_empty() {
details.prompt_so_far.push_str("<s>");
}
details.prompt_so_far.push_str(&details.prompt.prompt);
let input = create_input(
details.gen_key.to_string(),
&details.prompt_so_far,
details.prompt.grammar.clone(),
details.prompt.max_tokens,
false,
details.prompt.creativity,
);
// TODO working on removing trait bounds issue so we can use ? operator.
let mut str_resp = details
.client
.sse_generate(input)
.await
.map(sanitize_json_response)
.unwrap();
details.prompt_so_far.push_str(&str_resp);
let resp: T = match serde_json::from_str(&str_resp) {
Ok(obj) => obj,
Err(e) => {
// If the resp is not fully valid JSON, request more
// from the LLM.
match e.classify() {
Category::Eof => {
continue_execution(
details,
&mut str_resp,
)
.await?
}
_ => return Err(e.into()),
}
}
};
// mistral 7b end of response token (for when BNF is used)
if !details.prompt_so_far.trim().ends_with("</s>") {
details.prompt_so_far.push_str("</s>");
}
Ok(resp)
}
#[async_recursion(?Send)]
async fn continue_execution<'a, T: DeserializeOwned>(
details: &mut AiExecution<'a>,
resp_so_far: &mut String,
) -> Result<T> {
// Grammar state is retained here (as opposed to false
// normally) to let the model continue to generate JSON.
let input = create_input(
details.gen_key.to_string(),
details.prompt_so_far,
details.prompt.grammar.clone(),
details.prompt.max_tokens,
true,
details.prompt.creativity,
);
// TODO convert error to remove trait bound issue
let resp = details.client.sse_generate(input).await.unwrap();
details.prompt_so_far.push_str(&resp);
resp_so_far.push_str(&resp);
let resp: Value = match serde_json::from_str(&resp_so_far) {
Ok(obj) => obj,
Err(e) => match e.classify() {
Category::Eof | Category::Syntax => {
continue_execution(details, resp_so_far).await?
}
_ => {
return Err(e.into());
}
},
};
let resp: T = serde_json::from_value(resp)?;
Ok(resp)
}
#[derive(Debug, Clone, Copy)]
pub enum AiCreativity {
Predictable,
Normal,
Creative,
}
pub struct AiPrompt {
pub prompt: String,
pub grammar: Option<String>,
pub max_tokens: u64,
pub creativity: AiCreativity,
}
impl AiPrompt {
pub fn new(prompt: &str) -> AiPrompt {
AiPrompt {
prompt: prompt.to_string(),
grammar: None,
max_tokens: 150,
creativity: AiCreativity::Normal,
}
}
pub fn new_with_grammar(prompt: &str, grammar: &str) -> AiPrompt {
AiPrompt {
prompt: prompt.to_string(),
grammar: Some(grammar.to_string()),
max_tokens: 150,
creativity: AiCreativity::Normal,
}
}
pub fn new_with_grammar_and_size(prompt: &str, grammar: &str, tokens: u64) -> AiPrompt {
AiPrompt {
prompt: prompt.to_string(),
grammar: Some(grammar.to_string()),
max_tokens: tokens,
creativity: AiCreativity::Normal,
}
}
pub fn creative_with_grammar(prompt: &str, grammar: &str) -> AiPrompt {
AiPrompt {
prompt: prompt.to_string(),
grammar: Some(grammar.to_string()),
max_tokens: 150,
creativity: AiCreativity::Creative,
}
}
pub fn creative_with_grammar_and_size(prompt: &str, grammar: &str, tokens: u64) -> AiPrompt {
AiPrompt {
prompt: prompt.to_string(),
grammar: Some(grammar.to_string()),
max_tokens: tokens,
creativity: AiCreativity::Creative,
}
}
}
pub struct AiConversation {
gen_key: String,
prompt_so_far: Rc<RefCell<String>>,
client: Rc<KoboldClient>,
}
impl AiConversation {
pub fn new(client: Rc<KoboldClient>) -> AiConversation {
AiConversation {
prompt_so_far: Rc::new(RefCell::new(String::new())),
gen_key: new_uuid_string(),
client,
}
}
pub fn is_empty(&self) -> bool {
self.prompt_so_far.borrow().is_empty()
}
pub fn reset(&self) {
let mut prompt_so_far = RefCell::borrow_mut(&self.prompt_so_far);
prompt_so_far.clear();
}
pub async fn execute<T: DeserializeOwned>(&self, prompt: &AiPrompt) -> Result<T> {
let mut prompt_so_far = RefCell::borrow_mut(&self.prompt_so_far);
let prompt_so_far = &mut *prompt_so_far;
let mut details = AiExecution {
prompt_so_far,
client: &self.client,
gen_key: &self.gen_key,
prompt: &prompt
};
converse(&mut details).await
}
}

194
src/ai/generator.rs Normal file
View File

@ -0,0 +1,194 @@
use anyhow::{anyhow, Result};
use itertools::Itertools;
use super::convo::AiConversation;
use super::prompts::{execution_prompts, parsing_prompts, world_prompts};
use crate::kobold_api::Client as KoboldClient;
use crate::models::coherence::{CoherenceFailure, SceneFix};
use crate::models::commands::{Command, Commands, RawCommandExecution, VerbsResponse};
use crate::models::world::raw::{
ExitSeed, ItemDetails, ItemSeed, PersonDetails, PersonSeed, SceneSeed,
};
use crate::models::world::scenes::{Exit, Scene, SceneStub, Stage};
use std::rc::Rc;
fn find_exit_position(exits: &[Exit], exit_to_find: &Exit) -> Result<usize> {
let (pos, _) = exits
.iter()
.find_position(|&exit| exit == exit_to_find)
.ok_or(anyhow!("cannot find exit"))?;
Ok(pos)
}
/// Intermediate level struct that is charged with creating 'raw'
/// information via the LLM and doing basic coherence on it. Things
/// like ID creation, data management, and advanced coherence are done
/// at a higher level.
pub struct AiClient {
parsing_convo: AiConversation,
world_creation_convo: AiConversation,
person_creation_convo: AiConversation,
execution_convo: AiConversation,
}
impl AiClient {
pub fn new(client: Rc<KoboldClient>) -> AiClient {
AiClient {
parsing_convo: AiConversation::new(client.clone()),
world_creation_convo: AiConversation::new(client.clone()),
person_creation_convo: AiConversation::new(client.clone()),
execution_convo: AiConversation::new(client.clone()),
}
}
pub fn reset_commands(&self) {
self.parsing_convo.reset();
self.execution_convo.reset();
}
pub fn reset_world_creation(&self) {
self.world_creation_convo.reset();
}
pub fn reset_person_creation(&self) {
self.person_creation_convo.reset();
}
pub async fn parse(&self, cmd: &str) -> Result<Commands> {
// If convo so far is empty, add the instruction header,
// otherwise only append to existing convo.
let prompt = match self.parsing_convo.is_empty() {
true => parsing_prompts::intro_prompt(&cmd),
false => parsing_prompts::continuation_prompt(&cmd),
};
let mut cmds: Commands = self.parsing_convo.execute(&prompt).await?;
let verbs = self.find_verbs(cmd).await?;
self.check_coherence(&verbs, &mut cmds).await?;
Ok(cmds)
}
async fn find_verbs(&self, cmd: &str) -> Result<Vec<String>> {
let prompt = parsing_prompts::find_verbs_prompt(cmd);
let verbs: VerbsResponse = self.parsing_convo.execute(&prompt).await?;
// Basic coherence filtering to make sure the 'verb' is
// actually in the text.
Ok(verbs
.verbs
.into_iter()
.filter(|verb| cmd.contains(verb))
.collect())
}
async fn check_coherence(&self, verbs: &[String], commands: &mut Commands) -> Result<()> {
// let coherence_prompt = parsing_prompts::coherence_prompt();
// let mut commands: Commands = self.parsing_convo.execute(&coherence_prompt).await?;
// Non-LLM coherence checks: remove empty commands, remove
// non-verbs, etc.
let filtered_commands: Vec<Command> = commands
.clone()
.commands
.into_iter()
.filter(|cmd| !cmd.verb.is_empty() && verbs.contains(&cmd.verb))
.collect();
commands.commands = filtered_commands;
commands.count = commands.commands.len();
Ok(())
}
pub async fn execute_raw(&self, stage: &Stage, cmd: &Command) -> Result<RawCommandExecution> {
let prompt = execution_prompts::execution_prompt(stage, &cmd);
let raw_exec: RawCommandExecution = self.execution_convo.execute(&prompt).await?;
Ok(raw_exec)
}
pub async fn create_scene_seed(
&self,
scene_type: &str,
fantasticalness: &str,
) -> Result<SceneSeed> {
let prompt = world_prompts::scene_creation_prompt(scene_type, fantasticalness);
let scene: SceneSeed = self.world_creation_convo.execute(&prompt).await?;
Ok(scene)
}
pub async fn create_scene_seed_from_stub(
&self,
stub: &SceneStub,
connected_scene: &Scene,
) -> Result<SceneSeed> {
let prompt = world_prompts::scene_from_stub_prompt(connected_scene, stub);
let scene: SceneSeed = self.world_creation_convo.execute(&prompt).await?;
Ok(scene)
}
pub async fn create_person_details(
&self,
scene: &SceneSeed,
seed: &PersonSeed,
) -> Result<PersonDetails> {
let prompt = world_prompts::person_creation_prompt(scene, seed);
let person: PersonDetails = self.person_creation_convo.execute(&prompt).await?;
Ok(person)
}
pub async fn create_item_details(
&self,
scene: &SceneSeed,
seed: &ItemSeed,
) -> Result<ItemDetails> {
let item_details = ItemDetails {
description: "fill me in--details prompt to AI not done yet".to_string(),
attributes: vec![],
secret_attributes: vec![],
};
Ok(item_details)
}
pub(super) async fn fix_scene<'a>(
&self,
scene: &Scene,
failures: Vec<CoherenceFailure<'a>>,
) -> Result<Vec<SceneFix>> {
let mut fixes = vec![];
// We should always have exits here, and we should always find
// them in the scene.
for failure in failures {
let fix = match failure {
CoherenceFailure::InvalidExitName(original_exit) => {
println!("invalid exit name: {}", original_exit.name);
let prompt = world_prompts::fix_exit_prompt(scene, original_exit);
let fixed: ExitSeed = self.world_creation_convo.execute(&prompt).await?;
println!("fixed with: {:?}", fixed);
let position = find_exit_position(&scene.exits, original_exit)?;
SceneFix::FixedExit {
index: position,
new: fixed,
}
}
CoherenceFailure::DuplicateExits(bad_exits) => {
println!("found duplicate exits {:?}", bad_exits);
let position = find_exit_position(&scene.exits, bad_exits[0])?;
SceneFix::DeleteExit(position)
}
};
fixes.push(fix);
}
Ok(fixes)
}
// async fn fix_events(&mut self, scene: &Scene, failures: &EventConversionFailures) {
// //
// }
}

271
src/ai/mod.rs Normal file
View File

@ -0,0 +1,271 @@
use crate::db::Database;
use crate::kobold_api::Client as KoboldClient;
use crate::models::commands::{
CommandExecution, Commands, ExecutionConversionResult, RawCommandExecution,
};
use crate::models::world::items::{Category, Item, Rarity};
use crate::models::world::people::{Gender, Person, Sex};
use crate::models::world::raw::{ItemSeed, PersonSeed, SceneSeed};
use crate::models::world::scenes::{Exit, Scene, SceneStub, Stage};
use crate::models::{new_uuid_string, Content, ContentContainer, ContentRelation};
use anyhow::{bail, Result};
use itertools::Itertools;
use std::rc::Rc;
mod coherence;
pub mod convo;
pub mod generator;
pub mod prompts;
use convo::AiPrompt;
use generator::AiClient;
use self::coherence::AiCoherence;
/// Highest-level AI/LLM construct, which returns fully converted game
/// objects to us. Basically, call the mid-level `client` to create
/// seed objects, then call the mid level client again to detail the
/// entities from their seeds. Then, stick a DB ID on them and put
/// them in the database(?).
pub struct AiLogic {
generator: Rc<AiClient>,
coherence: AiCoherence,
db: Rc<Database>,
}
impl AiLogic {
pub fn new(api_client: Rc<KoboldClient>, db: &Rc<Database>) -> AiLogic {
let generator = Rc::new(AiClient::new(api_client));
let coherence = AiCoherence::new(generator.clone());
AiLogic {
generator,
coherence,
db: db.clone(),
}
}
pub async fn execute(
&mut self,
stage: &Stage,
cmd: &str,
) -> Result<(Commands, CommandExecution)> {
let parsed_cmd = self.generator.parse(cmd).await?;
let execution = self.execute_parsed(stage, &parsed_cmd).await?;
Ok((parsed_cmd, execution))
}
pub async fn execute_parsed(
&mut self,
stage: &Stage,
parsed_cmd: &Commands,
) -> Result<CommandExecution> {
//TODO handle multiple commands in list
if parsed_cmd.commands.is_empty() {
return Ok(CommandExecution::empty());
}
let cmd = &parsed_cmd.commands[0];
let raw_exec: RawCommandExecution = self.generator.execute_raw(stage, cmd).await?;
// Coherence check:
// Set aside any events that are not in the enum
// Set aside anything with correct event, but wrong parameters.
// Ask LLM to fix them, if possible
//TODO make a aiclient::fix_execution
let converted = crate::commands::convert_raw_execution(raw_exec, &self.db).await;
self.generator.reset_commands();
//TODO handle the errored events aside from yeeting them out
match converted {
ExecutionConversionResult::Success(execution) => Ok(execution),
ExecutionConversionResult::PartialSuccess(execution, _) => Ok(execution),
ExecutionConversionResult::Failure(failures) => {
bail!("unhandled command execution failure: {:?}", failures)
}
}
}
pub async fn create_person(&mut self, scene: &SceneSeed, seed: &PersonSeed) -> Result<Person> {
self.generator.reset_person_creation();
let details = self.generator.create_person_details(scene, seed).await?;
let gender = match details.gender.to_lowercase().as_ref() {
"male" | "man" | "boy" | "transmasc" => Gender::Male,
"female" | "woman" | "girl" | "transfem" => Gender::Female,
"nonbinary" => Gender::NonBinary,
// fall back to using sex
_ => match details.sex.to_lowercase().as_ref() {
"male" | "man" | "boy" | "transmasc" => Gender::Male,
"female" | "woman" | "girl" | "transfem" => Gender::Female,
_ => Gender::NonBinary, // TODO 1/3 chance!
},
};
let sex = match details.sex.to_lowercase().as_ref() {
"male" | "man" | "boy" | "transfem" => Sex::Male,
"female" | "woman" | "girl" | "transmasc" => Sex::Female,
_ => match gender {
Gender::Male => Sex::Male,
Gender::Female => Sex::Male,
_ => Sex::Male, // TODO 50/50 chance!
},
};
self.generator.reset_person_creation();
Ok(Person {
_key: Some(new_uuid_string()),
name: seed.name.to_string(),
description: details.description,
age: details.age,
residence: details.residence,
current_activity: details.current_activity,
occupation: seed.occupation.to_string(),
race: seed.race.clone(),
sex,
..Default::default()
})
}
pub async fn create_item(&mut self, scene: &SceneSeed, seed: &ItemSeed) -> Result<Item> {
let details = self.generator.create_item_details(scene, seed).await?;
// TODO these have to be sent to the AI
let category = Category::Other;
let rarity = Rarity::Common;
Ok(Item {
_key: Some(new_uuid_string()),
name: seed.name.to_string(),
description: details.description,
attributes: details.attributes,
secret_attributes: details.secret_attributes,
category,
rarity,
..Default::default()
})
}
pub async fn create_scene_with_id(
&mut self,
scene_type: &str,
fantasticalness: &str,
scene_id: &str,
) -> Result<ContentContainer> {
let mut content = self.create_scene(scene_type, fantasticalness).await?;
let scene = content.owner.as_scene_mut();
scene._key = Some(scene_id.to_string());
Ok(content)
}
pub async fn create_scene_from_stub(
&mut self,
stub: SceneStub,
connected_scene: &Scene,
) -> Result<ContentContainer> {
self.generator.reset_world_creation();
let seed = self
.generator
.create_scene_seed_from_stub(&stub, connected_scene)
.await?;
// There are two coherence steps: the first fixes up exit
// directions and stuff, while the second is the normal scene
// coherence (that can invoke the LLM).
let mut content = self.fill_in_scene_from_stub(seed, stub).await?;
self.coherence
.make_scene_from_stub_coherent(&mut content, connected_scene);
self.coherence.make_scene_coherent(&mut content).await?;
self.generator.reset_world_creation();
Ok(content)
}
pub async fn create_scene(
&mut self,
scene_type: &str,
fantasticalness: &str,
) -> Result<ContentContainer> {
self.generator.reset_world_creation();
let scene_seed = self
.generator
.create_scene_seed(scene_type, fantasticalness)
.await?;
let mut content = self.fill_in_scene(scene_seed).await?;
self.coherence.make_scene_coherent(&mut content).await?;
self.generator.reset_world_creation();
Ok(content)
}
async fn fill_in_scene_from_stub(
&mut self,
seed: SceneSeed,
stub: SceneStub,
) -> Result<ContentContainer> {
let mut content = self.fill_in_scene(seed).await?;
let new_scene = content.owner.as_scene_mut();
new_scene._id = stub._id;
new_scene._key = stub._key;
Ok(content)
}
async fn fill_in_scene(&mut self, mut scene_seed: SceneSeed) -> Result<ContentContainer> {
let mut content_in_scene = vec![];
// People in scene
let mut people = vec![];
for person_seed in scene_seed.people.as_slice() {
let person = self.create_person(&scene_seed, person_seed).await?;
people.push(ContentRelation::person(person));
}
// Items in scene
let mut items = vec![];
for item_seed in scene_seed.items.as_slice() {
let item = self.create_item(&scene_seed, item_seed).await?;
items.push(ContentRelation::item(item));
}
// TODO items on people, which will require 'recursive' ContentContainers.
let exits: Vec<_> = scene_seed
.exits
.drain(0..)
.map(|seed| Exit::from(seed))
.collect();
let mut stubs: Vec<_> = exits
.iter()
.map(|exit| ContentRelation::scene_stub(SceneStub::from(exit)))
.collect();
let mut scene = Scene {
_key: Some(new_uuid_string()),
name: scene_seed.name,
region: scene_seed.region,
description: scene_seed.description,
props: scene_seed.props.drain(0..).map_into().collect(),
is_stub: false,
exits,
..Default::default()
};
content_in_scene.append(&mut people);
content_in_scene.append(&mut items);
content_in_scene.append(&mut stubs);
Ok(ContentContainer {
owner: Content::Scene(scene),
contained: content_in_scene,
})
}
}

View File

@ -0,0 +1,212 @@
use crate::ai::AiPrompt;
use crate::models::commands::{Command, CommandEvent, EventConversionFailures};
use crate::models::world::scenes::{Scene, Stage};
use strum::VariantNames;
const COMMAND_EXECUTION_BNF: &'static str = r#"
root ::= CommandExecution
CommandEvent ::= "{" ws "\"eventName\":" ws string "," ws "\"appliesTo\":" ws string "," ws "\"parameter\":" ws string "}"
CommandEventlist ::= "[]" | "[" ws CommandEvent ("," ws CommandEvent)* "]"
CommandExecution ::= "{" ws "\"valid\":" ws boolean "," ws "\"reason\":" ws string "," ws "\"narration\":" ws string "," ws "\"events\":" ws CommandEventlist "}"
CommandExecutionlist ::= "[]" | "[" ws CommandExecution ("," ws CommandExecution)* "]"
string ::= "\"" ([^"]*) "\""
boolean ::= "true" | "false"
ws ::= [ \t\n]*
number ::= [0-9]+ "."? [0-9]*
stringlist ::= "[" ws "]" | "[" ws string ("," ws string)* ws "]"
numberlist ::= "[" ws "]" | "[" ws string ("," ws number)* ws "]"
"#;
const COMMAND_EXECUTION_PROMPT: &'static str = r#"
[INST]
You are running a text-based adventure game. You have been given a command to execute. Your response must be in JSON.
You can only execute the command if it is valid. A command is invalid if:
- It is physically impossible to perform the action.
- Example: climbing a flat, vertical wall without equipment.
- Example: carrying more weight than physically possible.
- The action does not make sense.
- Example: trying to kill something that is already dead.
- Example: grabbing an item not present in the scene.
- Example: targeting something or someone not present in the scene.
- The action is not legal, moral, or ethical, according to the cultural norms or laws of the player's current location.
- Exception: If the player is evil, they might proceed with an illegal action anyway.
A command is valid if it does not fail one or more of the tests above that would make it invalid.
Return structured JSON data consisting of:
- `valid`: This field is `true` if the command is judged to be valid and possible. It is `false` if the command is not valid.
- `reason`: This field contains the reason a command is considered invalid. This value should be `null` if the command is valid.
- `narration`: The narrative text that the player will see. A descriptive result of their action.
- `events`: A field that contains the results of executing the commands - a series of events that must happen to the player, the scene, and entities in the scene, in order for the command to be considered executed.
The `events` field must be filled with entries if the command is valid. It is a series of events that must happen. An event has `name`, `appliesTo`, and `parameter` fields:
- `name`: The name of the event, which can be one of the ones detailed below.
- `appliesTo`: The player, item, NPC, or other entity in the scene.
- The event applies only to one target.
- The `appliesTo` field should be the `key` of the target. If no key was provided, use the target's name instead.
- `parameter`: Optional parameter with a string value that will be parsed. Parameters allowed depend on the type of event, and are detailed below.
The following events can be generated:
- `change_scene`: The player's current scene is changed.
- `appliesTo` must be set to `player`.
- `parameter` must be the Scene Key of the new scene.
- `take_damage`: The target of the event takes an amount of damage.
- `appliesTo` must be the target taking damage (player, NPC, item, prop, or other thing in the scene)
- `parameter` must be the amount of damage taken. This value must be a positive integer.
- `narration`: Additional narrative information for the player that summarizes something not covered in the main narration.
- `appliesTo` is irrelevant for this event.
- `parameter` is irrelevant for this event.
- `stand`: The target of the event stands up.
- `appliesTo` must be the person standing up.
- `parameter` is irrelevant for this event.
- `sit`: The target of the event sits down.
- `appliesTo` must be the person sitting down.
- `parameter` is irrelevant for this event.
- `prone`: The target of the event lies prone.
- `appliesTo` must be the person lying prone.
- `parameter` is irrelevant for this event.
- `crouch`: The target of the event crouches.
- `appliesTo` must be the person crouching.
- `parameter` is irrelevant for this event.
- `unrecognized`: For any event that is not in the list above, and is thus considered invalid. This event will be recorded for analysis.
- `appliesTo` must be the target in the scene that the event would apply to, if it was a valid event.
- `parameter` should be a value that theoretically makes sense, if this event was a valid event.
{SCENE_INFO}
**Player Command**:
- Action: `{}`
- Target: `{}`
- Location: `{}`
- Using: `{}`
[/INST]
"#;
pub const FIX_PROMPT: &'static str = r#"
The following command enxecution events are invalid or unrecognized.
"#;
const INVALID_NUMBER: &'static str = r#"
The number was invalid. It must be a positive integer. Make sure it is a positive integer.
"#;
const UNRECOGNIZED_EVENT: &'static str = r#"
The event {event_name} is not a recognized event. The event must be one of these events:
{event_name_list}
Change it so that the event is one of the valid events in the list, but only if the event
would make sense. If the event still cannot be recognized, set the event name to `unrecognized`.
Your reponse must be in JSON.
"#;
const SCENE_EXIT_INFO: &'static str = r#"
**Exit:**:
- Name: `{EXIT_NAME}`
- Direction: `{DIRECTION}`
- Scene Key: `{SCENE_KEY}`
- Scene Location: `{EXIT_LOCATION}`
"#;
fn unrecognized_event_solution(event_name: &str) -> String {
let valid_events = CommandEvent::VARIANTS
.iter()
.map(|name| format!(" - {}", name))
.collect::<Vec<_>>()
.join("\n");
UNRECOGNIZED_EVENT
.replacen("{event_name}", event_name, 1)
.replacen("{valid_event_names}", &valid_events, 1)
}
fn stage_info(stage: &Stage) -> String {
let scene_description = "**Scene Description:** ".to_string() + &stage.scene.description;
let mut info = "**Scene Information:**\n".to_string();
info.push_str(" - Key: ");
info.push_str(&format!("`{}`", stage.key));
info.push_str("\n");
info.push_str(" - Name: ");
info.push_str(&stage.scene.name);
info.push_str("\n");
info.push_str(" - Location: ");
info.push_str(&stage.scene.region);
info.push_str("\n");
let people: String = stage
.people
.iter()
.map(|p| format!(" - {}", p.name))
.collect::<Vec<_>>()
.join("\n");
info.push_str("**People:**\n");
info.push_str(&people);
info.push_str("\n");
let items: String = stage
.items
.iter()
.map(|i| format!(" - {} ({})", i.name, i.category))
.collect::<Vec<_>>()
.join("\n");
info.push_str("**Items:**\n");
info.push_str(&items);
info.push_str("\n");
let props: String = stage
.scene
.props
.iter()
.map(|p| format!(" - {}", p.name))
.collect::<Vec<_>>()
.join("\n");
info.push_str("**Props:**\n");
info.push_str(&props);
info.push_str("\n\n");
let exits: String = stage
.scene
.exits
.iter()
.map(|e| {
SCENE_EXIT_INFO
.replacen("{EXIT_NAME}", &e.name, 1)
.replacen("{DIRECTION}", &e.direction, 1)
.replacen("{SCENE_KEY}", &e.scene_key, 1)
.replacen("{EXIT_LOCATION}", &e.region, 1)
})
.collect::<Vec<_>>()
.join("\n\n");
info.push_str(&exits);
info.push_str(&scene_description);
info
}
pub fn execution_prompt(stage: &Stage, cmd: &Command) -> AiPrompt {
let scene_info = stage_info(&stage);
let prompt = COMMAND_EXECUTION_PROMPT
.replacen("{SCENE_INFO}", &scene_info, 1)
.replacen("{}", &cmd.verb, 1)
.replacen("{}", &cmd.target, 1)
.replacen("{}", &cmd.location, 1)
.replacen("{}", &cmd.using, 1);
AiPrompt::new_with_grammar_and_size(&prompt, COMMAND_EXECUTION_BNF, 512)
}
pub fn fix_prompt(scene: &Scene, failures: &EventConversionFailures) -> AiPrompt {
AiPrompt::new("")
}

3
src/ai/prompts/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod execution_prompts;
pub mod parsing_prompts;
pub mod world_prompts;

View File

@ -0,0 +1,129 @@
use crate::ai::AiPrompt;
pub const COMMAND_BNF: &str = r#"
root ::= Commands
Command ::= "{" ws "\"verb\":" ws string "," ws "\"target\":" ws string "," ws "\"location\":" ws string "," ws "\"using\":" ws string "}"
Commandlist ::= "[]" | "[" ws Command ("," ws Command)* "]"
Commands ::= "{" ws "\"commands\":" ws Commandlist "," ws "\"count\":" ws number "}"
Commandslist ::= "[]" | "[" ws Commands ("," ws Commands)* "]"
string ::= "\"" ([^"]*) "\""
boolean ::= "true" | "false"
ws ::= [ \t\n]*
number ::= [0-9]+ "."? [0-9]*
stringlist ::= "[" ws "]" | "[" ws string ("," ws string)* ws "]"
numberlist ::= "[" ws "]" | "[" ws string ("," ws number)* ws "]"
"#;
pub const INTRO_PROMPT: &'static str = r#"
[INST]
You are running a text-based adventure game, and the player is providing you commands as input.
- The commands must be parsed into structured data for command execution.
- Every message provided after these instructions that starts with `Player Input` is considered Player Input.
- Your response should be structured JSON data that contains a list of commands to execute.
- The parsed structured commands must also be checked for coherence.
A command consists of:
- `verb`: a verb, which is the action that the player wants to take. This must always be a verb.
- `target`: the target of the action. This must always be a valid target.
- `location`: the location of the target (example: player's inventory, in the room, towards the north)
- `using`: the item or means by which the action will be accomplished. The item must be mentioned in the
Player Input.
Steps for parsing the Player Input:
1. Extract the verbs from the Player Input. These are the commands that will be executed.
2. Match the extracted verbs with their targets.
3. Extract the location of each target, acccording to the instructions below.
4. The `using` field should be the item or means via which the command will be accomplished.
5. Check the structured data for coherence. Remove any commands from the list that are not do not make snse.
6. The `count` value should be the expected number of commands, given the original Player Input.
Instructions for extracting target locations:
- The location is where the target of the command is located.
- If the target is in the scene with the player, the location is `current_scene`.
- If there is no obvious location of the target, check to see if there is a compass direction related to the target. If so, that is the location of the target.
- If the target is located on the player's person, the value is `self`.
- If the location is not known, the value should be `unknown`.
- If the generated location is `other`, change the location to `unknown`.
Instructions for checking structured data for coherence and making sure it makes sense:
- Remove any commands from the final list that are not verbs.
- Words like `with`, `and`, `by` are not verbs. Remove them from the final command list.
- Targets of commands in the structured data must be in the Player Input.
- The action in the `verb` field must be present in the original Player Input. If not, remove
the comand from the list.
- If the original Player Input does not mention a target, remove that comand from the final list.
- The location of the target should make sense. If the player is interacting with another character
as a target, the location of the target is not `self`, but most likely `current_scene`.
- The value in the `using` field must be mentioned in the original Player Input. If it is not,
change the value of `using` to `unknown`.
- If the command is not part of the expected output, given the Player Input, remove it from the list.
- If the `verb` field is empty, remove the command from the list.
Final instructions:
- If the `verb` field does not actually contain a verb, remove it from the list.
- Make sure the `using` field makes sense.
- Make sure the `target` field makes sense.
- Make sure all commands that are coherent and make sense remain in the list.
- Make sure commands that are not coherent or don't make sense are removed from the list.
Player Input: `{}`
[/INST]
"#;
pub const COHERENCE_PROMPT: &'static str = r#"
[INST]
Check the generated commands for coherence according to these instructions. Your response must be in JSON.
- If the `verb` field does not actually contain a verb, remove it from the list.
- The action in the `verb` field must be present in the original Player Input. If not, remove
the comand from the list.
- Make sure the `using` field makes sense.
- Make sure the `target` field makes sense.
- Make sure all commands that are coherent and make sense remain in the list.
- Make sure commands that are not coherent or don't make sense are removed from the list.
[/INST]
"#;
pub const FIND_VERBS_BNF: &str = r#"
root ::= Verbs
Verbs ::= "{" ws "\"verbs\":" ws stringlist "}"
Verbslist ::= "[]" | "[" ws Verbs ("," ws Verbs)* "]"
string ::= "\"" ([^"]*) "\""
boolean ::= "true" | "false"
ws ::= [ \t\n]*
number ::= [0-9]+ "."? [0-9]*
stringlist ::= "[" ws "]" | "[" ws string ("," ws string)* ws "]"
numberlist ::= "[" ws "]" | "[" ws string ("," ws number)* ws "]"
"#;
pub const FIND_VERBS_PROMPT: &'static str = "
[INST]
Extract the verbs from from the text below, labeled `Text`. This text is a command entered by the user, playing a text-based aventure game. Return the verbs as a JSON array.
Text: `{}`
[/INST]";
pub fn intro_prompt(cmd: &str) -> AiPrompt {
let mut prompt = INTRO_PROMPT.replace("{}", cmd);
AiPrompt::new_with_grammar(&prompt, COMMAND_BNF)
}
pub fn continuation_prompt(cmd: &str) -> AiPrompt {
let mut prompt = String::new();
prompt.push_str("[INST]");
prompt.push_str(&format!("Player Input: `{}`", cmd));
prompt.push_str("[/INST]");
AiPrompt::new_with_grammar(&prompt, COMMAND_BNF)
}
pub fn coherence_prompt() -> AiPrompt {
AiPrompt::new_with_grammar(COHERENCE_PROMPT, COMMAND_BNF)
}
pub fn find_verbs_prompt(cmd: &str) -> AiPrompt {
let prompt = FIND_VERBS_PROMPT.replace("{}", cmd);
AiPrompt::new_with_grammar(&prompt, FIND_VERBS_BNF)
}

View File

@ -0,0 +1,303 @@
use crate::{
ai::AiPrompt,
models::world::{
raw::{PersonSeed, SceneSeed},
scenes::{Exit, Scene, SceneStub},
},
};
const SCENE_BNF: &'static str = r#"
root ::= Scene
Prop ::= "{" ws "\"name\":" ws string "," ws "\"description\":" ws string "," ws "\"features\":" ws stringlist "," ws "\"possible_interactions\":" ws stringlist "}"
Proplist ::= "[]" | "[" ws Prop ("," ws Prop)* "]"
Exit ::= "{" ws "\"name\":" ws string "," ws "\"direction\":" ws string "," "\"region\":" ws string "}"
Exitlist ::= "[]" | "[" ws Exit ("," ws Exit)* "]"
Item ::= "{" ws "\"name\":" ws string "," ws "\"category\":" ws string "," ws "\"rarity\":" ws string "}"
Itemlist ::= "[]" | "[" ws Item ("," ws Item)* "]"
Person ::= "{" ws "\"name\":" ws string "," ws "\"occupation\":" ws string "," ws "\"race\":" ws string "}"
Personlist ::= "[]" | "[" ws Person ("," ws Person)* "]"
Scene ::= "{" ws "\"name\":" ws string "," ws "\"region\":" ws string "," ws "\"description\":" ws string "," ws "\"people\":" ws Personlist "," ws "\"items\":" ws Itemlist "," ws "\"props\":" ws Proplist "," "\"exits\":" ws Exitlist "}"
Scenelist ::= "[]" | "[" ws Scene ("," ws Scene)* "]"
string ::= "\"" ([^"]*) "\""
boolean ::= "true" | "false"
ws ::= [ \t\n]*
number ::= [0-9]+ "."? [0-9]*
stringlist ::= "[" ws "]" | "[" ws string ("," ws string)* ws "]"
numberlist ::= "[" ws "]" | "[" ws string ("," ws number)* ws "]"
"#;
const EXIT_SEED_BNF: &'static str = r#"
root ::= ExitSeed
ExitSeed ::= "{" ws "\"name\":" ws string "," ws "\"direction\":" ws string "," ws "\"region\":" ws string "}"
ExitSeedlist ::= "[]" | "[" ws ExitSeed ("," ws ExitSeed)* "]"
string ::= "\"" ([^"]*) "\""
boolean ::= "true" | "false"
ws ::= [ \t\n]*
number ::= [0-9]+ "."? [0-9]*
stringlist ::= "[" ws "]" | "[" ws string ("," ws string)* ws "]"
numberlist ::= "[" ws "]" | "[" ws string ("," ws number)* ws "]"
"#;
const PERSON_DETAILS_BNF: &'static str = r#"
root ::= PersonDetails
Item ::= "{" ws "\"name\":" ws string "," ws "\"category\":" ws string "," ws "\"rarity\":" ws string "}"
Itemlist ::= "[]" | "[" ws Item ("," ws Item)* "]"
PersonDetails ::= "{" ws "\"description\":" ws string "," ws "\"sex\":" ws string "," ws "\"gender\":" ws string "," ws "\"age\":" ws number "," ws "\"residence\":" ws string "," ws "\"items\":" ws Itemlist "," ws "\"currentActivity\":" ws string "}"
PersonDetailslist ::= "[]" | "[" ws PersonDetails ("," ws PersonDetails)* "]"
string ::= "\"" ([^"]*) "\""
boolean ::= "true" | "false"
ws ::= [ \t\n]*
number ::= [0-9]+ "."? [0-9]*
stringlist ::= "[" ws "]" | "[" ws string ("," ws string)* ws "]"
numberlist ::= "[" ws "]" | "[" ws string ("," ws number)* ws "]"
"#;
const SCENE_INSTRUCTIONS: &'static str = r#"
You are running a text-based adventure game. You must design a scene for the text-based adventure game that the user is playing. Your response must be in JSON.
A scene is a room, city, natural landmark, or another specific location in the game world.
The scene must be created with a certain level of fantasticalness:
- `low`: Completely mundane scene, with little to no magical elements. No powerful items or artifacts. No powerful people are present, only common, mundane people.
- `medium`: Magical elements might be present in the scene, along with some notable items or people.
- `high`: High fantasy, a place of great power, where important people congregate, and powerful artifacts are found.
The scene has the following information:
- `name`: The name of the scene, or location where the scene takes place.
- `region`: The greater enclosing region of the scene.
- The region should be specific, like the name of the city, state/province, kingdom, or geographical area.
- The are should not be a description of where the scene is located. It must be a specifically named place.
- `description`: A description of the scene, directed at the player.
- `exits`: A handful of cardinal directions or new scenes to which the player can use to move to a new scene, either in the same region, or a completely different region. Exits have their own fields.
- `direction`: This must be cardinal or relative direction of the exit. Examples: `north`, `south`, `east`, `west`, `up`, `down`, `nearby`, `in`, `out`.
- `name`: This should be the name name of the new scene that the exit leads to. This must NOT be a direction (like `north`, `south`, `up`, `down`, `in`, `out`, etc).
- `region`: This should be the greater enclosing region of the scene that this exit leads to.
More instructions for the `exits` field of a scene:
- The name of an exit must be thematically appropriate.
- All exit directions must be unique. Do not include the same direction twice.
- Make sure the `name` field does not have the direction in it, as that is already in the `direction` field.
- The `region` field for an exit should be same the `region` as the scene itself, if the exit leads somewhere else in the same general area.
- IF the exit leads to a different region, the `region` should be a different value, leading the player to a new region of the world.
The scene should also be populated with the following entities:
- People: Interesting people (not including the player themselves)
- Items: Weapons, trinkets, currency, utensils, and other equipment.
- Props: Various features in the scene which may or may not have a purpose.
A scene is NOT required to have these entities. A scene can have 0 people, items, or props. It should generally have at least one entity.
Do not generate more than 10 entities.
Generate this data as a structured response.
"#;
const SCENE_CREATION_PROMPT: &'static str = r#"
[INST]
{SCENE_INSTRUCTIONS}
The requested type of scene is: `{}`
The requested amount of fantasticalness is: `{}`
[/INST]
"#;
const SCENE_FROM_STUB_PROMPT: &'static str = r#"
[INST]
{SCENE_INSTRUCTIONS}
## Creation of THIS scene
Create the scene and determine its fantasticalness (`low`, `medium`, or `high`) from the provided name and region.
- The scene connected to this one is provided for contextual information.
- The player arrived from the Connected Scene.
- The newly created scene MUST include the Connected Scene as an exit, in the opposite direction the player entered this new scene.
- Example: If player went `east` to arrive here, the Connected Scene should be `west`.
- Example: If player went `in` to arrive here, the Connected Scene should be `out`.
- The `scene_key` field of this exit MUST be the `key` of the Connected Scene.
- The `scene_id` field of this exit MUST be the `id` of the Connected Scene.
## Scene to Create
Name of scene to create: `{SCENE_NAME}`
Region of scene to create: `{SCENE_REGION}`
## Connected Scene Information
The Connected Scene is the scene that the player just arrived from. Use the connected scene as context for building the new scene.
Basic Connected Scene Information:
- Connected Scene ID: `{CONNECTED_SCENE_ID}`
- Connected Scene Key: `{CONNECTED_SCENE_KEY}`
- Connected Scene Name: `{CONNECTED_SCENE_NAME}`
- Connected Scene Region: `{CONNECTED_SCENE_REGION}`
- Connected Scene Direction: `{CONNECTED_SCENE_DIRECTION}`
### Connected Scene Description
{CONNECTED_SCENE_DESCRIPTION}
[/INST]
"#;
const PERSON_CREATION_PROMPT: &'static str = r#"
[INST]
You are running a text-based adventure game. Your response must be in JSON.
Fill in the details of the person below. This person is a character in a text-based adventure game. Use the person's basic information (name, race, occupation), along with information about the scene, to fill in details about this character. The character is in this scene. The following information needs to be generated:
- `age`: How old the person is, in years. This age should be appropriate for the person's race.
- `sex`: The physical sex of the character. This must always be `male` or `female`.
- `gender`: The self-identified gender of the character.
- This is usually the same value as `sex`, but not always, as characters are, very rarely, trans.
- Valid values for `gender` are `male`, `female`, and `nonbinary`.
- `description`: A long, detailed physical description of the character.
- What they look like, the color of their hair, skin, eyes.
- What clothes they are wearing.
- Their facial expression.
- Details about how they move and act. How they sound when they talk.
- `residence`: Where the person lives. This place does not need to be located in the current scene.
- A mundane person, like a peasant, worker, or merchant, would likely have a home in the current scene.
- People that are more fantastical in nature, or more powerful, might have a residence outside the current scene.
- `items`: Any items or equipment that the person currently has in their possession.
- The items and equipment should be relevant to what they are currently doing.
- `currentActivity`: What the person is currently doing in the scene.
- This is narrative text, that has no effect on the state of the player or the person.
## Person Information
- Name: `{NAME}`
- Race: `{RACE}`
- Occupation: `{OCCUPATION}`
## Scene Information
{SCENE_INFO}
[/INST]
"#;
const SCENE_INFO_FOR_PERSON: &'static str = r#"
Basic scene information:
- Scene Name: {NAME}
- Scene REGION: {REGION}
Extended scene description:
{DESCRIPTION}
"#;
const FIX_EXIT_PROMPT: &'static str = r#"
This is an exit in a scene that was determined to be invalid. Fix the exit by giving it a better name, and making sure the direction makes sense. The scene's name and description is provided below for reference.
- The `name` field should be the name of the place that the player would go from this scene.
- The `name` field must not be the name of the scene below.
- The `name` field must not be a cardinal or relative direction.
- Example: `north`, `south`, `east`, `west` are NOT valid exit names.
- Example: `up`, `down`, `in`, `out` are NOT valid exit names.
- Keep the same `direction` field, if the direction is already a proper direction word.
- The `direction` field should be `north`, `south`, `west`, `east`, `in`, `out`, etc.
- The `direction` field is the direction the player goes to get to the new place.
Do NOT use any of the directions below for the fixed exit. Prefer using the original `direction`, if possible.
**Do not use these directions in the fixed exit:**
{OTHER_DIRECTIONS}
## Invalid Exit Information
**Invalid Exit Name:** `{INVALID_EXIT_NAME}`
**Invalid Exit Direction:** `{INVALID_EXIT_DIRECTION}`
- Keep this `direction`, if the direction is a valid direction word.
- If it is not valid, change it to something else.
- If the direction is changed to something else, it must NOT be one of the directions you cannot use (see above).
## Scene Information
**Scene Name:** `{SCENE_NAME}`
Do **NOT** use this Scene Name as a `name` for the new exit.
**Scene Description**
{SCENE_DESCRIPTION}
"#;
fn scene_info_for_person(scene: &SceneSeed) -> String {
SCENE_INFO_FOR_PERSON
.replacen("{NAME}", &scene.name, 1)
.replacen("{REGION}", &scene.region, 1)
.replacen("{DESCRIPTION}", &scene.description, 1)
}
pub fn scene_creation_prompt(scene_type: &str, fantasticalness: &str) -> AiPrompt {
AiPrompt::creative_with_grammar_and_size(
&SCENE_CREATION_PROMPT
.replacen("{SCENE_INSTRUCTIONS}", SCENE_INSTRUCTIONS, 1)
.replacen("{}", scene_type, 1)
.replacen("{}", fantasticalness, 1),
SCENE_BNF,
1024,
)
}
pub fn fix_exit_prompt(scene: &Scene, invalid_exit: &Exit) -> AiPrompt {
let other_directions: String = scene
.exits
.iter()
.map(|exit| format!("- `{}`", exit.direction))
.collect::<Vec<_>>()
.join("\n");
AiPrompt::new_with_grammar_and_size(
&FIX_EXIT_PROMPT
.replacen("{INVALID_EXIT_NAME}", &invalid_exit.name, 1)
.replacen("{INVALID_EXIT_DIRECTION}", &invalid_exit.direction, 1)
.replacen("{SCENE_NAME}", &scene.name, 1)
.replacen("{SCENE_DESCRIPTION}", &scene.description, 1)
.replacen("{OTHER_DIRECTIONS}", &other_directions, 1),
EXIT_SEED_BNF,
1024,
)
}
pub fn scene_from_stub_prompt(connected_scene: &Scene, stub: &SceneStub) -> AiPrompt {
let connected_scene_id = connected_scene._id.as_deref().unwrap_or("");
let connected_scene_key = connected_scene._key.as_deref().unwrap_or("");
let connected_direction = connected_scene
.exits
.iter()
.find(|exit| Some(&exit.scene_key) == stub._key.as_ref())
.map(|exit| exit.direction.as_ref())
.unwrap_or("back");
AiPrompt::creative_with_grammar_and_size(
&SCENE_FROM_STUB_PROMPT
.replacen("{SCENE_INSTRUCTIONS}", SCENE_INSTRUCTIONS, 1)
.replacen("{CONNECTED_SCENE_NAME}", &connected_scene.name, 1)
.replacen("{CONNECTED_SCENE_REGION}", &connected_scene.region, 1)
.replacen("{CONNECTED_SCENE_DIRECTION}", &connected_direction, 1)
.replacen("{CONNECTED_SCENE_KEY}", connected_scene_key, 1)
.replacen("{CONNECTED_SCENE_ID}", connected_scene_id, 1)
.replacen(
"{CONNECTED_SCENE_DESCRIPTION}",
&connected_scene.description,
1,
)
.replacen("{SCENE_NAME}", &stub.name, 1)
.replacen("{SCENE_REGION}", &stub.region, 1),
SCENE_BNF,
1024,
)
}
pub fn person_creation_prompt(scene: &SceneSeed, person: &PersonSeed) -> AiPrompt {
AiPrompt::creative_with_grammar_and_size(
&PERSON_CREATION_PROMPT
.replacen("{NAME}", &person.name, 1)
.replacen("{RACE}", &person.race, 1)
.replacen("{OCCUPATION}", &person.occupation, 1)
.replacen("{SCENE_INFO}", &scene_info_for_person(scene), 1),
PERSON_DETAILS_BNF,
1024,
)
}

197
src/commands/mod.rs Normal file
View File

@ -0,0 +1,197 @@
use crate::{
db::Database,
models::commands::{
CommandEvent, CommandExecution, EventCoherenceFailure, EventConversionError,
EventConversionFailures, ExecutionConversionResult, RawCommandEvent, RawCommandExecution,
},
};
use anyhow::Result;
use futures::stream::{self, StreamExt, TryStreamExt};
use itertools::{Either, Itertools};
use std::convert::TryFrom;
use strum::VariantNames;
type EventConversionResult = std::result::Result<CommandEvent, EventConversionError>;
type RefEventConversionResult<'a> = std::result::Result<&'a CommandEvent, EventConversionError>;
impl CommandEvent {
pub fn new(raw_event: RawCommandEvent) -> EventConversionResult {
let event_name = raw_event.event_name.as_str().to_lowercase();
if Self::VARIANTS.contains(&event_name.as_str()) {
deserialize_recognized_event(raw_event)
} else {
Err(EventConversionError::UnrecognizedEvent(raw_event))
}
}
}
impl TryFrom<RawCommandEvent> for CommandEvent {
type Error = EventConversionError;
fn try_from(raw_event: RawCommandEvent) -> Result<Self, Self::Error> {
CommandEvent::new(raw_event)
}
}
/// Internal struct to hold the narrative parts of the
/// RawCommandExecution to minimize clones.
struct Narrative {
valid: bool,
reason: Option<String>,
narration: String,
}
fn from_raw_success(raw: Narrative, events: Vec<CommandEvent>) -> CommandExecution {
CommandExecution {
events,
valid: raw.valid,
reason: match &raw.reason {
Some(reason) if !raw.valid && reason.is_empty() => {
Some("invalid for unknown reason".to_string())
}
Some(_) if !raw.valid => raw.reason.clone(),
_ => None,
},
narration: raw.narration.clone(),
}
}
pub async fn convert_raw_execution(
mut raw_exec: RawCommandExecution,
db: &Database,
) -> ExecutionConversionResult {
if !raw_exec.valid {
return ExecutionConversionResult::Success(CommandExecution::from_raw_invalid(raw_exec));
}
let narrative = Narrative {
valid: raw_exec.valid,
reason: raw_exec.reason.take(),
narration: std::mem::take(&mut raw_exec.narration),
};
let conversions: Vec<_> = raw_exec
.events
.into_iter()
.map(|raw_event| CommandEvent::new(raw_event))
.collect();
let (converted, conversion_failures): (Vec<_>, Vec<_>) =
conversions.into_iter().partition_map(|res| match res {
Ok(converted_event) => Either::Left(converted_event),
Err(err) => Either::Right(err),
});
// Coherence check of converted events.
let (successes, incoherent_events): (Vec<_>, Vec<_>) = stream::iter(converted.into_iter())
.then(|event| check_event_coherence(db, event))
.collect::<Vec<_>>()
.await
.into_iter()
.partition_map(|res| match res {
Ok(event) => Either::Left(event),
Err(err) => Either::Right(err),
});
let failure_len = conversion_failures.len() + incoherent_events.len();
if successes.len() > 0 && failure_len == 0 {
ExecutionConversionResult::Success(from_raw_success(narrative, successes))
} else if successes.len() > 0 && failure_len > 0 {
let converted_execution = from_raw_success(narrative, successes);
let failures =
EventConversionFailures::from_failures(conversion_failures, incoherent_events);
ExecutionConversionResult::PartialSuccess(converted_execution, failures)
} else {
ExecutionConversionResult::Failure(EventConversionFailures::from_failures(
conversion_failures,
incoherent_events,
))
}
}
fn deserialize_recognized_event(
raw_event: RawCommandEvent,
) -> Result<CommandEvent, EventConversionError> {
let event_name = raw_event.event_name.as_str().to_lowercase();
let event_name = event_name.as_str();
match event_name {
// scene-related
"change_scene" => Ok(CommandEvent::ChangeScene {
scene_key: raw_event
.parameter
.strip_prefix("scenes/")
.map(String::from)
.unwrap_or(raw_event.parameter)
.clone(),
}),
// bodily position-related
"stand" => Ok(CommandEvent::Stand {
target: raw_event.applies_to,
}),
"sit" => Ok(CommandEvent::Sit {
target: raw_event.applies_to,
}),
"prone" => Ok(CommandEvent::Prone {
target: raw_event.applies_to,
}),
"crouch" => Ok(CommandEvent::Crouch {
target: raw_event.applies_to,
}),
// combat-related
"take_damage" => deserialize_take_damage(raw_event),
// miscellaneous
"narration" => Ok(CommandEvent::Narration(raw_event.parameter)),
// unrecognized
_ => Err(EventConversionError::UnrecognizedEvent(raw_event)),
}
}
fn deserialize_take_damage(
raw_event: RawCommandEvent,
) -> Result<CommandEvent, EventConversionError> {
match raw_event.parameter.parse::<u32>() {
Ok(dmg) => Ok(CommandEvent::TakeDamage {
target: raw_event.applies_to,
amount: dmg,
}),
Err(_) => Err(EventConversionError::InvalidParameter(raw_event)),
}
}
async fn check_event_coherence<'a>(
db: &Database,
event: CommandEvent,
) -> std::result::Result<CommandEvent, EventCoherenceFailure> {
match event {
CommandEvent::ChangeScene { ref scene_key } => match db.stage_exists(&scene_key).await {
Ok(exists) => match exists {
true => Ok(event),
false => Err(invalid_converted_event(event).unwrap()),
},
Err(err) => Err(invalid_converted_event_because_err(event, err)),
},
_ => Ok(event),
}
}
fn invalid_converted_event(event: CommandEvent) -> Option<EventCoherenceFailure> {
match event {
CommandEvent::ChangeScene { .. } => Some(EventCoherenceFailure::TargetDoesNotExist(event)),
_ => None,
}
}
fn invalid_converted_event_because_err(
event: CommandEvent,
err: anyhow::Error,
) -> EventCoherenceFailure {
EventCoherenceFailure::OtherError(event, format!("{}", err))
}

420
src/db/mod.rs Normal file
View File

@ -0,0 +1,420 @@
use crate::models::commands::{CachedCommand, Command, Commands};
use crate::models::world::scenes::{Scene, Stage, StageOrStub};
use crate::models::{Content, ContentContainer, Insertable};
use anyhow::Result;
use arangors::document::options::InsertOptions;
use arangors::graph::{EdgeDefinition, Graph};
use arangors::transaction::{TransactionCollections, TransactionSettings};
use arangors::uclient::reqwest::ReqwestClient;
use arangors::{
AqlQuery, ClientError, Collection, Database as ArangoDatabase, Document, GenericConnection,
};
use serde::{Deserialize, Serialize};
use serde_json::value::to_value as to_json_value;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
mod queries;
/// Type alias for how we're storing IDs in DB. WOuld prefer to have
/// strong UUIDs in code and strings in DB.
pub type Key = String;
enum CollectionType {
Document,
Edge,
}
// Document Collections
const CMD_COLLECTION: &'static str = "command_cache";
const SCENE_COLLECTION: &'static str = "scenes";
const REGION_COLLECTION: &'static str = "regions";
const PEOPLE_COLLECTION: &'static str = "people";
const ITEMS_COLLECTION: &'static str = "items";
const PROPS_COLLECTION: &'static str = "props";
const RACES_COLLECTION: &'static str = "races";
const OCCUPATIONS_COLLECTION: &'static str = "occupations";
// Edge collections
const GAME_WORLD_EDGES: &'static str = "game_world";
const PERSON_ATTRS: &'static str = "person_attributes";
// Graphs
const GAME_WORLD_GRAPH: &'static str = "world";
const DOC_COLLECTIONS: &'static [&str] = &[
CMD_COLLECTION,
SCENE_COLLECTION,
REGION_COLLECTION,
PEOPLE_COLLECTION,
ITEMS_COLLECTION,
PROPS_COLLECTION,
RACES_COLLECTION,
OCCUPATIONS_COLLECTION,
];
const EDGE_COLLECTIONS: &'static [&str] = &[GAME_WORLD_EDGES, PERSON_ATTRS];
// Change if we decide to use a different HTTP client.
type ArangoHttp = ReqwestClient;
type ActiveDatabase = ArangoDatabase<ArangoHttp>;
type ArangoResult<T> = std::result::Result<T, ClientError>;
/// Generic edge that relates things back and forth, where the
/// relation property determines what kind of relation we actually
/// have.
#[derive(Serialize, Deserialize, Debug)]
struct Edge {
_from: String,
_to: String,
relation: String,
}
/// Convert an Arango response for a single document, which may be
/// missing, into an Option type. Bubble up any other errors.
fn extract_document<T>(document: ArangoResult<Document<T>>) -> Result<Option<T>> {
match document {
Ok(doc) => Ok(Some(doc.document)),
Err(db_err) => match db_err {
ClientError::Arango(ref arr_err) => {
if arr_err.error_num() == 1202 {
Ok(None)
} else {
Err(db_err.into())
}
}
_ => Err(db_err.into()),
},
}
}
fn take_first<T>(mut vec: Vec<T>) -> Option<T> {
if vec.get(0).is_none() {
None
} else {
Some(vec.swap_remove(0))
}
}
fn insert_opts() -> InsertOptions {
InsertOptions::builder()
.silent(false)
.return_new(true)
.build()
}
fn is_scene_stub(value: &JsonValue) -> bool {
value
.as_object()
.and_then(|v| v.get("scene"))
.and_then(|scene| scene.get("isStub"))
.and_then(|is_stub| is_stub.as_bool())
.unwrap_or(false)
}
async fn insert_single<T>(collection: &Collection<ArangoHttp>, value: &mut T) -> Result<()>
where
T: Insertable + Clone + Serialize,
{
let doc = to_json_value(&value)?;
let resp = collection.create_document(doc, insert_opts()).await?;
let header = resp.header().unwrap();
value.set_key(header._key.clone());
value.set_id(header._id.clone());
Ok(())
}
#[derive(Serialize, Deserialize)]
struct UpsertResponse {
pub _id: String,
pub _key: String,
}
async fn upsert_scene(db: &ActiveDatabase, scene: &mut Scene) -> Result<()> {
let scene_json = serde_json::to_string(&scene)?;
let query = queries::UPSERT_SCENE.replace("<SCENE_JSON>", &scene_json);
let aql = AqlQuery::builder()
.query(&query)
.bind_var("@scene_collection", SCENE_COLLECTION)
.bind_var("scene_key", to_json_value(&scene._key).unwrap())
.build();
//db.aql_bind_vars::<JsonValue>(&query, vars).await?;
let resp = take_first(db.aql_query::<UpsertResponse>(aql).await?)
.expect("did not get upsert response");
scene._id = Some(resp._id);
scene._key = Some(resp._key);
Ok(())
}
fn content_collection(content: &Content) -> &'static str {
match content {
Content::Scene(_) => SCENE_COLLECTION,
Content::SceneStub(_) => SCENE_COLLECTION,
Content::Person(_) => PEOPLE_COLLECTION,
Content::Item(_) => ITEMS_COLLECTION,
}
}
pub struct Database {
conn: arangors::GenericConnection<ArangoHttp>,
world_name: String,
}
impl Database {
pub async fn new(conn: GenericConnection<ArangoHttp>, world_name: &str) -> Result<Database> {
let db = Database {
conn,
world_name: world_name.to_string(),
};
db.init().await?;
Ok(db)
}
async fn init(&self) -> Result<()> {
let dbs = self.conn.accessible_databases().await?;
if !dbs.contains_key(&self.world_name) {
self.conn.create_database(&self.world_name).await?;
}
self.create_collections(CollectionType::Document, DOC_COLLECTIONS)
.await?;
self.create_collections(CollectionType::Edge, EDGE_COLLECTIONS)
.await?;
self.create_graphs().await?;
Ok(())
}
async fn create_collections(&self, coll_type: CollectionType, names: &[&str]) -> Result<()> {
let db = self.db().await?;
let in_db = db.accessible_collections().await?;
for name in names {
if in_db.iter().find(|info| info.name == *name).is_none() {
match coll_type {
CollectionType::Document => db.create_collection(&name).await?,
CollectionType::Edge => db.create_edge_collection(&name).await?,
};
}
}
Ok(())
}
async fn create_graphs(&self) -> Result<()> {
let db = self.db().await?;
let in_db = db.graphs().await?.graphs;
if in_db
.iter()
.find(|graph| graph.name == GAME_WORLD_GRAPH)
.is_none()
{
let edge_def = EdgeDefinition {
collection: GAME_WORLD_EDGES.to_string(),
from: vec![SCENE_COLLECTION.to_string()],
to: vec![
ITEMS_COLLECTION.to_string(),
REGION_COLLECTION.to_string(),
OCCUPATIONS_COLLECTION.to_string(),
PEOPLE_COLLECTION.to_string(),
PROPS_COLLECTION.to_string(),
RACES_COLLECTION.to_string(),
],
};
let world_graph = Graph::builder()
.edge_definitions(vec![edge_def])
.name(GAME_WORLD_GRAPH.to_string())
.build();
db.create_graph(world_graph, false).await?;
}
Ok(())
}
async fn db(&self) -> Result<ArangoDatabase<ArangoHttp>> {
let db = self.conn.db(&self.world_name).await?;
Ok(db)
}
async fn collection(&self, name: &str) -> Result<Collection<ArangoHttp>> {
let coll = self.db().await?.collection(name).await?;
Ok(coll)
}
pub async fn store_content(&self, container: &mut ContentContainer) -> Result<()> {
let txn_settings = TransactionSettings::builder()
.collections(
TransactionCollections::builder()
.write(vec![
SCENE_COLLECTION.to_string(),
PEOPLE_COLLECTION.to_string(),
ITEMS_COLLECTION.to_string(),
GAME_WORLD_EDGES.to_string(),
])
.build(),
)
.build();
let txn = self.db().await?.begin_transaction(txn_settings).await?;
// First, all contained content must be inserted.
for relation in container.contained.as_mut_slice() {
let collection = content_collection(&relation.content);
self.store_single_content(collection, &mut relation.content)
.await?;
}
// Now insert the container/owner content + relations
let collection = content_collection(&container.owner);
self.store_single_content(collection, &mut container.owner)
.await?;
self.relate_content(&container).await?;
txn.commit_transaction().await?;
Ok(())
}
async fn relate_content(&self, container: &ContentContainer) -> Result<()> {
let game_world = self.collection(GAME_WORLD_EDGES).await?;
let owner_id = container
.owner
.id()
.expect("Did not get an ID from inserted object!");
for relation in container.contained.as_slice() {
let content_id = relation
.content
.id()
.expect("Did not get ID from inserted contained object!");
let outbound = Edge {
_from: owner_id.to_string(),
_to: content_id.to_string(),
relation: relation.outbound.clone(),
};
let inbound = Edge {
_from: content_id.to_string(),
_to: owner_id.to_string(),
relation: relation.inbound.clone(),
};
game_world
.create_document(outbound, InsertOptions::default())
.await?;
game_world
.create_document(inbound, InsertOptions::default())
.await?;
}
Ok(())
}
pub async fn store_single_content(&self, coll_name: &str, content: &mut Content) -> Result<()> {
let collection = self.collection(coll_name).await?;
match content {
//Content::Scene(ref mut scene) => insert_single(&collection, scene).await?,
Content::Scene(ref mut scene) => upsert_scene(&self.db().await?, scene).await?,
Content::SceneStub(ref mut stub) => insert_single(&collection, stub).await?,
Content::Person(ref mut person) => insert_single(&collection, person).await?,
Content::Item(ref mut item) => insert_single(&collection, item).await?,
};
Ok(())
}
pub async fn load_stage(&self, scene_key: &str) -> Result<Option<StageOrStub>> {
let mut vars = HashMap::new();
vars.insert("scene_key", to_json_value(&scene_key).unwrap());
vars.insert("@scene_collection", SCENE_COLLECTION.into());
let db = self.db().await?;
let res = db
.aql_bind_vars::<JsonValue>(queries::LOAD_STAGE, vars)
.await?;
let maybe_stage = take_first(res);
if let Some(stage) = maybe_stage {
let stage_or_stub = if is_scene_stub(&stage) {
// The stub is embedded in the scene field of the result.
StageOrStub::Stub(serde_json::from_value(
stage.get("scene").cloned().unwrap(),
)?)
} else {
StageOrStub::Stage(serde_json::from_value(stage)?)
};
Ok(Some(stage_or_stub))
} else {
Ok(None)
}
}
pub async fn stage_exists(&self, scene_key: &str) -> Result<bool> {
let mut vars = HashMap::new();
vars.insert("scene_key", to_json_value(&scene_key).unwrap());
vars.insert("@scene_collection", SCENE_COLLECTION.into());
let db = self.db().await?;
let stage_count = db
.aql_bind_vars::<JsonValue>(queries::LOAD_STAGE, vars)
.await?
.len();
Ok(stage_count > 0)
}
pub async fn cache_command(
&self,
raw_cmd: &str,
scene: &Scene,
parsed_cmds: &Commands,
) -> Result<()> {
let collection = self.collection(CMD_COLLECTION).await?;
let doc = CachedCommand {
raw: raw_cmd.to_string(),
scene_key: scene._key.as_ref().cloned().expect("scene is missing key"),
commands: parsed_cmds.clone(),
};
collection.create_document(doc, insert_opts()).await?;
Ok(())
}
pub async fn load_cached_command(
&self,
raw_cmd: &str,
scene: &Scene,
) -> Result<Option<CachedCommand>> {
let scene_key = scene._key.as_deref();
let aql = AqlQuery::builder()
.query(queries::LOAD_CACHED_COMMAND)
.bind_var("@cache_collection", CMD_COLLECTION)
.bind_var("raw_cmd", to_json_value(raw_cmd)?)
.bind_var("scene_key", to_json_value(scene_key)?)
.build();
let results = self.db().await?.aql_query(aql).await?;
Ok(take_first(results))
}
}

39
src/db/queries.rs Normal file
View File

@ -0,0 +1,39 @@
pub const LOAD_STAGE: &'static str = r#"
FOR scene IN @@scene_collection
FILTER scene._key == @scene_key
LET occupants = (FOR v, edge IN OUTBOUND scene._id GRAPH 'world'
FILTER edge.relation == "scene-has-person"
RETURN v)
LET items = (FOR v, edge IN OUTBOUND scene._id GRAPH 'world'
FILTER edge.relation == "item-located-at"
RETURN v)
LET exits = (FOR v, edge in OUTBOUND scene._id GRAPH 'world'
FILTER edge.relation == "connects-to"
FOR exit in scene.exits || [] // Stubs have no exits field
FILTER exit.scene_key == v._key
RETURN MERGE(exit, { scene_id: v._id }))
RETURN {
"id": scene._id,
"key": scene._key,
"scene": MERGE(scene, { "exits": exits }),
"people": occupants,
"items": items,
}
"#;
pub const UPSERT_SCENE: &'static str = r#"
UPSERT { _key: @scene_key }
INSERT <SCENE_JSON>
UPDATE <SCENE_JSON>
IN @@scene_collection
RETURN { "_id": NEW._id, "_key": NEW._key }
"#;
pub const LOAD_CACHED_COMMAND: &'static str = r#"
FOR cmd IN @@cache_collection
FILTER cmd.raw == @raw_cmd && cmd.scene_key == @scene_key
RETURN cmd
"#;

99
src/game_loop.rs Normal file
View File

@ -0,0 +1,99 @@
use crate::db::Database;
use crate::io::display;
use crate::models::commands::CommandExecution;
use crate::state::GameState;
use anyhow::Result;
use reedline::{DefaultPrompt, Reedline, Signal};
use std::rc::Rc;
pub struct GameLoop {
state: GameState,
db: Rc<Database>,
editor: Reedline,
prompt: DefaultPrompt,
}
impl GameLoop {
pub fn new(state: GameState, db: Rc<Database>) -> GameLoop {
GameLoop {
state,
db,
editor: Reedline::create(),
prompt: DefaultPrompt::default(),
}
}
async fn handle_execution(&mut self, execution: CommandExecution) -> Result<()> {
if !execution.valid {
display!(
"You can't do that: {}",
execution.reason.unwrap_or("for some reason...".to_string())
);
return Ok(());
}
display!("\n\n{}\n\n", execution.narration);
for event in execution.events {
self.state.update(event).await?;
}
Ok(())
}
async fn execute_command(&mut self, cmd: &str) -> Result<CommandExecution> {
let stage = &self.state.current_scene;
let cached_command = self.db.load_cached_command(cmd, &stage.scene).await?;
let execution = if let Some(cached) = cached_command {
self.state
.logic
.execute_parsed(stage, &cached.commands)
.await?
} else {
let (cmds_to_cache, execution) = self.state.logic.execute(stage, cmd).await?;
self.db
.cache_command(cmd, &stage.scene, &cmds_to_cache)
.await?;
execution
};
Ok(execution)
}
async fn handle_input(&mut self, cmd: &str) -> Result<()> {
if !cmd.is_empty() {
let execution = self.execute_command(cmd).await?;
self.handle_execution(execution).await?;
}
Ok(())
}
pub async fn run_loop(&mut self) -> Result<()> {
loop {
display!("{}", self.state.current_scene);
let sig = self.editor.read_line(&self.prompt);
match sig {
Ok(Signal::Success(buffer)) => {
display!("We processed: {}", buffer);
self.handle_input(&buffer).await?;
}
Ok(Signal::CtrlD) | Ok(Signal::CtrlC) => {
display!("\nAborted!");
break;
}
x => {
display!("Event: {:?}", x);
}
}
}
Ok(())
}
}

23
src/io.rs Normal file
View File

@ -0,0 +1,23 @@
#[inline]
pub(crate) fn display_text<S : AsRef<str>>(text: S) {
let text = text.as_ref();
let (columns, _) = crossterm::terminal::size().ok().unwrap_or((80, 25));
let columns: usize = columns.into();
let text = textwrap::wrap(text, columns);
text.into_iter().for_each(|line| {
println!("{}", line);
});
}
macro_rules! display {
($text:expr) => {
crate::io::display_text($text);
};
($fmt:expr, $text:expr) => {
crate::io::display_text(format!($fmt, $text));
};
}
pub(crate) use display;

128
src/kobold_api.rs Normal file
View File

@ -0,0 +1,128 @@
use async_trait::async_trait;
use es::SSE;
use eventsource_client as es;
use futures::{Stream, StreamExt, TryStreamExt};
use serde::{Deserialize, Serialize};
use std::num::NonZeroU64;
use std::time::Duration;
use crate::ai::convo::AiCreativity;
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
fn creativity_to_temperature(creativity: AiCreativity) -> Option<f64> {
match creativity {
AiCreativity::Predictable => Some(0.5),
AiCreativity::Normal => Some(0.7),
AiCreativity::Creative => Some(1.0),
}
}
pub fn create_input(
gen_key: String,
prompt: &str,
grammar: Option<String>,
max_tokens: u64,
retain_gramar_state: bool,
creativity: AiCreativity,
) -> types::GenerationInput {
types::GenerationInput {
genkey: Some(gen_key),
prompt: prompt.to_string(),
grammar: grammar,
grammar_retain_state: retain_gramar_state,
use_default_badwordsids: false,
max_context_length: None,
max_length: NonZeroU64::new(max_tokens),
min_p: None,
mirostat: None,
mirostat_eta: None,
mirostat_tau: None,
rep_pen: Some(1.1),
temperature: creativity_to_temperature(creativity),
tfs: None,
top_a: Some(0.0),
top_p: Some(0.92),
typical: None,
rep_pen_range: Some(320),
top_k: None,
sampler_order: vec![6, 0, 1, 3, 4, 2, 5],
sampler_seed: None,
stop_sequence: vec!["<s>".to_string(), "</s>".to_string()],
}
}
pub struct WrappedGenerationError(String);
impl From<es::Error> for WrappedGenerationError {
fn from(value: es::Error) -> Self {
WrappedGenerationError(format!("{:?}", value))
}
}
#[derive(Serialize, Deserialize)]
struct AIEvent {
token: String,
}
fn create_response_stream(
client: impl es::Client,
) -> impl Stream<Item = Result<String, es::Error>> {
client.stream().map(|sse| {
sse.and_then(|event| match event {
SSE::Event(ev) => serde_json::from_str::<AIEvent>(&ev.data)
.map(|r| r.token)
.map_err(|err| es::Error::Unexpected(Box::new(err))),
SSE::Comment(_) => Ok("".to_string()),
})
})
}
#[async_trait]
pub trait SseGenerationExt {
async fn sse_generate(
&self,
input: types::GenerationInput,
) -> std::result::Result<String, es::Error>;
}
#[async_trait]
impl SseGenerationExt for Client {
async fn sse_generate(
&self,
input: types::GenerationInput,
) -> std::result::Result<String, es::Error> {
let params = serde_json::to_string(&input)?;
let stream_url = format!("{}/extra/generate/stream", self.baseurl());
let reconnect_opts = es::ReconnectOptions::reconnect(true)
.retry_initial(false)
.delay(Duration::from_secs(1))
.backoff_factor(2)
.delay_max(Duration::from_secs(60))
.build();
let client = es::ClientBuilder::for_url(&stream_url)?
.header("accept", "application/json")?
.header("Content-Type", "application/json")?
.method("POST".to_string())
.body(params)
.reconnect(reconnect_opts)
.build();
let mut stream = create_response_stream(client);
let mut response = String::new();
loop {
let maybe_token = stream.try_next().await;
match maybe_token {
Ok(Some(token)) => response.push_str(&token),
Err(es::Error::Eof) => break,
Err(err) => return Err(err),
_ => (),
}
}
Ok(response)
}
}

120
src/main.rs Normal file
View File

@ -0,0 +1,120 @@
use anyhow::Result;
use config::Config;
use game_loop::GameLoop;
use models::world::scenes::{root_scene_id, Stage};
use state::GameState;
use std::{io::stdout, rc::Rc, time::Duration};
use arangors::Connection;
mod ai;
mod commands;
mod db;
mod game_loop;
mod io;
#[allow(dead_code)]
mod kobold_api;
mod models;
mod state;
use crate::{db::Database, models::world::scenes::StageOrStub};
use kobold_api::Client;
struct GameConfig {
pub kobold_endpoint: String,
pub arangodb_endpoint: String,
}
// Needs to be moved somewhere else.
async fn store_root_scene(db: &Database, state: &mut GameState) -> Result<Stage> {
let mut created_scene: crate::models::ContentContainer = state
.logic
.create_scene_with_id(&state.start_prompt, "mundane", root_scene_id())
.await?;
db.store_content(&mut created_scene).await?;
let stage = db
.load_stage(&root_scene_id())
.await?
.expect("could not find root scene")
.stage();
Ok(stage)
}
async fn load_root_scene(db: &Database, state: &mut GameState) -> Result<()> {
let root_scene: Stage = if let Some(stage_or_stub) = db.load_stage(&root_scene_id()).await? {
match stage_or_stub {
StageOrStub::Stage(stage) => stage,
_ => panic!("Root scene was not a Stage!"),
}
} else {
store_root_scene(db, state).await?
};
state.current_scene = root_scene;
Ok(())
}
fn load_config() -> Result<GameConfig> {
let settings = Config::builder()
.add_source(config::File::with_name("config.toml"))
.add_source(config::Environment::with_prefix("AIGAME"))
.build()
.unwrap();
let kobold_endpoint = settings
.get::<Option<String>>("connection.kobold_endpoint")?
.unwrap_or("http://127.0.0.1:5001/api".to_string());
let arangodb_endpoint = settings
.get::<Option<String>>("connection.arangodb_endpoint")?
.unwrap_or("http://localhost:8529".to_string());
Ok(GameConfig {
arangodb_endpoint,
kobold_endpoint,
})
}
#[tokio::main]
async fn main() -> Result<()> {
let config = load_config()?;
println!("Kobold API: {}", config.kobold_endpoint);
println!("ArangoDB: {}", config.arangodb_endpoint);
println!();
let base_client = reqwest::ClientBuilder::new()
.connect_timeout(Duration::from_secs(180))
.pool_idle_timeout(Duration::from_secs(180))
.timeout(Duration::from_secs(180))
.build()?;
let conn = Connection::establish_without_auth(config.arangodb_endpoint).await?;
let client = Rc::new(Client::new_with_client(
&config.kobold_endpoint,
base_client,
));
let db = Rc::new(Database::new(conn, "test_world").await?);
let logic = ai::AiLogic::new(client, &db);
let mut state = GameState {
logic,
db: db.clone(),
current_scene: Stage::default(),
start_prompt: "simple medieval village surrounded by farmlands, with a forest nearby"
.to_string(),
};
load_root_scene(&db, &mut state).await?;
let mut game_loop = GameLoop::new(state, db.clone());
game_loop.run_loop().await?;
Ok(())
}

1
src/models/christmas.txt Normal file
View File

@ -0,0 +1 @@
ho ho ho

View File

@ -0,0 +1,18 @@
use super::world::{scenes::Exit, raw::ExitSeed};
/// Categorize various coherence issues to be re-submitted to the
/// LLM.
pub enum CoherenceFailure<'a> {
/// Exit name is invalid, a direction or something else weird.
InvalidExitName(&'a Exit),
/// Two or more exits share the same name or direction.
DuplicateExits(Vec<&'a Exit>),
}
pub enum SceneFix {
FixedExit {
index: usize,
new: ExitSeed
},
DeleteExit(usize),
}

159
src/models/commands.rs Normal file
View File

@ -0,0 +1,159 @@
use serde::{Deserialize, Serialize};
use strum::{EnumString, EnumVariantNames};
use thiserror::Error;
/// Stored in the database to bypass AI 'parsing' when possible.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CachedCommand {
pub raw: String,
pub scene_key: String,
pub commands: Commands,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Commands {
pub commands: Vec<Command>,
pub count: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Command {
pub verb: String,
pub target: String,
pub location: String,
pub using: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct VerbsResponse {
pub verbs: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct VerbsAndTargets {
pub entries: Vec<VerbAndTargetEntry>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct VerbAndTargetEntry {
pub verb: String,
pub target: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RawCommandExecution {
pub valid: bool,
pub reason: Option<String>,
pub narration: String,
pub events: Vec<RawCommandEvent>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RawCommandEvent {
pub event_name: String,
pub applies_to: String,
pub parameter: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, EnumString, EnumVariantNames)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum CommandEvent {
ChangeScene {
scene_key: String,
},
TakeDamage {
target: String,
amount: u32,
},
Narration(String),
Stand {
target: String,
},
Sit {
target: String,
},
Prone {
target: String,
},
Crouch {
target: String,
},
Unrecognized {
event_name: String,
narration: String,
},
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CommandExecution {
pub valid: bool,
pub reason: Option<String>,
pub narration: String,
pub events: Vec<CommandEvent>,
}
impl CommandExecution {
pub fn empty() -> CommandExecution {
CommandExecution {
valid: true,
reason: None,
narration: "".to_string(),
events: vec![],
}
}
pub fn from_raw_invalid(raw: RawCommandExecution) -> CommandExecution {
CommandExecution {
valid: raw.valid,
reason: raw.reason,
narration: "".to_string(),
events: vec![],
}
}
}
#[derive(Clone, Debug)]
pub enum ExecutionConversionResult {
Success(CommandExecution),
PartialSuccess(CommandExecution, EventConversionFailures),
Failure(EventConversionFailures),
}
#[derive(Clone, Debug)]
pub struct EventConversionFailures {
pub conversion_failures: Vec<EventConversionError>,
pub coherence_failures: Vec<EventCoherenceFailure>,
}
impl EventConversionFailures {
pub fn from_failures(
conversion_failures: Vec<EventConversionError>,
coherence_failures: Vec<EventCoherenceFailure>,
) -> EventConversionFailures {
EventConversionFailures {
conversion_failures,
coherence_failures,
}
}
}
#[derive(Error, Clone, Debug)]
pub enum EventConversionError {
#[error("invalid parameter for {0:?}")]
InvalidParameter(RawCommandEvent),
#[error("unrecognized event - {0:?}")]
UnrecognizedEvent(RawCommandEvent),
}
#[derive(Error, Clone, Debug)]
pub enum EventCoherenceFailure {
#[error("target of command does not exist")]
TargetDoesNotExist(CommandEvent),
#[error("uncategorized coherence failure: {1}")]
OtherError(CommandEvent, String),
}

193
src/models/mod.rs Normal file
View File

@ -0,0 +1,193 @@
use self::world::items::Item;
use self::world::people::Person;
use self::world::scenes::{Scene, SceneStub};
use uuid::Uuid;
// Has to come before any module declarations!
macro_rules! impl_insertable {
($structname: ident) => {
impl Insertable for $structname {
fn id(&self) -> Option<&str> {
self._id.as_deref()
}
fn key(&self) -> Option<&str> {
self._key.as_deref()
}
fn set_id(&mut self, id: String) -> Option<String> {
let old_id = self.take_id();
self._id = Some(id);
old_id
}
fn set_key(&mut self, key: String) -> Option<String> {
let old_key = self.take_key();
self._key = Some(key);
old_key
}
fn take_id(&mut self) -> Option<String> {
self._id.take()
}
fn take_key(&mut self) -> Option<String> {
self._key.take()
}
}
};
}
pub mod commands;
pub mod world;
pub mod coherence;
pub fn new_uuid_string() -> String {
let uuid = Uuid::now_v7();
let mut uuid_str = Uuid::encode_buffer();
let uuid_str = uuid.hyphenated().encode_lower(&mut uuid_str);
uuid_str.to_owned()
}
/// This enables arbitrary outbound relations between game content
/// entities. Usually is something like scene -> person, or person ->
/// item. Can also be like scene -> item, scene -> prop, etc.
#[derive(Debug)]
pub struct ContentContainer {
pub owner: Content,
pub contained: Vec<ContentRelation>,
}
#[derive(Debug)]
pub struct ContentRelation {
pub content: Content,
pub outbound: String,
pub inbound: String,
}
impl ContentRelation {
pub fn person(person: Person) -> ContentRelation {
ContentRelation {
content: Content::Person(person),
outbound: "scene-has-person".to_string(),
inbound: "person-at-scene".to_string(),
}
}
pub fn item(item: Item) -> ContentRelation {
ContentRelation {
content: Content::Item(item),
outbound: "item-located-at".to_string(),
inbound: "item-possessed-by".to_string(),
}
}
pub fn scene_stub(stub: SceneStub) -> ContentRelation {
ContentRelation {
content: Content::SceneStub(stub),
outbound: "connects-to".to_string(),
inbound: "connects-to".to_string(),
}
}
}
pub trait Insertable {
fn id(&self) -> Option<&str>;
fn take_id(&mut self) -> Option<String>;
fn set_id(&mut self, id: String) -> Option<String>;
fn key(&self) -> Option<&str>;
fn take_key(&mut self) -> Option<String>;
fn set_key(&mut self, key: String) -> Option<String>;
}
/// Anything that can be considered unique game content. This is
/// something recorded as a separate entity in the database, rather
/// than being embedded as part of another entity.
#[derive(Debug)]
pub enum Content {
Person(world::people::Person),
Scene(world::scenes::Scene),
SceneStub(world::scenes::SceneStub),
Item(world::items::Item),
}
impl Content {
pub fn as_scene(&self) -> &Scene {
match self {
Self::Scene(ref scene) => scene,
_ => panic!("not a scene"),
}
}
pub fn as_scene_mut(&mut self) -> &mut Scene {
match self {
Self::Scene(ref mut scene) => scene,
_ => panic!("not a scene"),
}
}
}
impl Insertable for Content {
fn id(&self) -> Option<&str> {
match self {
Content::Scene(scene) => scene._id.as_deref(),
Content::SceneStub(stub) => stub._id.as_deref(),
Content::Person(person) => person._id.as_deref(),
Content::Item(item) => item._id.as_deref(),
}
}
fn take_id(&mut self) -> Option<String> {
match self {
Content::Scene(ref mut scene) => scene._id.take(),
Content::SceneStub(ref mut stub) => stub._id.take(),
Content::Person(ref mut person) => person._id.take(),
Content::Item(ref mut item) => item._id.take(),
}
}
fn set_id(&mut self, id: String) -> Option<String> {
let old_id = self.take_id();
match self {
Content::Scene(ref mut scene) => scene._id = Some(id),
Content::SceneStub(ref mut stub) => stub._id = Some(id),
Content::Person(ref mut person) => person._id = Some(id),
Content::Item(ref mut item) => item._id = Some(id),
}
old_id
}
fn key(&self) -> Option<&str> {
match self {
Content::Scene(scene) => scene._key.as_deref(),
Content::SceneStub(stub) => stub._key.as_deref(),
Content::Person(person) => person._key.as_deref(),
Content::Item(item) => item._key.as_deref(),
}
}
fn take_key(&mut self) -> Option<String> {
match self {
Content::Scene(ref mut scene) => scene._key.take(),
Content::SceneStub(ref mut stub) => stub._key.take(),
Content::Person(ref mut person) => person._key.take(),
Content::Item(ref mut item) => item._key.take(),
}
}
fn set_key(&mut self, key: String) -> Option<String> {
let old_key = self.take_key();
match self {
Content::Scene(ref mut scene) => scene._key = Some(key),
Content::SceneStub(ref mut stub) => stub._key = Some(key),
Content::Person(ref mut person) => person._key = Some(key),
Content::Item(ref mut item) => item._key = Some(key),
}
old_key
}
}

62
src/models/world/items.rs Normal file
View File

@ -0,0 +1,62 @@
use crate::models::new_uuid_string;
use serde::{Deserialize, Serialize};
use strum::{EnumString, EnumVariantNames, Display};
use super::super::Insertable;
#[derive(Serialize, Deserialize, Debug, EnumString, EnumVariantNames, Clone, Display)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum Category {
Weapon,
Armor,
Accessory,
Other,
}
#[derive(Serialize, Deserialize, Debug, EnumString, EnumVariantNames, Clone)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum Rarity {
Common,
Uncommon,
Rare,
Mythic,
Legendary,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
pub struct Item {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub _key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub _id: Option<String>,
pub name: String,
pub description: String,
pub category: Category,
pub rarity: Rarity,
pub attributes: Vec<String>,
pub secret_attributes: Vec<String>,
}
impl_insertable!(Item);
impl Default for Item {
fn default() -> Self {
Self {
_key: Some(new_uuid_string()),
_id: None,
name: "".to_string(),
description: "".to_string(),
category: Category::Other,
rarity: Rarity::Common,
attributes: vec![],
secret_attributes: vec![],
}
}
}

5
src/models/world/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod raw;
pub mod items;
pub mod people;
pub mod scenes;

View File

@ -0,0 +1,84 @@
use crate::models::new_uuid_string;
use super::super::Insertable;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use strum::{EnumString, EnumVariantNames};
#[derive(Serialize, Deserialize, Debug, EnumString, EnumVariantNames, Clone)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Sex {
Male,
Female,
}
impl Display for Sex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Female => write!(f, "female"),
Self::Male => write!(f, "male"),
}
}
}
#[derive(Serialize, Deserialize, Debug, EnumString, EnumVariantNames, Clone)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Gender {
Male,
Female,
NonBinary,
}
impl Display for Gender {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Gender::Female => write!(f, "woman"),
Gender::Male => write!(f, "man"),
Gender::NonBinary => write!(f, "nonbinary"),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
pub struct Person {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub _key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub _id: Option<String>,
pub name: String,
pub description: String,
pub age: u32,
pub residence: String,
pub current_activity: String,
pub occupation: String,
pub race: String,
pub sex: Sex,
pub gender: Gender,
}
impl_insertable!(Person);
impl Default for Person {
fn default() -> Self {
Person {
_key: Some(new_uuid_string()),
_id: None,
name: "".to_string(),
description: "".to_string(),
age: 0,
residence: "".to_string(),
current_activity: "".to_string(),
occupation: "".to_string(),
race: "".to_string(),
sex: Sex::Male,
gender: Gender::Male,
}
}
}

75
src/models/world/raw.rs Normal file
View File

@ -0,0 +1,75 @@
/// Raw world information is information generated by the LLM, which
/// needs to be filled in with extra information to be fully complete.
/// Raw information does not have db IDs, or most of the other info an
/// entity might want.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct World {
pub name: String,
}
/// Contains everything needed to generate a DB-backed scene. The info
/// here is a seed for the full creation, which spiders out like a
/// tree structure.
#[derive(Serialize, Deserialize, Debug)]
pub struct SceneSeed {
pub name: String,
pub region: String,
pub description: String,
pub people: Vec<PersonSeed>,
pub items: Vec<ItemSeed>,
pub props: Vec<PropSeed>,
pub exits: Vec<ExitSeed>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ExitSeed {
pub name: String,
pub region: String,
pub direction: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PersonSeed {
pub name: String,
pub occupation: String,
pub race: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PersonDetails {
pub description: String,
pub sex: String,
pub gender: String,
pub age: u32,
pub residence: String,
pub items: Vec<ItemSeed>,
pub current_activity: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ItemSeed {
pub name: String,
pub category: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ItemDetails {
pub description: String,
// Attribtues are interesting features that set this item apart
// from others, and can be useful in commands or actions or
// certain situations.
pub attributes: Vec<String>,
pub secret_attributes: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PropSeed {
pub name: String,
pub description: String,
pub features: Vec<String>,
pub possible_interactions: Vec<String>,
}

292
src/models/world/scenes.rs Normal file
View File

@ -0,0 +1,292 @@
use crate::models::world::people::Person;
use crate::models::{new_uuid_string, Insertable};
use crate::{db::Key, models::world::items::Item};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use super::raw::{ExitSeed, PropSeed};
pub fn root_scene_id() -> &'static String {
static ROOT_SCENE_ID: OnceLock<String> = OnceLock::new();
ROOT_SCENE_ID.get_or_init(|| "__root_scene__".to_string())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Scene {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "_key", default)]
pub _key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "_id", default)]
pub _id: Option<String>,
pub name: String,
pub region: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub is_stub: bool,
#[serde(default)]
pub props: Vec<Prop>,
#[serde(default)]
pub exits: Vec<Exit>,
}
impl_insertable!(Scene);
impl Default for Scene {
fn default() -> Self {
Self {
_key: Some(new_uuid_string()),
_id: None,
name: "".to_string(),
region: "".to_string(),
description: "".to_string(),
is_stub: false,
props: vec![],
exits: vec![],
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SceneStub {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "_key", default)]
pub _key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "_id", default)]
pub _id: Option<String>,
pub name: String,
pub region: String,
#[serde(default)]
pub is_stub: bool,
}
impl_insertable!(SceneStub);
impl Default for SceneStub {
fn default() -> Self {
Self {
_key: None,
_id: None,
name: "".to_string(),
region: "".to_string(),
is_stub: true,
}
}
}
impl From<&Exit> for SceneStub {
fn from(exit: &Exit) -> Self {
Self {
_key: Some(exit.scene_key.clone()),
name: exit.name.clone(),
region: exit.region.clone(),
is_stub: true,
..Default::default()
}
}
}
// The stage is everything: a scene, the people ("actors") in it, the
// props, etc.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Stage {
pub id: String,
pub key: String,
pub scene: Scene,
pub people: Vec<Person>,
pub items: Vec<Item>,
}
impl Default for Stage {
fn default() -> Self {
Self {
id: String::default(),
key: String::default(),
scene: Scene::default(),
people: Vec::default(),
items: Vec::default(),
}
}
}
impl std::fmt::Display for Stage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut output = self.scene.name.clone();
output.push_str("\n\n");
output.push_str(&self.scene.description);
output.push_str("\n\n");
let people = self
.people
.iter()
.map(|p| format!("{} ({} {}) is here.", p.name, p.race, p.occupation))
.collect::<Vec<_>>()
.join("\n");
let items = self
.items
.iter()
.map(|i| format!("A {} is here.", i.name))
.collect::<Vec<_>>()
.join("\n");
let props = self
.scene
.props
.iter()
.map(|p| format!("A {} is here.", p.name.to_ascii_lowercase()))
.collect::<Vec<_>>()
.join("\n");
let exits = self
.scene
.exits
.iter()
.map(|e| format!("{}", e))
.collect::<Vec<_>>()
.join("\n");
if !people.is_empty() {
output.push_str(&people);
output.push_str("\n");
}
if !items.is_empty() {
output.push_str(&items);
output.push_str("\n");
}
if !props.is_empty() {
output.push_str(&props);
output.push_str("\n");
}
if !exits.is_empty() {
output.push_str("\n\nExits:\n");
output.push_str(&exits);
} else {
output.push_str("\n\nExits: seemingly none...");
}
write!(f, "{}", output)
}
}
// key vs id: scene_key is the document id within the scenes
// collection. scene_id is the full collection + key of that scene.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Exit {
pub name: String,
pub region: String,
pub direction: String,
pub scene_key: String,
// will be set when returned from DB, if not manually set.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub scene_id: Option<String>,
}
impl Exit {
pub fn from_connected_scene(scene: &Scene, direction_from: &str) -> Exit {
Exit {
name: scene.name.clone(),
region: scene.region.clone(),
direction: direction_from.to_string(),
scene_key: scene._key.as_ref().cloned().unwrap(),
scene_id: scene._id.clone(),
}
}
}
impl From<ExitSeed> for Exit {
fn from(seed: ExitSeed) -> Self {
Self {
direction: seed.direction,
name: seed.name,
region: seed.region,
scene_key: new_uuid_string(),
scene_id: None, // it will be set by the database.
}
}
}
impl From<&ExitSeed> for Exit {
fn from(seed: &ExitSeed) -> Self {
Self {
direction: seed.direction.clone(),
name: seed.name.clone(),
region: seed.region.clone(),
scene_key: new_uuid_string(),
scene_id: None, // it will be set by the database.
}
}
}
impl From<&mut ExitSeed> for Exit {
fn from(seed: &mut ExitSeed) -> Self {
Self {
direction: seed.direction.clone(),
name: seed.name.clone(),
region: seed.region.clone(),
scene_key: new_uuid_string(),
scene_id: None, // it will be set by the database.
}
}
}
impl std::fmt::Display for Exit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, " - {} ({})", self.name, self.direction)
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Prop {
pub name: String,
pub description: String,
pub features: Vec<String>,
pub possible_interactions: Vec<String>,
}
// This is here because some day we might pormote props to first-calss
// entities in a Stage instance.
impl From<PropSeed> for Prop {
fn from(value: PropSeed) -> Self {
Prop {
name: value.name,
description: value.description,
features: value.features,
possible_interactions: value.possible_interactions,
}
}
}
#[derive(Debug, Clone)]
pub enum StageOrStub {
Stage(Stage),
Stub(SceneStub),
}
impl StageOrStub {
/// Consumes self into Stage type. Panics if not a Stage.
pub fn stage(self) -> Stage {
match self {
Self::Stage(stage) => stage,
_ => panic!("not a stage"),
}
}
}

63
src/state.rs Normal file
View File

@ -0,0 +1,63 @@
use crate::models::Insertable;
use crate::{
ai::AiLogic,
db::Database,
models::{
commands::CommandEvent,
world::scenes::{SceneStub, Stage, StageOrStub},
ContentContainer,
},
};
use anyhow::Result;
use std::rc::Rc;
pub struct GameState {
pub start_prompt: String,
pub logic: AiLogic,
pub db: Rc<Database>,
pub current_scene: Stage,
}
impl GameState {
pub async fn update(&mut self, event: CommandEvent) -> Result<()> {
println!("handling event: {:?}", event);
match event {
CommandEvent::ChangeScene { scene_key } => self.change_scene(&scene_key).await?,
CommandEvent::Narration(narration) => println!("\n\n{}\n\n", narration),
_ => (),
}
Ok(())
}
async fn create_from_stub(&mut self, stub: SceneStub) -> Result<Stage> {
let mut created_scene: ContentContainer = self
.logic
.create_scene_from_stub(stub, &self.current_scene.scene)
.await?;
self.db.store_content(&mut created_scene).await?;
let key = created_scene.owner.key().unwrap();
let stage = self
.db
.load_stage(key)
.await?
.expect("could not find just-created scene")
.stage();
Ok(stage)
}
async fn change_scene(&mut self, scene_key: &str) -> Result<()> {
match self.db.load_stage(scene_key).await? {
Some(stage_or_stub) => match stage_or_stub {
StageOrStub::Stage(stage) => self.current_scene = stage,
StageOrStub::Stub(stub) => self.current_scene = self.create_from_stub(stub).await?,
},
_ => (),
}
Ok(())
}
}