Compare commits

...

27 Commits

Author SHA1 Message Date
deadvey ae74c07948 docs update and moved around the test stories 2026-05-26 23:00:23 +01:00
deadvey 148fb73f7f added support for instring variables and made character IDs
be always stored in lowercase
2026-05-26 21:41:36 +01:00
deadvey cc3eca857d strings in variables can now be received by get_string_token
and inputs can be directly assigned to a variable
2026-05-26 20:35:57 +01:00
deadvey f6a95f76bd Added support for assigning variables to the output of a choice 2026-05-26 17:48:36 +01:00
deadvey 59f32e975b clippy lints and moved the operator matchbox to it's own function 2026-05-26 11:54:53 +01:00
deadvey 4f7abb5f19 added support for if elif else statements by returning a boolean
from the identifier syntax parsing
2026-05-26 11:38:14 +01:00
deadvey 20369ef838 added basic variable functionality, doesn't really do anything at the
moment but you can assign and change variables.
2026-05-25 02:19:30 +01:00
deadvey 21bf659718 Began adding support for variables 2026-05-24 15:09:59 +01:00
deadvey 93ca1ea34a untrack images :skull_emoji: 2026-05-20 21:53:33 +01:00
deadvey 29565949b0 Client is approx 5% done 2026-05-20 21:51:09 +01:00
deadvey 556185e095 added some TODO listing in the README and removed junk file 2026-05-19 19:38:25 +01:00
deadvey 9255c1d7fa Fixed some clippy lints 2026-05-19 19:34:56 +01:00
deadvey ee34493895 Changed the data_to_send to be a stack so many lines of code can
be pre-processed before the user interacts.
When the /happening api is called it just dequeues the front item
2026-05-19 19:23:19 +01:00
deadvey 019f1088a3 fixed a bug with single word strings and multiple choice blocks side by side 2026-05-18 23:05:19 +01:00
deadvey cba3ec04d7 documentation about location of story file 2026-05-18 14:33:30 +01:00
deadvey fbb5a4b503 fixed clippy lints 2026-05-18 14:18:25 +01:00
deadvey 367b3ac396 fixed some formatting and added a note 2026-05-18 14:51:31 +02:00
deadvey 5f294cceb2 documentation 2026-05-18 13:49:35 +01:00
deadvey 051bfe46e4 redesigned how file is tokenised into string, keyword, identifier, bracket and character
added support for GOTOs and removed object recursive calls
2026-05-18 13:22:29 +01:00
deadvey d4947a4434 unwrap_or_exit can now display the error message, requires it to use the Debug trait 2026-05-17 15:52:18 +01:00
deadvey dd04399784 Cleaned code up a bit
TODD:
make colour an enum
make unwrap_or_exit return the error
2026-05-17 15:44:01 +01:00
deadvey 13049309b2 Changed how the data is tokenised 2026-05-17 12:47:22 +01:00
deadvey 0c28bc113d started working on new tokeniser 2026-05-16 21:11:45 +01:00
deadvey 94422b307c fixed some markdown formatting 2026-05-16 14:19:04 +01:00
deadvey 6d012dbe6b added character documentation and split Clothing into a sub-struct
of Character
2026-05-16 14:06:49 +01:00
deadvey 99a5b03290 added animate and to character commands 2026-05-16 12:46:15 +01:00
deadvey a251be7827 added a modify data function for changing the data_to_send mutex 2026-05-16 12:40:00 +01:00
30 changed files with 4239 additions and 1027 deletions
+3
View File
@@ -1,5 +1,8 @@
/server/target /server/target
/client/target
*.swp *.swp
.venv .venv
.~* .~*
report.odt report.odt
examples/
images/
+561 -560
View File
File diff suppressed because it is too large Load Diff
+59 -117
View File
@@ -1,16 +1,18 @@
# HAPPENING # Happening
An interactive story telling software.<br/>
An interactive story telling software.
Read and write people's stories
For help with making a story, see [this documentation](/docs/MAKING_A_STORY.md)
For help with syntax, see [this documentation](/docs/SYNTAX.md)
## Install ## Install
This is not really out of development, but to run it, clone the repo, go into /server/ and use cargo run story.zip to run a file called story.zip that's in the /stories/ directory.
This is not really out of development, but to run it, clone the repo, go into /server/ and use cargo run story.zip to run a file called story.zip, absolute or relative file location.
## Technical Details ## Technical Details
### Back-end server -- Rust ### File layout
The server component of Happening will be written in Rust.<br/>
Parses the code and sends it via the API.<br/>
File layout:<br/>
- animations/ - animations/
- features/ - features/
- scenes/ - scenes/
@@ -26,13 +28,27 @@ File layout:<br/>
- scenes/ - scenes/
- images/ - images/
The variables are stored as a hashmap, characters are objects.<br/> ### Back-end server -- Rust
The server component of Happening will be written in Rust.<br/>
Parses the code and sends it via the API.<br/>
File layout:
The variables are stored as a hashmap, characters are structs in a hashmap.<br/>
### API ### API
Using the network interface, port 20264.<br/> Using the network interface, port 20264.<br/>
> [!NOTE]
> The port is configurable in config.json of the server, client should be flexible with ports.
Characters are sent to the frontend and stored there when the character is created on the frontend.<br/> Characters are sent to the frontend and stored there when the character is created on the frontend.<br/>
There are 4 endpoints.
#### /happening
GET Returns what is happening on the next cycle:
``` ```
{ {
@@ -42,136 +58,63 @@ content: String,
character: String, character: String,
choice: [String,String,String,...]
} }
``` ```
#### /character/\<character name\>
GET Returns the struct of the character, ideally this should only be gotten when a new character is made or a character changes a feature.
#### /choice
POST the choice as a JSON formatting number, eg 1 to choose choice index 1, returns "ack" as acknowledgment.
#### /input
POST not yet fully implemented on the syntax side but allows client to send a user input to the server as text. returns "ack" as acknowlegment.
### Frontend -- Python ### Frontend -- Python
Things the frontend can do:<br/> Things the frontend should be able to:
- Output text - Output text
- Display the character - Display the character
- Move characters around - Move characters around
- Display a choice to the user that gets sent back to the server - Display a choice to the user that gets sent back to the server
### Syntax ### Files
#### Setup For more info on these files see [Making a Story](/docs/MAKING_A_STORY.md)
This is done in the about.json file, #### story.ha
```
{
"title": "My Great Story", This contains the code for the story, syntax for writing the code can be seen in [the syntax section](/docs/SYNTAX.md)
"description": "please read!" #### about.json
} This file contains details about your story such as the title and description.
```
#### Characters #### characters.json
See [Character documentation](/docs/CHARACTER.md) for more info
Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/> Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/>
Customisation is done with @CHARACTER change \<feature\> into \<feature name\><br/> Customisation is done with @CHARACTER change \<feature\> into \<feature name\><br/>
Move a character with @CHARACTER to fr
attributes:<br/> > [!NOTE]
> fr means front-right for instance.
- gender ## TODO
- skin_color - /about.json
- name - tokeniser check for lack of END
- eye_color - Fix no closing brace edge case
- pronoun_subject - Support single quotes for strings
- pronoun_object - backslashes in strings
- pronoun_deppos - Brace index getter check for closing
- pronoun_indpos - Proper Error messages centralised???
- pronoun_reflex
- animation
- head
- hair
- torso
- arm
- leg
- hair_color
- top_clothing
- bottom_clothing
- shoes
#### Outputs
```
@CHARACTER says "this string
is multi-line
and ends with a"
```
#### Variables
Variables are referenced with the \$, only integers will be supported.<br/>
#### Selection
Condition based:
```
if (condition) {
}
elif (condition) {
}
else
}
```
Choice based:
```
choice "choice 1" {
}
or "choice 2" {
}
or "choice 3" {
}
```
#### Positioning
```
@CHARACTER to position
PAN to position
```
#### Other
```
label:
GOTO label
```
#### Ending
`END` to exit out of the story
## Implemented stuff
### Commands
| Command | Implemented |
|-----------------|-------------|
| END | Yes |
| CHOICE/OR/OR | Yes |
| IF/ELSE IF/ELSE | No |
| GOTO | No |
| PAN | No |
### Character sub-commands
| Character Command | Implemented |
|-------------------|-------------|
| SAYS | Yes |
| CHANGE | Yes |
| TO | No |
| ANIMATE | No |
### Other Features
| Feature | Implemented |
| ------- | ----------- |
| Variables | No |
## Error codes ## Error codes
| | | | | | | |
|---------|---------------------------------------------|-----------------------------------------------------------------------------------------| | ------- | ------------------------------------------- | --------------------------------------------------------------------------------------- |
| Code ID | Meaning | Possible remedies | | Code ID | Meaning | Possible remedies |
| 0 | Success | N/A | | 0 | Success | N/A |
| 1 | File unexpectedly reached termination point | Make sure there is an END statement in the code. | | 1 | File unexpectedly reached termination point | Make sure there is an END statement in the code. |
@@ -188,5 +131,4 @@ GOTO label
| 12 | Failed to open archive | Make sure the story file is a zip archive. | | 12 | Failed to open archive | Make sure the story file is a zip archive. |
| 13 | Unable to setup the characters hashmap. | Make sure the characters.json file exists, is valid JSON and contains valid characters. | | 13 | Unable to setup the characters hashmap. | Make sure the characters.json file exists, is valid JSON and contains valid characters. |
| 14 | Unable to read the main story file. | Make sure the story.ha file exists and is readable. | | 14 | Unable to read the main story file. | Make sure the story.ha file exists and is readable. |
| 15 | | | | 15 | Unable to tokenise data | Make sure your code is correctly formatted |
+2151
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "happening-client"
version = "0.1.0"
edition = "2024"
[dependencies]
home = "0.5.12"
macroquad = "0.4.15"
phf = {version="0.13.1",features=["macros"]}
reqwest = {version="0.13.3",features=["blocking","json"]}
serde = {version="1.0.228",features=["derive"]}
serde_json = "1.0.149"
tokio = {version="1.52.3",features=["full"]}
+223
View File
@@ -0,0 +1,223 @@
use macroquad::prelude::*;
use reqwest::*;
use reqwest::blocking;
use phf::phf_map;
use std::
{
thread,
time::Duration,
collections::
{
HashMap,
HashSet,
},
process::exit,
};
use serde::
{
Serialize,
Deserialize,
};
use home::home_dir;
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct Data {
pub action_type: String,
pub content: String,
pub character: String,
pub choices: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(default)]
pub struct Character
{
name: String,
gender: String,
eye_color: Colour,
hair_color: Colour,
skin_color: Colour,
pronoun_subject: String,
pronoun_object: String,
pronoun_deppos: String,
pronoun_indpos: String,
pronoun_reflex: String,
head_shape: String,
hair_style: String,
torso_shape: String,
arm_shape: String,
leg_shape: String,
clothing: Clothing,
}
#[derive(Debug,Deserialize,Serialize,Clone,Default)]
#[serde(default)]
pub struct Clothing
{
top: String,
bottom: String,
shoes: String,
}
#[derive(Debug,Deserialize,Serialize,Clone,Default)]
#[serde(default)]
pub struct Colour
{
red: u8,
green: u8,
blue: u8,
}
static POSITIONS: phf::Map<&'static str, [f32;2]> = phf_map! [
"fl" => [0.0,500.0],
"bl" => [0.0,0.0],
"fr" => [500.0,500.0],
"br" => [500.0,0.0],
];
#[macroquad::main("happening")]
async fn main()
{
let mut characters: HashMap<String, (Character, Texture2D)> = HashMap::new();
characters.insert("narrator".to_string(), (Character { name: "Narrator".to_string(), ..Default::default() }, Texture2D::empty()));
let mut textures: HashMap<String,(Texture2D, f32, f32, Color)> = HashMap::new();
let mut text: String = String::new();
let mut checking_for_choice: bool = false;
let mut data: Data = next_happening(); // First one should be begin
loop
{
clear_background(RED);
for (name, (texture, x, y, colour)) in &textures
{
draw_texture(&texture, *x, *y, *colour);
}
draw_multiline_text(&text, 50.0,30.0,40.0,None,WHITE);
if !is_any_key_down()
{
next_frame().await;
continue
}
let keys: HashSet<KeyCode> = get_keys_pressed();
if checking_for_choice
{
for key in &keys
{
let keycode = *key as u16;
let length: u16 = data.choices.len() as u16;
println!("key: {key:?} {keycode}");
if keycode > 48 && keycode <= length+48
{
checking_for_choice = false;
println!("Sending POST: {}",keycode-49);
let value = keycode - 49;
send_choice(value);
}
else { continue }
}
}
// Get the next character
data = next_happening();
let character_name: String = data.character.to_lowercase();
// Add the character to the HashMap if it's not already
if character_name != "" && !characters.contains_key(&character_name)
{
println!("Fetching {character_name}");
let new_character = get_character(&character_name).await;
characters.insert(character_name.clone(), new_character);
}
// Matchbox for all the commands
match data.action_type.to_lowercase().as_str()
{
"choice" =>
{
for (index, choice) in data.choices.iter().enumerate()
{
text += format!("\n{}. {}",index+1, choice).as_str();
}
checking_for_choice = true;
},
"output" =>
{
println!("SAYING");
text = format!("{}: {}", characters[&character_name].0.name.clone(),wrap_text(&data.content).as_str());
},
"to" =>
{
let position = POSITIONS.get(&data.content).cloned().unwrap();
let texture = &characters[&character_name].1;
textures.insert(character_name.clone(),(texture.clone(), position[0], position[1], WHITE)); // Heavy
}
"begin" => (),
"end" => exit(0),
_ => println!("Unknown action, {}", data.action_type),
}
next_frame().await;
}
}
fn wrap_text(text: &str) -> String
{
text.chars()
.collect::<Vec<_>>()
.chunks(30)
.map(|chunk| chunk.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn next_happening()
-> Data
{
let data: Data = reqwest::blocking::get(format!("http://127.0.0.1:20264/happening")).unwrap().json().unwrap();
println!("{data:?}");
data
}
#[tokio::main]
async fn send_choice(index: u16)
{
let client = reqwest::Client::new();
let res = client.post("http://localhost:20264/choice")
.json(&index)
.send()
.await;
}
async fn get_character(name: &str)
-> (Character, Texture2D)
{
let character: Character = reqwest::blocking::get(format!("http://127.0.0.1:20264/character/{name}")).unwrap().json().unwrap();
println!("{character:?}");
let skin_colour = character.skin_color.clone();
let skin: Color = Color::from_rgba(skin_colour.red,skin_colour.green,skin_colour.blue,255);
let hair_colour = character.hair_color.clone();
let hair: Color = Color::from_rgba(hair_colour.red,hair_colour.green,hair_colour.blue,255);
let head_path: String = format!("/home/deadvey/.local/share/happening/images/head/{}.png",character.head_shape);
let head: Texture2D = change_colour(&mut load_image(head_path.as_str()).await.unwrap(), &skin, &hair);
(character, head)
}
fn change_colour(image: &mut Image, skin: &Color, hair: &Color)
-> Texture2D
{
let target = Color::from_rgba(255,0,255,255);
for y in 0..image.height() {
for x in 0..image.width() {
let pixel = image.get_pixel(x as u32, y as u32);
if pixel == target {
image.set_pixel(x as u32, y as u32, *skin);
}
}
}
let target = Color::from_rgba(0,255,255,255);
for y in 0..image.height() {
for x in 0..image.width() {
let pixel = image.get_pixel(x as u32, y as u32);
if pixel == target {
image.set_pixel(x as u32, y as u32, *hair);
}
}
}
Texture2D::from_image(&image)
}
+4
View File
@@ -0,0 +1,4 @@
killall happening-server
RUST_LOG=debug
happening-server $XDG_DATA_HOME/happening/stories/$1 &
cargo run .
+75
View File
@@ -0,0 +1,75 @@
# Characters
## Character features
| Feature | Examples | Notes |
| --------------- | --------------------------------------------- | ----------------------------------------- |
| Name | Timothy Sharpshooter | |
| Gender | Male, Female, NB, Other | |
| Skin Color | [255,255,255] | RGB colour |
| Eye Color | [14,0,244] | RGB Color |
| Hair Color | [0,128,44] | RGB Color |
| Pronoun Subject | He, She, They | See https://en.wikipedia.org/wiki/Pronoun |
| Pronoun Object | Him, Her, Them | See https://en.wikipedia.org/wiki/Pronoun |
| Pronoun Deppos | His, Her, Their | See https://en.wikipedia.org/wiki/Pronoun |
| Pronoun Indpos | His, Hers, Theirs | See https://en.wikipedia.org/wiki/Pronoun |
| Pronoun Reflex | Himself, Herself, Themselves | See https://en.wikipedia.org/wiki/Pronoun |
| Head Shape | | |
| Torso Shape | | |
| Arm Shape | | |
| Leg Shape | | |
| Hair Style | | |
| Clothing | See [Character Clothing](##Clothing) | |
> [!NOTE]
> Pronouns can be implied and unrequired with a recognised Gender value, however, custom values can be filled in if desired.
## Clothing
| Appearal | Examples | Notes |
| -------- | -------- | ------------------------ |
| Top | | Shirt, Jumper, etc... |
| Bottom | | Trousers, tights, etc... |
| Shoes | | |
| Hat | | |
| Gloves | | |
| Neck | | Necklace, Scarf, etc... |
## Characters.json
Characters are stored in the characters.json file which looks like this:
```
{
"tim": {
"name": "Timothy Sharpshooter",
"gender": "Male",
"skin_color": "",
"eye_color": "",
"hair_color": "",
"pronoun_subject": "He",
"pronoun_object": "Him",
"pronoun_deppos": "His",
"pronoun_indpos": "His",
"pronoun_reflex": "Himself",
"head_shape": "",
"hair_style": "",
"torso_shape": "",
"arm_shape": "",
"leg_shape": "",
"clothing": {
"top": "",
"bottom": "",
"shoes": "",
"hat": "",
"gloves": "",
"neck": ""
}
},
"bob": {
...
}
}
```
> [!NOTE]
> MUST be valid JSON or it can't be deserialised.
+34
View File
@@ -0,0 +1,34 @@
# Making a Story
## Setting up
You need these three files to make a story:
- about.json
- story.ha
- characters.json
## Filling out the files
Inside about.json should be the following content:
```
{
"title": "My Great Story",
"description": "please read!"
}
```
Change the title and description to whatever you would like.<br/>
Inside story.ha should be the code for your story, see [the documentation on coding](/docs/SYNTAX.md) for help with this.<br/>
Finally, inside characters.json should be a JSON formatted object containing all your characters. See [the documentation on characters](/docs/CHARACTERS.md) for help with this file.<br/>
## Making a story file
A story file is just a zip file of these files, so zip up story.ha, characters.json and about.json into a zip file (make sure these are in the root of the zip file).
## Playing your story
To play your story, simply run cargo run \<your zip file\>
+149
View File
@@ -0,0 +1,149 @@
# Syntax
## Setup
This is done in the about.json file,
```
{
"title": "My Great Story",
"description": "please read!"
}
```
## Characters
See [Character documentation](/docs/CHARACTER.md) for more info
Referencing a character using the @, @NARRATOR is reserved for the Narrator.<br/>
Customisation is done with @CHARACTER change \<feature\> \<feature name\><br/>
Move a character with @CHARACTER to fr
Outputting can be done with @CHARACTER says "string", see more at (outputs)[##outputs]
> [!NOTE]
> fr means front-right for instance.
## Variables
Currently only strings and integers are supported as variable types (booleans are planned).<br/>
The interpreter can assume the data type so this doesn't need to be specified.<br/>
To assign new variables (or overwrite existing ones):
```
$x = 1
$an_integer = 3
$y = "hello"
$a_string = "world"
```
> [!NOTE]
> Variable names can be as long as you want and can contain any characters except whitespaces
To modify existing variables:
```
$x + 1 // Adds 1 to an integer x
$x - 1 // Subtracts 1 from an integer x
$y + " world" // Appends " world" to the end of a string y
```
Other uses:
```
$x = choice "choice 1" { // Assigns to x the choice made by the client
...
$x = input // Assigns to x the string input made by the client
```
## Outputs
```
@CHARACTER says "this string
is multi-line
and ends with a"
$x = "hello world"
@CHARACTER says $x
$name = "deadvey"
@CHARACTER says "hello $name"
```
> [!NOTE]
> Strings only support double quotes now ("") and do not support having quotes within quotes.
## Variables
Variables are referenced with the \$, only integers will be supported.<br/>
## Selection
Condition based:
```
if condition {
}
elif condition { // Only gets checked if the if and all previous elif statements failed
}
else // Always passes if all previous if and elif statements failed
}
```
> [!NOTE]
> See [conditions](##conditions)
Choice based:
```
choice "choice 1" {
}
or "choice 2" {
}
or "choice 3" {
}
```
You can assign a variable to the result of a choice by doing the following:
```
$x = choice "choice 1" {
...
```
## Positioning
```
@CHARACTER to position
PAN position
```
## Conditions
A condition works works like this:
```
$x == "hello" // evaluates true only if x is "hello"
$x > 1 // evaulates true if x is an integer and is more than 1
$x < 1 // evaluates true if x is an integer and is less than 1
$x >= 1 // evaluates true if x is an integer and is more than or equal to 1
$x <= 1 // evaluates true if x is an integer and is less than or equal to 1
```
> [!NOTE]
> == can be used for integers or strings whereas the rest can only be used with integers
The order (variable operator value) is currently fixed and so must be layed out like this.
## Other
```
label:
GOTO label
```
## Ending
`END` to exit out of the story
+13 -1
View File
@@ -457,11 +457,13 @@ dependencies = [
[[package]] [[package]]
name = "happening-server" name = "happening-server"
version = "0.0.2" version = "0.0.3"
dependencies = [ dependencies = [
"env_logger", "env_logger",
"log", "log",
"regex",
"serde", "serde",
"serde-patch",
"serde_json", "serde_json",
"tokio", "tokio",
"warp", "warp",
@@ -969,6 +971,16 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-patch"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ba8dcbff6509fa9394810d943e0e9d486a4256c23f6fcea8a58865fbe8260c4"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
+3 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "happening-server" name = "happening-server"
version = "0.0.2" version = "0.0.3"
edition = "2024" edition = "2024"
license = "GPL-3" license = "GPL-3"
repository = "https://git.javalsai.tuxcord.net/deadvey/happening/" repository = "https://git.javalsai.tuxcord.net/deadvey/happening/"
@@ -25,7 +25,9 @@ unwrap_used = "warn"
[dependencies] [dependencies]
env_logger = "0.11.10" env_logger = "0.11.10"
log = "0.4.29" log = "0.4.29"
regex = "1.12.3"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde-patch = "0.2.3"
serde_json = "1.0.149" serde_json = "1.0.149"
tokio = { version = "1.51.0", features = ["rt-multi-thread","macros"] } tokio = { version = "1.51.0", features = ["rt-multi-thread","macros"] }
warp = { version = "0.4.2", features = ["server"] } warp = { version = "0.4.2", features = ["server"] }
+52 -18
View File
@@ -3,11 +3,13 @@ use crate::
{ {
// internal code // internal code
character, character,
UnwrapOrExit,
// libraries // libraries
json, json,
HashMap, HashMap,
Arc, Arc,
Mutex, Mutex,
VecDeque,
config, config,
mpsc::Sender, mpsc::Sender,
info, info,
@@ -17,7 +19,7 @@ use crate::
Deserialize, Deserialize,
}; };
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct DataToSend { pub struct DataToSend {
pub action_type: String, pub action_type: String,
pub content: String, pub content: String,
@@ -30,30 +32,28 @@ pub struct DataToSend {
// tx to allow the program executor to move onto the next bit of code // tx to allow the program executor to move onto the next bit of code
pub async fn api_process pub async fn api_process
( (
data_to_send: Arc<Mutex<DataToSend>>, happening_queue: Arc<Mutex<VecDeque<DataToSend>>>,
characters: Arc<Mutex<HashMap::<String,character::Character>>>, characters: Arc<Mutex<HashMap::<String,character::Character>>>,
tx: Sender<(bool, usize)>, tx: Sender<(usize, String)>,
) )
{ {
// This data must be passed through to the api route in order to be used // This data must be passed through to the api route in order to be used
let data_filter = warp::any().map(move || Arc::clone(&data_to_send)); let happening_queue_filter = warp::any().map(move || Arc::clone(&happening_queue));
let characters_filter = warp::any().map(move || Arc::clone(&characters)); let characters_filter = warp::any().map(move || Arc::clone(&characters));
let tx_filter = warp::any().map(move || tx.clone()); let tx_filter1 = warp::any().map(move || tx.clone());
let tx_filter2 = tx_filter.clone(); let tx_filter2 = tx_filter1.clone();
info!("Running server");
// The server route is loaded at address:port/happening // The server route is loaded at address:port/happening
let main = warp::path("happening") let main = warp::path("happening")
.and(warp::get()) .and(warp::get())
.and(data_filter) .and(happening_queue_filter)
.and(tx_filter)
// Perform this code on a GET request // Perform this code on a GET request
.map(|state: Arc<Mutex<DataToSend>>, tx_handle: Sender<(bool,usize)>| .map(|queue: Arc<Mutex<VecDeque<DataToSend>>>|
{ {
debug!("GET: {state:?}"); //debug!("GET: {state:?}");
let reply = state.as_ref(); let mut queue = queue.lock().unwrap_or_exit("Queue Mutex was poisoned", 2);
let _ = tx_handle.send((true,0)); let reply = queue.pop_front().unwrap_or_default();
drop(queue);
warp::reply::json(&reply) // Send the reply data (data_to_send formatted as JSON) warp::reply::json(&reply) // Send the reply data (data_to_send formatted as JSON)
}).boxed(); }).boxed();
let characters = warp::path("character") let characters = warp::path("character")
@@ -87,17 +87,51 @@ pub async fn api_process
let choice = warp::path("choice") let choice = warp::path("choice")
.and(warp::post()) .and(warp::post())
.and(warp::body::json()) .and(warp::body::json())
.and(tx_filter2) .and(tx_filter1)
.map(|index: usize, tx_handle: Sender<(bool,usize)>| { .map(|index: usize, tx_handle: Sender<(usize,String)>| {
debug!("Choice: {index}"); debug!("Choice: {index}");
let _ = tx_handle.send((true,index)); let _ = tx_handle.send((index,String::new()));
let reply = "ack";
warp::reply::json(&reply)
}).boxed();
let input = warp::path("input")
.and(warp::post())
.and(warp::body::json())
.and(tx_filter2)
.map(|input: String, tx_handle: Sender<(usize, String)>|
{
let _ = tx_handle.send((0,input));
let reply = "ack"; let reply = "ack";
warp::reply::json(&reply) warp::reply::json(&reply)
}).boxed(); }).boxed();
let routes = main.or(characters).or(choice); let routes = main.or(characters).or(choice).or(input);
// Start the server // Start the server
info!("Running server");
warp::serve(routes) warp::serve(routes)
.run(([127, 0, 0, 1],config::API_PORT)) .run(([127, 0, 0, 1],config::API_PORT))
.await; .await;
} }
// On fail, quit safely
// If successful, return nothing
pub fn modify_data // TODO rename
(
happening_queue: &Arc<Mutex<VecDeque<DataToSend>>>,
action_type: String,
content: String,
character: String,
choices: Vec<String>,
)
{
let mut queue = happening_queue.lock().unwrap_or_exit("Data to send Mutex was poisoned",2);
let new_data = DataToSend {
action_type,
content,
character,
choices,
};
debug!("{new_data:?}");
queue.push_back(new_data);
drop(queue);
}
+40 -35
View File
@@ -4,56 +4,57 @@ use crate::{
File, File,
HashMap, HashMap,
debug, debug,
info,
Deserialize, Deserialize,
Serialize, Serialize,
apply_mut,
Mutex, Mutex,
Arc, Arc,
}; };
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(default)]
pub struct Character pub struct Character
{ {
name: String, name: String,
gender: String, gender: String,
eye_color: String, eye_color: Colour,
hair_color: Colour,
skin_color: Colour,
pronoun_subject: String, pronoun_subject: String,
pronoun_object: String, pronoun_object: String,
pronoun_deppos: String, pronoun_deppos: String,
pronoun_indpos: String, pronoun_indpos: String,
pronoun_reflex: String, pronoun_reflex: String,
head: String, head_shape: String,
hair: String, hair_style: String,
torso: String, torso_shape: String,
arm: String, arm_shape: String,
leg: String, leg_shape: String,
hair_color: String, clothing: Clothing,
top_clothing: String, }
bottom_clothing: String, #[derive(Debug,Deserialize,Serialize,Clone,Default)]
#[serde(default)]
pub struct Clothing
{
top: String,
bottom: String,
shoes: String, shoes: String,
} }
#[derive(Debug,Deserialize,Serialize,Clone,Default)]
#[serde(default)]
pub struct Colour
{
red: u8,
green: u8,
blue: u8,
}
impl Character { impl Character {
pub fn set_field(&mut self, field: &str, value: &str) -> Result<(), ()> { // Big ass ugly match case
match field { pub fn set_field(&mut self, field: &str, value: &str) -> Result<(), String> {
"name" => self.name = value.to_string(), let patch = format!("{{ \"{field}\": \"{value}\" }}");
"gender" => self.gender = value.to_string(), apply_mut(self, patch)
"eye_color" => self.eye_color = value.to_string(), .map_err(|_| "Invalid field".to_string())?;
"pronoun_subject" => self.pronoun_subject = value.to_string(),
"pronoun_object" => self.pronoun_object = value.to_string(),
"pronoun_deppos" => self.pronoun_deppos = value.to_string(),
"pronoun_indpos" => self.pronoun_indpos = value.to_string(),
"pronoun_reflex" => self.pronoun_reflex = value.to_string(),
"head" => self.head = value.to_string(),
"hair" => self.hair = value.to_string(),
"torso" => self.torso = value.to_string(),
"arm" => self.arm = value.to_string(),
"leg" => self.leg = value.to_string(),
"hair_color" => self.hair_color = value.to_string(),
"top_clothing" => self.top_clothing = value.to_string(),
"bottom_clothing" => self.bottom_clothing = value.to_string(),
"shoes" => self.shoes = value.to_string(),
_ => return Err(()),
}
Ok(()) Ok(())
} }
} }
@@ -70,8 +71,12 @@ pub fn character_parse(archive: &mut ZipArchive<File>)
// Serialise this to a HashMap // Serialise this to a HashMap
let characters: HashMap<String, Character> = let characters: HashMap<String, Character> =
serde_json::from_str(&file_contents) serde_json::from_str::<HashMap<String,Character>>(&file_contents)
.map_err (|err| format!("Invalid JSON in characters.json: {err}"))?; .map_err (|err| format!("Invalid JSON in characters.json: {err}"))?
.into_iter()
.map(|(k,v)| (k.to_lowercase(), v))
.collect();
info!("Parsed characters from characters.json");
debug!("{characters:?}"); debug!("{characters:?}");
Ok(Arc::new(Mutex::new(characters))) Ok(Arc::new(Mutex::new(characters)))
} }
+41 -47
View File
@@ -3,6 +3,7 @@ mod character;
mod config; mod config;
mod api; mod api;
mod traits; mod traits;
mod tokenise;
use std:: use std::
{ {
@@ -10,7 +11,11 @@ use std::
env::args, env::args,
fs::File, fs::File,
io::Read, io::Read,
collections::HashMap, collections::
{
HashMap,
VecDeque,
},
sync::{Arc, Mutex, mpsc}, sync::{Arc, Mutex, mpsc},
}; };
use log:: use log::
@@ -26,6 +31,7 @@ use serde::
Serialize Serialize
}; };
use serde_json::json; use serde_json::json;
use serde_patch::apply_mut;
use zip:: use zip::
{ {
ZipArchive, ZipArchive,
@@ -34,6 +40,7 @@ use crate::
{ {
traits::UnwrapOrExit, traits::UnwrapOrExit,
}; };
use regex::Regex;
#[tokio::main] #[tokio::main]
async fn main() async fn main()
@@ -44,23 +51,15 @@ async fn main()
let (tx,rx) = mpsc::channel(); let (tx,rx) = mpsc::channel();
// Unzip zip archive to get data for story // Unzip zip archive to get data for story
let file_name = args().nth(1) // Get filename from arguments let file_name = args().nth(1) // Get filename from arguments
.unwrap_or_else .unwrap_or_else // TODO unwrap or exit
(|| { (|| {
error!("No filename specified"); error!("No filename specified");
exit(10); exit(10);
}); });
let file = File::open(format!("../stories/{file_name}")) // Get the file let file = File::open(file_name) // Get the file
.unwrap_or_else .unwrap_or_exit("Failed to read file.", 11);
(|err| {
error!("Failed to open file: {err}");
exit(11);
});
let mut archive = ZipArchive::new(file) // Open the archive let mut archive = ZipArchive::new(file) // Open the archive
.unwrap_or_else .unwrap_or_exit("Failed to open archive", 12);
(|err| {
error!("Failed to open archive: {err}");
exit(12);
});
// Setup the characters hashmap which will store each character in it as a Character struct // Setup the characters hashmap which will store each character in it as a Character struct
let characters = match character::character_parse(&mut archive) let characters = match character::character_parse(&mut archive)
{ {
@@ -76,36 +75,33 @@ async fn main()
}, },
}; };
// Initialise the data strcut that will be sent out during API GET requests // Initialise the data strcut that will be sent out during API GET requests
let data_to_send = Arc::new(Mutex::new(api::DataToSend let happening_stack = Arc::new(Mutex::new(
{ VecDeque::from([api::DataToSend{
action_type: "begin".to_owned(), action_type: "begin".to_owned(),
content: String::new(), content: String::new(), // TODO send title and description
character: String::new(), character: String::new(),
choices: vec![], choices: vec![],
})); }])
));
// setup the api stuff // // setup the api stuff //
// Make clones of the data Arc for the two processes // Make clones of the data Arc for the two processes
let data_clone1 = Arc::clone(&data_to_send); //let data_clone1 = Arc::clone(&data_to_send);
let happening_stack1 = Arc::clone(&happening_stack);
let characters_clone1 = Arc::clone(&characters); let characters_clone1 = Arc::clone(&characters);
let tx_clone = tx; let tx_clone = tx;
// Spawn a thread for warp api server // Spawn a thread for warp api server
tokio::spawn( tokio::spawn(
async move { async move {
api::api_process(data_clone1, characters_clone1, tx_clone).await; api::api_process(happening_stack1, characters_clone1, tx_clone).await;
}); });
// setup the parsing stuff // // setup the parsing stuff //
// Read the file and split it into tokens // Read the file and split it into tokens
// file read from a zip file /story.ha // file read relative to current directory
let mut story_file = archive.by_name("story.ha") let mut story_file = archive.by_name("story.ha")
.unwrap_or_else .unwrap_or_exit("Unable to read story file", 14);
(|err| {
error!("Unable to read story file: {err}");
exit(14);
});
let mut file_contents = String::new(); let mut file_contents = String::new();
story_file.read_to_string(&mut file_contents) story_file.read_to_string(&mut file_contents)
.unwrap_or_else .unwrap_or_else
@@ -113,32 +109,30 @@ async fn main()
error!("Unable to read story file to string: {err}"); error!("Unable to read story file to string: {err}");
exit(14); exit(14);
}); });
// Tokenise story file
let tokens: Vec<&str> = file_contents
.split_whitespace()
.collect();
if ! tokens.contains(&"END")
{
warn!("No END statement, story may exit unexpectedly");
}
debug!("{tokens:?}");
let data_clone2 = Arc::clone(&data_to_send); // Tokenise the file
let (tokens, labels) = tokenise::tokenise(&file_contents)
.unwrap_or_exit("Unable to tokenise data", 15);
// TESTING
//debug!("{tokens:?}\n{labels:?}");
for (index,token) in tokens.iter().enumerate()
{
debug!("{index}: {token:?}");
}
// Run the syntactic parser
let characters_clone2 = Arc::clone(&characters); let characters_clone2 = Arc::clone(&characters);
let happening_stack2 = Arc::clone(&happening_stack);
// Run the parsing process for the DSL // Run the parsing process for the DSL
match parsing::token_parse(&tokens, &characters_clone2, &data_clone2, &rx) info!("DSL parsing begun");
match parsing::token_parse(&tokens, &labels, &characters_clone2, &happening_stack2, &rx)
{ {
// Exit with error or success // Exit with error or success
Ok(()) => Ok(()) =>
{ {
api::modify_data(&happening_stack, "end".to_string(), String::new(), String::new(), vec![]);
let _ = rx.recv();
info!("Program exited successfully"); info!("Program exited successfully");
let mut data = data_to_send.lock().unwrap_or_exit("Data to send Mutex was poisoned",2); // TODO test
data.action_type = String::from("end");
data.content = String::new();
data.character = String::new();
// Manually unlock the mutex
std::mem::drop(data);
let _ = rx.recv(); // Wait for the client to respond
exit(0); exit(0);
}, },
Err(error) => Err(error) =>
+58 -126
View File
@@ -1,164 +1,96 @@
use std::collections::HashMap;
use crate:: use crate::
{ {
// Internal code // Internal code
character, character,
api, api,
tokenise,
// Libraries // Libraries
mpsc::Receiver, mpsc::Receiver,
Arc, Arc,
Mutex, Mutex,
VecDeque,
HashMap,
info, info,
debug, debug,
warn, warn,
UnwrapOrExit,
}; };
mod strings;
mod character_parse; mod character_parse;
mod keyword_parse;
mod identifier_parse;
// Parse the tokens in a file // Parse the tokens in a file
// Returns success or an error string // Returns success or an error string
pub fn token_parse( pub fn token_parse(
tokens: &Vec<&str>, tokens: &[tokenise::Token],
labels: &HashMap<String,usize>,
characters: &Arc<Mutex<HashMap::<String, character::Character>>>, characters: &Arc<Mutex<HashMap::<String, character::Character>>>,
data_to_send: &Arc<Mutex<api::DataToSend>>, happening_queue: &Arc<Mutex<VecDeque<api::DataToSend>>>,
rx: &Receiver<(bool,usize)>, rx: &Receiver<(usize,String)>,
) -> Result<(),String> ) -> Result<(),String>
{ {
info!("DSL parsing begun");
let mut index: usize = 0; let mut index: usize = 0;
if rx.recv().is_err() let mut variables: HashMap<String, tokenise::Value> = HashMap::new();
{
warn!("Some issue with api");
// TOD eh?
}
info!("Client has connected"); info!("Client has connected");
// Run an infinite loop // Run an infinite loop
'parse_loop: loop loop
{ {
debug!("Reading {index}");
// Get the next token // Get the next token
let token = tokens match tokens.get(index)
.get(index)
.ok_or_else(|| "File unexpectedly reached termination point".to_string())?;
debug!("{index}: {token}");
// The instructions are related to characters
if token.starts_with('@')
{ {
let character_name: String = token.chars().skip(1).collect(); // Keyword, eg IF, CHOICE or GOTO
debug!("Doing something with a character: {character_name}"); Some(tokenise::Token::Keyword(token)) =>
// The index is incremented to after the character's instructions
index = match character_parse::character_parse(index+1, tokens, character_name, characters, data_to_send)
{ {
Ok(increment) => increment, if token.to_lowercase().as_str() == "end" { return Ok(()); }
Err((err,increment)) => index = keyword_parse::keyword_parse(tokens, token, index, happening_queue, labels, &mut variables, rx)?;
{ },
warn!("{err}"); // Ignore closing braces and jump over opening brace blocks
increment Some(tokenise::Token::Bracket((bracket,new_index))) =>
},
};
}
// Miscelleneous instructions
else
{
match token.to_lowercase().as_str()
{ {
"end" => if bracket == &tokenise::Bracket::Closing { index += 1; }
else
{ {
info!("END command, exiting"); warn!("Unexpected brace block, jumping over...");
return Ok(()) // quit successfully index = new_index + 1;
},
"choice" =>
{
let (_,jump_points) = match choice_parse(index+1, tokens, data_to_send)
{
Ok((increment,jump_point)) => (increment,jump_point),
Err(error) => return Err(error),
};
if rx.recv().is_err() { warn!("Error sending choices to client"); }
let (_, choice) = match rx.recv()
{
Ok((_,choice)) => (None::<bool>, choice),
Err(err) =>
{
warn!("Error receiving choice from client, defaulting to choice 0 {err}");
(None::<bool>, 0)
}
};
index = jump_points[choice];
info!("CHOICE command with {} choices",jump_points.len());
debug!("{jump_points:?} {choice} {index}");
continue 'parse_loop
},
"or" =>
{
info!("OR command, jumping over");
index += match strings::closing_char(&tokens[index..], '{','}')
{
Some(index) => index,
None => return Err(String::from("No closing brace")),
};
continue
},
"}" =>
{
index += 1;
continue
},
_ =>
{
warn!("Invalid command: {token}");
} }
},
// Handle a character
Some(tokenise::Token::Character(character_name)) =>
{
index = match character_parse::character_parse(index+1,tokens,character_name.clone(),characters,happening_queue,&variables)
{
Ok(increment) => increment,
Err((err,increment)) =>
{
warn!("{err}");
increment
},
};
} }
// Identifier
Some(tokenise::Token::Identifier(name)) =>
{
index = match identifier_parse::identifier_parse(index+1,name,tokens,&mut variables,rx,happening_queue)
{
Ok((increment,_)) => increment,
Err((err,increment)) =>
{
warn!("{err}");
increment
},
};
}
Some(tok) =>
{
warn!("Unexpected token, at index {index}, expected character, keyword or identifier, got {tok:?}");
index += 1;
},
None => return Err("File unexpectedly reached termination point".to_string()),
} }
if rx.recv().is_err() // The instructions are related to characters
{
warn!("Some issue with api");
}
} }
} }
// Parse the options in a choice clause and returns the idexes of the code blocks
fn choice_parse
(
index: usize,
tokens: &[&str],
data_to_send: &Arc<Mutex<api::DataToSend>>,
) -> Result<(usize, Vec<usize>), String>
{
let mut sum_index: usize = index;
let mut choices: Vec<String> = Vec::new();
let mut choice_indeces: Vec<usize> = Vec::new();
// Ensure the index is valid (the index is not beyond the vector)
// Get the initial choice
let (choice_string, counter) = strings::extract_quoted(&tokens[sum_index..])
.ok_or_else(|| "No choice string".to_string())?;
sum_index += counter;
choices.push(choice_string);
choice_indeces.push(sum_index+1);
sum_index += strings::closing_char(&tokens[sum_index..], '{','}')
.ok_or_else(|| "No closing brace".to_string())? + 1;
// Find all the alternate choices labelled with OR
// Fill out the choices vector with all the choice strings
while tokens[sum_index].to_lowercase() == "or"
{
let (choice_string, counter) = strings::extract_quoted(&tokens[sum_index+1..])
.ok_or_else(|| "No choice string".to_string())?;
sum_index += counter;
choices.push(choice_string);
choice_indeces.push(sum_index+2);
sum_index += strings::closing_char(&tokens[sum_index..], '{','}')
.ok_or_else(|| "No closing brace".to_string())? + 1;
}
debug!("{choices:?}");
// Send the choices to the Client via the API
let mut data = data_to_send.lock().unwrap_or_exit("Data to send Mutex was poisoned",2);
data.action_type = String::from("choice");
data.content = String::new();
data.character = String::new();
data.choices = choices;
drop(data);
// Return the choice indeces
Ok((sum_index + 1, choice_indeces))
}
+36 -38
View File
@@ -1,14 +1,15 @@
use super::strings;
use crate:: use crate::
{ {
// Internal code // Internal code
character, character,
api, api,
tokenise,
UnwrapOrExit, UnwrapOrExit,
//Libs //Libs
Mutex, Mutex,
Arc, Arc,
HashMap, HashMap,
VecDeque,
info, info,
warn, warn,
debug, debug,
@@ -20,69 +21,66 @@ use crate::
pub fn character_parse pub fn character_parse
( (
index: usize, index: usize,
tokens: &Vec<&str>, tokens: &[tokenise::Token],
character_name: String, character_name: String,
characters: &Arc<Mutex<HashMap::<String, character::Character>>>, characters: &Arc<Mutex<HashMap::<String, character::Character>>>,
data_to_send: &Arc<Mutex<api::DataToSend>>, happening_queue: &Arc<Mutex<VecDeque<api::DataToSend>>>,
variables: &HashMap<String, tokenise::Value>,
) -> Result<usize,(String,usize)> ) -> Result<usize,(String,usize)>
{ {
let mut sum_index: usize = index; let mut sum_index: usize = index;
let characters_hashmap = characters.lock().unwrap_or_exit("Characters Mutex was poisoned",2);
if character_name.to_lowercase() != "narrator" && ! characters_hashmap.contains_key(&character_name)
{
return Err((format!("Character {character_name} does not exist"),sum_index + 1));
}
drop(characters_hashmap);
// Ensure the index is valid (the index is not beyond the vector) // Ensure the index is valid (the index is not beyond the vector)
let token = tokens let keyword = tokenise::get_keyword_token(tokens, sum_index)
.get(sum_index) .map_err(|err| (err, sum_index))?;
.ok_or_else(|| ("File unexpectedly reached termination point".to_string(), sum_index))?; match keyword.to_lowercase().as_str()
match token.to_lowercase().as_str()
{ {
// The character is saying something, so grab the text and pass it // The character is saying something, so grab the text and pass it
// to the client // to the client
"says" => "says" =>
{ {
info!("SAYS command with character {character_name}"); info!("SAYS command with character {character_name}");
match strings::extract_quoted(&tokens[sum_index+1..]) sum_index += 1;
{ let output = tokenise::get_string_token(tokens, sum_index, variables)
Some((output_string, counter)) => .map_err(|err| (err, sum_index))?;
{ debug!("Saying {output}");
debug!("{output_string}"); api::modify_data(happening_queue, "output".to_string(), output, character_name, vec![]);
sum_index += counter;
let mut data = data_to_send.lock().unwrap_or_exit("Data to send Mutex was poisoned", 2);
data.action_type = String::from("output");
data.content = output_string;
data.character = character_name;
data.choices = vec![];
drop(data);
},
None => return Err(("Unable to read output string".to_string(), sum_index)),
}
}, },
// Change the property of the selected character eg @tim CHANGE name "Bill Buffins" // Change the property of the selected character eg @tim CHANGE name "Bill Buffins"
// will change the character with ID tim to "Bill Buffins"; a character's ID cannot change // will change the character with ID tim to "Bill Buffins"; a character's ID cannot change
"change" => "change" =>
{ {
sum_index += 1; sum_index += 1;
let feature = tokens let feature = tokenise::get_keyword_token(tokens, sum_index)
.get(sum_index) .map_err(|err| (err, sum_index))?;
.ok_or_else(|| ("File unexpectedly reached termination point".to_string(), sum_index))?; sum_index += 1;
let output_string: String; let string = tokenise::get_string_token(tokens, sum_index,variables)
(output_string, sum_index) = match strings::extract_quoted(&tokens[sum_index+1..]) .map_err(|err| (err, sum_index))?;
{
Some((string,counter)) => (string,sum_index+counter),
None => return Err(("Unable to parse property to change character".to_string(),sum_index)),
};
info!("CHANGE command with character {character_name} feature {feature}"); info!("CHANGE command with character {character_name} feature {feature}");
let mut characters = characters.lock().unwrap_or_exit("Character Mutex was poisoned",3); let mut characters = characters.lock().unwrap_or_exit("Character Mutex was poisoned",3);
if let Some(character) = characters.get_mut(&character_name) if let Some(character) = characters.get_mut(&character_name)
&& character.set_field(feature, &output_string) && character.set_field(&feature, &string)
.is_err() { warn!("Feature {feature} does not exist") } .is_err() { warn!("Feature {feature} does not exist") }
drop(characters); drop(characters);
let mut data = data_to_send.lock().unwrap_or_exit("Data to send Mutex was poisoned",2); // TODO eh? api::modify_data(happening_queue, "change".to_string(), String::new(), character_name, vec![]);
data.action_type = String::from("change"); },
data.content = String::new(); // These two are mainly just actions performed by the frontend client, so just tell the client to move/animate
data.character = character_name; // the character and not much other processing needed on the serverside
drop(data); "to"|"animate" =>
{
sum_index += 1;
let content = tokenise::get_keyword_token(tokens, sum_index)
.map_err(|err| (err, sum_index))?;
api::modify_data(happening_queue, keyword.to_lowercase(), content, character_name, vec![]);
}, },
// Catch all condition, if the instruction is unrecognised as a // Catch all condition, if the instruction is unrecognised as a
// character command // character command
_ => return Err((format!("Invalid command: {token}"),sum_index)), _ => return Err((format!("Invalid command: {keyword}"),sum_index)),
} }
sum_index += 1; sum_index += 1;
Ok(sum_index) Ok(sum_index)
+169
View File
@@ -0,0 +1,169 @@
use crate::
{
// Internal code
tokenise,
api,
//Libs
HashMap,
Arc,
Mutex,
VecDeque,
warn,
debug,
info,
mpsc::Receiver,
};
use super::keyword_parse;
#[allow(unused_variables)]
pub fn identifier_parse
(
index: usize,
identifier: &String,
tokens: &[tokenise::Token],
variables: &mut HashMap<String, tokenise::Value>,
rx: &Receiver<(usize,String)>,
happening_queue: &Arc<Mutex<VecDeque<api::DataToSend>>>,
) -> Result<(usize,bool),(String,usize)>
{
let mut sum_index: usize = index;
let current = variables
.entry(identifier.clone())
.or_insert(tokenise::Value::Null)
.clone();
let operator = tokenise::get_operator_token(tokens, sum_index)
.map_err(|err| (err, sum_index))?;
sum_index += 1;
let result: bool = match tokenise::get_value_token(tokens, sum_index)
{
// An value that can be directly assigned to or compared against the variable
Ok(value) =>
{
sum_index += 1;
operator_match(&current,value,operator,identifier,variables)
},
// Another thing like a choice or an input
Err(_) =>
{
if operator != tokenise::Operator::Assignment // Only assignment is valid here
{
return Err((format!("Unexpected operator: {operator:?} at index {}",sum_index-1),sum_index + 1))
};
let keyword = tokenise::get_keyword_token(tokens,sum_index)
.map_err(|err| (err,sum_index))?;
match keyword.to_lowercase().as_str()
{
"choice" =>
{
let choice: String;
(sum_index, choice) = keyword_parse::choice_parse(tokens, index+1, happening_queue,rx,variables)
.map_err(|err| (err,sum_index+1))?;
variables.insert(identifier.to_owned(), tokenise::Value::String(choice));
},
"input" =>
{
api::modify_data(happening_queue, "input".to_string(), String::new(), String::new(), Vec::new());
info!("Waiting for client input");
let input = match rx.recv()
{
Ok((_,input)) => input,
Err(err) =>
{
warn!("Error receiving input from client, defaulting to choice \"\" {err}");
"".to_string()
}
};
variables.insert(identifier.to_owned(), tokenise::Value::String(input));
sum_index += 1;
},
_ =>
{
warn!("Unexpected keyword {keyword}");
},
}
false
},
};
debug!("{variables:?}");
Ok((sum_index,result))
}
fn operator_match
(
current: &tokenise::Value,
value: tokenise::Value,
operator: tokenise::Operator,
identifier: &String,
variables: &mut HashMap<String, tokenise::Value>
)
-> bool
{
// Operator match box
match operator
{
// Changing a value
tokenise::Operator::Assignment =>
{
variables.insert(identifier.to_owned(), value);
} ,
tokenise::Operator::Add =>
{
let result: tokenise::Value = match (value.clone(), current)
{
(tokenise::Value::Integer(int1),tokenise::Value::Integer(int2)) => tokenise::Value::Integer(int1 + int2),
(tokenise::Value::String(str1),tokenise::Value::String(str2)) => tokenise::Value::String(format!("{str1}{str2}")),
_ => value, // otherwise invalid
};
variables.insert(identifier.to_owned(), result);
},
tokenise::Operator::Sub =>
{
let result: tokenise::Value = match (value.clone(), current)
{
(tokenise::Value::Integer(int1),tokenise::Value::Integer(int2)) => tokenise::Value::Integer(int2 - int1),
_ => value, // otherwise invalid
};
variables.insert(identifier.to_owned(), result);
},
// Comparisons, return a boolean
tokenise::Operator::Comparison(comp) =>
{
let result = match (current, &value)
{
// Integer
(tokenise::Value::Integer(current), tokenise::Value::Integer(comparing)) =>
{
match comp
{
tokenise::Comparison::Equate => current == comparing,
tokenise::Comparison::Greater => current > comparing,
tokenise::Comparison::Less => current < comparing,
tokenise::Comparison::GreaterOrEqual => current >= comparing,
tokenise::Comparison::LessOrEqual => current <= comparing,
}
},
// String
(tokenise::Value::String(current), tokenise::Value::String(comparing)) =>
{
match comp
{
tokenise::Comparison::Equate => current == comparing,
tokenise::Comparison::Greater => current > comparing,
tokenise::Comparison::Less => current < comparing,
tokenise::Comparison::GreaterOrEqual => current >= comparing,
tokenise::Comparison::LessOrEqual => current <= comparing,
}
},
_ => {
warn!("Invalid comparison of {current:?} and {value:?}, evaluating false");
false
},
};
debug!("Comparison {current:?} comp {value:?} evaluates to {result}");
return result;
}
}
true
}
+165
View File
@@ -0,0 +1,165 @@
use crate::
{
tokenise,
api,
HashMap,
Arc,
Mutex,
VecDeque,
warn,
debug,
info,
mpsc::Receiver,
};
use super::identifier_parse;
pub fn keyword_parse(
tokens: &[tokenise::Token],
token: &str,
mut index: usize,
happening_queue: &Arc<Mutex<VecDeque<api::DataToSend>>>,
labels: &HashMap<String, usize>,
variables: &mut HashMap<String, tokenise::Value>,
rx: &Receiver<(usize,String)>,
)
-> Result<usize, String>
{
match token.to_lowercase().as_str()
{
"choice" =>
{
(index,_) = choice_parse(tokens, index, happening_queue, rx,variables)?;
},
"if" =>
{
// TODO can this go in a function?
let mut keyword = "if".to_string();
while keyword == "if" || keyword == "elif" || keyword == "else" // TODO less beefy??
{
index += 1;
let mut result: bool = true;
if keyword != "else"
{
let identifier = tokenise::get_identifier_token(tokens, index)?;
(index,result) = match identifier_parse::identifier_parse(index+1, &identifier, tokens, variables,rx,happening_queue)
{
Ok((increment, result)) => (increment,result),
Err((err,increment)) =>
{
warn!("{err}");
(increment,false)
}
}
}
if result { index += 1; break; }
index = tokenise::get_closing_index(tokens,index)?;
index += 1;
keyword = match tokenise::get_keyword_token(tokens,index)
{
Ok(keyword) => keyword,
Err(_) => break,
}
}
},
"else" =>
{
index += 1;
index = tokenise::get_closing_index(tokens, index)?;
},
"elif" =>
{
loop
{
index += 1;
let Ok(new_index) = tokenise::get_closing_index(tokens, index)
else { continue; };
index = new_index;
break
}
},
"or" =>
{
info!("OR command, jumping over");
index += 2;
let new_index = tokenise::get_closing_index(tokens, index)?;
index = new_index;
},
// Jump to a particular index based on a label eg GOTO character_check
"goto" =>
{
info!("GOTO command, jumping there");
index += 1;
let label = tokenise::get_keyword_token(tokens, index)?;
index = if let Some(label_index) = labels.get(&label) { *label_index }
else
{
warn!("Label {label} does not exist");
index + 1
};
debug!("Jumping to {index}");
},
"pan" =>
{
info!("PAN command, informing client");
index += 1;
let location = tokenise::get_keyword_token(tokens, index)?;
api::modify_data(happening_queue, "pan".to_string(), location, String::new(), Vec::new());
},
_ =>
{
warn!("Invalid command: {token}, index {index}");
index += 1;
}
}
Ok(index)
}
pub fn choice_parse
(
tokens: &[tokenise::Token],
mut index: usize,
happening_queue: &Arc<Mutex<VecDeque<api::DataToSend>>>,
rx: &Receiver<(usize,String)>,
variables: &HashMap<String,tokenise::Value>,
)
-> Result<(usize,String), String>
{
let mut next_token: String = "or".to_string();
let mut choices: Vec<String> = Vec::new();
let mut choice_indeces: Vec<usize> = Vec::new();
while next_token == "or"
{
index += 1;
choices.push
(
tokenise::get_string_token(tokens, index,variables)?
);
index += 1;
choice_indeces.push(index+1);
index = match tokenise::get_closing_index(tokens,index)
{
Ok(new_index) => new_index + 1,
Err(_) => break,
};
next_token = match tokenise::get_keyword_token(tokens, index)
{
Ok(string) => string,
Err(_) => break,
}
};
api::modify_data(happening_queue, "choice".to_string(), String::new(), String::new(), choices.clone());
info!("Waiting for client choice");
debug!("{choice_indeces:?}");
let choice = match rx.recv()
{
Ok((choice,_)) => choice,
Err(err) =>
{
warn!("Error receiving choice from client, defaulting to choice 0 {err}");
0
}
};
Ok((choice_indeces[choice],choices[choice].clone()))
}
View File
-42
View File
@@ -1,42 +0,0 @@
pub fn closing_char(parts: &[&str], open: char, close: char)
-> Option<usize>
{
let mut indentation: usize = 0;
let mut flag = false; // flag to mark you've passed open
for (index, part) in parts.iter().enumerate()
{
if part.contains(open)
{
indentation += 1;
flag = true;
}
if part.contains(close) { indentation -= 1; }
if indentation == 0 && flag { return Some(index); }
}
None
}
pub fn extract_quoted(parts: &[&str])
-> Option<(String, usize)>
{
let mut vec_string = Vec::new();
let mut counter: usize = 0;
for part in parts
{
counter += 1;
vec_string.push(*part);
// End of the string
if part.ends_with('\"') // TODO allow for backslashes and '
{
let final_string: String = vec_string.join(" ");
let final_string: String = final_string
.chars()
.skip(1)
.take(
final_string.chars()
.count() - 2)
.collect();
return Some((final_string, counter));
}
}
None
}
+296
View File
@@ -0,0 +1,296 @@
use crate::
{
HashMap,
Regex,
};
#[derive(Debug, Clone)]
pub enum Token
{
Value(Value),
Operator(Operator),
Keyword(String), // Keywords aren't checked for validity in this stage
Identifier(String),
Bracket((Bracket,usize)), // Stores the index of the matching deliminator
Character(String),
}
#[derive(Debug,Clone,PartialEq,Eq)]
pub enum Value
{
String(String),
Integer(i64),
Bool(bool),
Null,
}
#[derive(Debug,Clone,PartialEq,Eq)]
pub enum Bracket
{
Opening,
Closing,
}
#[derive(Debug, Clone,PartialEq)]
pub enum Operator
{
// Changing a value
Assignment,
Add,
Sub,
Comparison(Comparison),
}
// Comparing a value
#[derive(Debug,Clone,PartialEq)]
pub enum Comparison
{
Equate,
Greater,
Less,
GreaterOrEqual,
LessOrEqual,
}
impl Value
{
pub fn as_string(&self) -> String
{
match self
{
Value::String(s) => s.clone(),
Value::Integer(i) => i.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "".to_string(),
}
}
}
// TODO error reporting for all of these
pub fn get_operator_token(tokens: &[Token], index: usize)
-> Result<Operator, String>
{
match tokens.get(index) {
Some(Token::Operator(op)) => Ok(op.clone()),
Some(tok) => Err(format!("Unexpected token at index {index}, expected operator, got {tok:?}")),
None => Err("File unexpectedly reached termination point".to_string()),
}
}
pub fn get_keyword_token(tokens: &[Token], index: usize)
-> Result<String, String>
{
match tokens.get(index) {
Some(Token::Keyword(s)) => Ok(s.clone()),
Some(tok) => Err(format!("Unexpected token at index {index}, expected keyword, got {tok:?}")),
None => Err("File unexpectedly reached termination point".to_string()),
}
}
pub fn get_closing_index(tokens: &[Token], index: usize)
-> Result<usize, String>
{
match tokens.get(index) {
Some(Token::Bracket((_, index))) => Ok(*index), // TODO check for closing
Some(tok) => Err(format!("Unexpected token at index {index}, expected opening brace, got {tok:?}")),
None => Err("File unexpectedly reached termination point".to_string()),
}
}
pub fn get_value_token(tokens: &[Token], index: usize)
-> Result<Value, String>
{
match tokens.get(index) {
Some(Token::Value(val)) => Ok(val.clone()),
Some(tok) => Err(format!("Unexpected token at index {index}, expected value (int, string or boolean), got {tok:?}")),
None => Err("File unexpectedly reached termination point".to_string()),
}
}
pub fn get_identifier_token(tokens: &[Token], index: usize)
-> Result<String, String>
{
match tokens.get(index) {
Some(Token::Identifier(s)) => Ok(s.clone()),
Some(tok) => Err(format!("Unexpected token at index {index}, expected identifier, got {tok:?}")),
None => Err("File unexpectedly reached termination point".to_string()),
}
}
pub fn get_string_token(tokens: &[Token], index: usize, variables: &HashMap<String, Value>)
-> Result<String, String>
{
match tokens.get(index) {
// Either get string directly
Some(Token::Value(val)) =>
{
match val
{
Value::String(s) =>
{
let string = insert_variables_into_string(s, variables);
Ok(string)
},
_ => Err("Unexpected value type".to_string()),
}
},
// Or get the string that the identifier references
Some(Token::Identifier(iden)) =>
{
let val = variables.get(iden)
.ok_or(format!("Variable {iden} not initialised"))?;
match val
{
Value::String(s) =>
{
let string = insert_variables_into_string(s, variables);
Ok(string)
},
_ => Err("Unexpected value type".to_string()),
}
},
Some(tok) => Err(format!("Unexpected token at index {index}, expected string value, got {tok:?}")),
None => Err("File unexpectedly reached termination point".to_string()),
}
}
fn insert_variables_into_string(string: &str, variables: &HashMap<String, Value>)
-> String
{
let re = Regex::new(r"\$([A-Za-z0-9-_]*)").unwrap();
re.replace_all(string, |caps: &regex::Captures| {
let key = &caps[1].to_lowercase();
variables.get(key)
.map(|v| v.as_string())
.unwrap_or_else(|| caps[0].to_string()) // leave unchanged if missing
})
.to_string()
}
// Tokenise the story to Vec<Token>
// It can contain sub-objects (using recursive calls)
// TODO check for END
pub fn tokenise(file_contents: &str)
-> Result<(Vec<Token>,HashMap<String,usize>),String>
{
let space_seperated: Vec<&str> = file_contents
.split_whitespace()
.collect();
let mut tokenised_data: Vec<Token> = Vec::new();
let mut labels: HashMap<String, usize> = HashMap::new();
let mut bracket_stack: Vec<usize> = Vec::new();
let mut index: usize = 0;
while index < space_seperated.len()
{
let mut item = space_seperated[index].to_string();
// Operator
let op: Option<Operator> = match item.as_str()
{
"=" => Some(Operator::Assignment),
"+" => Some(Operator::Add),
"-" => Some(Operator::Sub),
"==" => Some(Operator::Comparison(Comparison::Equate)),
">" => Some(Operator::Comparison(Comparison::Greater)),
"<" => Some(Operator::Comparison(Comparison::Less)),
">=" => Some(Operator::Comparison(Comparison::GreaterOrEqual)),
"<=" => Some(Operator::Comparison(Comparison::LessOrEqual)),
_ => None,
};
if let Some(op) = op
{
tokenised_data.push(Token::Operator(op));
index += 1;
continue // skip the rest of this loop
}
// Characters
if item.starts_with('@')
{
let mut chars = item.chars();
chars.next();
tokenised_data.push(Token::Character(chars.as_str().to_lowercase().to_string())); // Force character to be lowecase
}
// Strings
else if item.starts_with('"') // TODO support '
{
let Some((new_index, new_item)) = tokenise_string(&space_seperated, index)
else { return Err("File unexpectedly ended: No closing quote".to_string()) };
index = new_index;
item = new_item;
tokenised_data.push(Token::Value(Value::String(item)));
}
else if let Ok(value) = item.parse::<i64>()
{
tokenised_data.push(Token::Value(Value::Integer(value))); // unwrap is fine here because I checked
}
else if item == "true"
{
tokenised_data.push(Token::Value(Value::Bool(true)));
}
else if item == "false"
{
tokenised_data.push(Token::Value(Value::Bool(false)));
}
// variable/identifier
else if item.starts_with('$')
{
let mut chars = item.chars();
chars.next();
tokenised_data.push(Token::Identifier(chars.as_str().to_string()));
}
// Integer
// Labels
else if item.ends_with(':')
{
let mut chars = item.chars();
chars.next_back();
let label = chars.as_str();
labels.insert
(
label.to_string(),
tokenised_data.len(),
);
}
else if item == "{"
{
bracket_stack.push(tokenised_data.len());
tokenised_data.push(Token::Bracket((Bracket::Opening,0))); // TODO fix no closing brace edge case
}
else if item == "}"
{
let prev_index = bracket_stack.pop()
.ok_or_else(|| "Unexpected closing brace".to_string())?;
tokenised_data[prev_index] = Token::Bracket((Bracket::Opening, tokenised_data.len()));
tokenised_data.push(Token::Bracket((Bracket::Closing,prev_index)));
}
else { tokenised_data.push(Token::Keyword(item)); }
index += 1;
}
if !bracket_stack.is_empty() { return Err("File unexpectedly ended: No closing brace".to_string()) }
Ok((tokenised_data, labels))
}
// TODO support strings that contain " in them by using a backslash
fn tokenise_string(space_seperated: &[&str], mut index: usize)
-> Option<(usize, String)>
{
let mut string = String::new();
let first_word = space_seperated[index];
if first_word.ends_with('"') // One word string edge case
{
let mut chars = first_word.chars();
chars.next();
chars.next_back();
string += chars.as_str();
return Some((index,string));
}
let mut chars = first_word.chars();
chars.next();
string += chars.as_str();
for item in &space_seperated[index+1..]
{
if item.ends_with('"')
{
let mut chars = item.chars();
chars.next_back();
string += format!(" {}", chars.as_str()).as_str();
return Some((index+1,string))
}
string += format!(" {item}").as_str();
index += 1;
}
None
}
+10 -5
View File
@@ -4,20 +4,25 @@ use crate::
exit, exit,
}; };
// TODO support Options
// TODO pass error message back
pub trait UnwrapOrExit<T> pub trait UnwrapOrExit<T>
{ {
fn unwrap_or_exit(self, error_message: &str, error_code: i32) -> T; fn unwrap_or_exit(self, error_message: &str, error_code: i32) -> T;
} }
impl<T, E> UnwrapOrExit<T> for Result<T, E> impl<T, E: std::fmt::Debug> UnwrapOrExit<T> for Result<T, E>
{ {
fn unwrap_or_exit(self, error_message: &str, error_code: i32) -> T fn unwrap_or_exit(self, error_message: &str, error_code: i32) -> T
{ {
if let Ok(value) = self { value } match self
else
{ {
error!("{error_message}"); Ok(value) => value,
exit(error_code); Err(error) =>
{
error!("{error_message}: {error:?}");
exit(error_code);
}
} }
} }
} }
-22
View File
@@ -1,22 +0,0 @@
{
"tim": {
"name": "Timothy Sharpshooter",
"gender": "Male",
"skin_color": "",
"eye_color": "",
"pronoun_subject": "He",
"pronoun_object": "Him",
"pronoun_deppos": "His",
"pronoun_indpos": "His",
"pronoun_reflex": "Himself",
"head": "",
"hair": "",
"torso": "",
"arm": "",
"leg": "",
"hair_color": "",
"top_clothing": "",
"bottom_clothing": "",
"shoes": ""
}
}
-9
View File
@@ -1,9 +0,0 @@
@tim says "hello world, it's a good day"
@tim change name "Timothy Fineshooter"
choice "choice numero uno" {
@tim says "super sad"
}
or "choice numero duo" {
@tim says "super unsad"
}
END
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
{
"title": "Once upon a Test",
"description": "This story is for testing purposes"
}
+27
View File
@@ -0,0 +1,27 @@
{
"tim": {
"name": "Timothy Sharpshooter",
"gender": "Male",
"skin_color": [0,0,0],
"eye_color": [0,0,0],
"hair_color": [0,0,0],
"pronoun_subject": "He",
"pronoun_object": "Him",
"pronoun_deppos": "His",
"pronoun_indpos": "His",
"pronoun_reflex": "Himself",
"head_shape": "normal-male",
"hair_style": "",
"torso_shape": "",
"arm_shape": "",
"leg_shape": "",
"clothing": {
"top": "",
"bottom": "",
"shoes": "",
"hat": "",
"gloves": "",
"neck": ""
}
}
}
+27
View File
@@ -0,0 +1,27 @@
$name = input
@tim says "hello"
@tim says $name
$x = 3
$x + 1
if $x == 4 {
@tim says "5"
}
elif $x < 5 {
@tim says "<5"
}
else {
@tim says "whar"
}
label:
$choice_string = choice "choice numero uno" {
@tim says "super sad"
}
or "choice numero duo" {
@tim says "super unsad"
}
or "choice numero tres" {
@tim says "hola mi amigos"
goto label
}
END
+21 -1
View File
@@ -1,13 +1,21 @@
import requests import requests
import os import os
import time
import sys
debug = True
try:
if sys.argv[1] == "silent": debug = False
except:
debug = True
# Loop and get new api # Loop and get new api
def main(): def main():
response = {} response = {}
id = -1
while True: while True:
try: try:
response = api_get() response = api_get()
print(response) if debug: print(response)
match response["action_type"]: match response["action_type"]:
case "output": case "output":
character = get_character(response["character"]) character = get_character(response["character"])
@@ -15,8 +23,12 @@ def main():
case "choice": case "choice":
user_choice = choice(response["choices"]) user_choice = choice(response["choices"])
continue continue
case "input":
get_input()
continue
case "end": case "end":
print("Exitting successfully") print("Exitting successfully")
requests.post("127.0.0.1:20264", json=0);
os._exit(0) os._exit(0)
except: except:
print("Server not up or cannot be reached") print("Server not up or cannot be reached")
@@ -34,6 +46,12 @@ def choice(choices):
print("Invalid choice, defaulting to 0") print("Invalid choice, defaulting to 0")
requests.post(api_url, json=choice); requests.post(api_url, json=choice);
def get_input():
api_url = "http://localhost:20264/input"
input_string = input("Input: ")
requests.post(api_url, json=input_string);
# Character outputs text to the user # Character outputs text to the user
def output(character, text): def output(character, text):
print(character["name"], "says") print(character["name"], "says")
@@ -41,6 +59,8 @@ def output(character, text):
# Get user from the backend # Get user from the backend
def get_character(character): def get_character(character):
if character.lower() == "narrator":
return {"name": "narrator"}
api_url = f"http://localhost:20264/character/{character}" api_url = f"http://localhost:20264/character/{character}"
return requests.get(api_url).json() return requests.get(api_url).json()