Commit 28db12bf by Jonathan Thomas

Merge branch 'develop' into hidden-icon

parents 78c30d56 a119149f
Pipeline #13249 passed with stages
in 2 minutes 24 seconds
......@@ -52,6 +52,13 @@ build_mod:
find build/libs -type f -name '*sources*.jar' -exec rm {} \;
mv build/libs/creaturechat-*.jar .
if [ "$minecraft_version" == "1.20.1" ]; then
jar_name=$(ls creaturechat-*+1.20.1.jar)
cp "$jar_name" "${jar_name%.jar}-forge.jar"
touch FORGE
zip -r "${jar_name%.jar}-forge.jar" FORGE
fi
FABRIC_API_JAR="fabric-api-${fabric_version}.jar"
DOWNLOAD_URL="https://github.com/FabricMC/fabric/releases/download/${fabric_version//+/%2B}/${FABRIC_API_JAR}"
wget -q -O "${FABRIC_API_JAR}" $DOWNLOAD_URL
......@@ -91,7 +98,7 @@ gpt-4o:
tags:
- minecraft
# Optional test (gpt 4o)
# Optional test (llama3-8b)
llama3-8b:
stage: test
script:
......
......@@ -4,6 +4,95 @@ All notable changes to **CreatureChat** are documented in this file. The format
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Fixed
- Changing death message timestamp output to use DEBUG log level
## [1.2.1] - 2025-01-01
### Changed
- Refactor of EntityChatData constructor (no need for playerName anymore)
- Improved LLM / AI Options in README.md (to more clearly separate free and paid options)
- Improved LLM unit tests for UNFLEE (trying to prevent failures for brave archer)
### Fixed
- Fixed a bug which broadcasts too many death messages (any mob with a custom name). Now it must also have a character sheet.
- Prevent crash due to missing texture when max friend/enemy + right click on entity
- Fixed bug which caused a max friend to interact with both off hand + main hand, causing both a message + riding (only check main hand now)
- Hide auto-generated messages from briefly appearing from the mob (i.e. interact, show, attack, arrival)
- Name tags were hidden for entities with no character sheet (they are now rendered)
## [1.2.0] - 2024-12-28
### Added
- New friendship particles (hearts + fire) to indicate when friendship changes
- Added sound effects for max friendship and max enemy
- New follow, flee, attack, lead, and protect particles & sound effects (for easy confirmation of behaviors)
- New animated lead particle (arrows pointing where they are going)
- New animated attack particles (with random # of particles)
- New sounds and particles when max friendship with EnderDragon (plus XP drop)
- New `/creaturechat story` command to customize the character creation and chat prompts with custom text.
### Changed
- Entity chat data now separates friendship by player and includes timestamps
- When entity conversations switch players, a message is added for clarity (so the entity knows a new player entered the conversation)
- Data is no longer deleted on entity death, and instead a "death" timestamp is recorded
- Removed "pirate" speaking style and a few <non-response> outputs
- Passive entities no longer emit damage particles when attacking, they emit custom attack particles
- Protect now auto sets friendship to 1 (if <= 0), to prevent entity from attacking and protecting at the same time
- Seperated `generateCharacter()` and `generateMessage()` functions for simplicity
- Fixing PACKET_S2C_MESSAGE from crashing a newly logging on player, if they receive that message first.
- Added NULL checks on client message listeners (to prevent crashes for invalid or uninitialized clients)
- Broadcast ALL player friendships with each message update (to keep client in sync with server)
### Fixed
- Fixed a regression caused by adding a "-forge" suffix to one of our builds
- Do not show auto-generated message above the player's head (you have arrived, show item, etc...)
## [1.1.0] - 2024-08-07
### Added
- New LEAD behavior, to guide a player to a random location (and show message when destination is reached)
- Best friends are now rideable! Right click with an empty hand. Excludes tameable entities (dogs, cats, etc...)
- Villager trades are now affected by friendship! Be nice!
- Automatically tame best friends (who are tameable) and un-tame worst enemies!
- Added FORGE deployment into automated deploy script
### Changed
- Improved character creation with more random classes, speaking styles, and alignments.
- Large refactor of how MobEntity avoids targeting players when friendship > 0
- Updated LookControls to support PhantomEntity and made it more generalized (look in any direction)
- Updated FLEE behavior Y movement speed
- Updated unit tests to add new LEAD tests
- Updated README.md to include HTML inside spoiler instructions, and whitelist/blacklist commands
### Fixed
- Entity persistence is now fixed (after creating a character sheet). No more despawning mobs.
- Fixed consuming items when right-clicking on chat bubbles (with something in your hand)
- Fixed crash when PROTECT behavior targets another player
- Fixed error when saving chat data while generating a new chat message
## [1.0.8] - 2024-07-16
### Added
- New **whitelist / blacklist** Minecraft **commands**, to show and hide chat bubbles based on entity type
- New **S2C packets** to send whitelist / blacklist changes on login and after commands are executed
- Added **UNFLEE behavior** (to stop fleeing from a player)
- Added support for **non path aware** entities to **FLEE** (i.e. Ghast)
- Added **new LLM tests** for UNFLEE
### Changed
- Chat Bubble **rendering** & interacting is now dependent on **whitelist / blacklist** config
- Improved client **render performance** (only query nearby entities every 3rd call)
- Fixed a **crash with FLEE** when non-path aware entities (i.e. Ghast) attempted to flee.
- Updated ATTACK **CHARGE_TIME** to be a little **faster** (when non-native attacks are used)
- Extended **click sounds** to 12 blocks away (from 8)
- Fixed certain **behaviors** from colliding with others (i.e. **mutual exclusive** ones)
- Updated README.md with new video thumbnail, and simplified text, added spoiler to install instructions
- Large **refactor** of Minecraft **commands** (and how --config args are parsed)
- Fixed **CurseForge deploy script** to be much faster, and correctly lookup valid Type and Version IDs
## [1.0.7] - 2024-07-03
### Added
......
![CreatureChat Logo](src/main/resources/assets/creaturechat/icon.png "CreatureChat Logo")
# CreatureChat
### Chat, befriend, and interact with a rich world of creatures like never before! All creatures can talk and respond naturally using AI.
## Chat with any mob in Minecraft! All creatures can talk & react using AI!
## Features:
- **Dynamic Dialogues**: Engage with Minecraft creatures like never before, each with a unique character sheet.
- **AI-Driven Chats**: Powered by ChatGPT, ensuring each conversation is fresh and engaging.
- **Custom Behaviors**: Creatures can make decisions on their own and **Follow, Flee, Attack**, and more!
- **Reactive Interactions**: Creatures automatically react to being damaged or receiving items from players.
- **Friendship Status**: Track your relationships on a 7-point scale, from foes to friends.
- **Custom UI Artwork**: Features beautiful hand-drawn icons for entities, expressive chat bubbles.
- **Multi-Player Interaction**: Share the experience; conversations sync across server & players.
- **Personalized Memory**: Creatures remember past interactions, making each chat more personal.
- **Model Support**: Flexible backend, compatible with various GPT and open-sources LLM models.
### Features
- **AI-Driven Chats:** Using ChatGPT or open-source AI models, each conversation is unique and engaging!
- **Behaviors:** Creatures can make decisions on their own and **Follow, Flee, Attack, Protect**, and more!
- **Reactions:** Creatures automatically react to being damaged or receiving items from players.
- **Friendship:** Track your relationships from friends to foes.
- **Multi-Player:** Share the experience; conversations sync across server & players.
- **Memory:** Creatures remember your past interactions, making each chat more personal.
Ready to deepen your Minecraft journey with meaningful conversations and enduring friendships?
**Step into the world of CreatureChat 🗨 and spark your first conversation today!**
### Create meaningful conversations and enduring friendships? A betrayal perhaps?
## Recommended Installation (with Fabric)
1. **Install Fabric Loader & API**: Follow the instructions [here](https://fabricmc.net/use/).
1. **Install CreatureChat Mod**: Download and copy `creaturechat-*.jar` and `fabric-api-*.jar` into your `.minecraft/mods` folder.
1. **Create an OpenAI API key**: Visit https://platform.openai.com/api-keys, and use the **+ Create new secret key** button.
Copy/Paste your key into the `/creaturechat key set <YOUR-SECRET-KEY-HERE>` command.
1. **Launch Minecraft** with the Fabric profile
[![CreatureChat Trailer Video](src/main/resources/assets/creaturechat/screenshots/video-thumbnail.png)](https://youtu.be/GdY969Orsh0?si=qRgCne10XqOBC8YB)
## OR
## Installation Instructions
### Fabric (Recommended)
## Forge Installation (with Sinytra Connector)
1. **Install Forge:** Download [Forge Installer](https://files.minecraftforge.net/), run it, select "Install client".
1. **Install Forgified Fabric API:** Download [Forgified Fabric API](https://curseforge.com/minecraft/mc-mods/forgified-fabric-api) and copy the `*.jar` into your `.minecraft/mods` folder.
1. **Install Sinytra Connector:** Download [Sinytra Connector](https://www.curseforge.com/minecraft/mc-mods/sinytra-connector) and copy the `*.jar` into your `.minecraft/mods` folder.
1. **Install CreatureChat Mod**: Download and copy `creaturechat-*.jar` into your `.minecraft/mods` folder.
1. **Create an OpenAI API key**: Visit https://platform.openai.com/api-keys, and use the **+ Create new secret key** button.
Copy/Paste your key into the `/creaturechat key set <YOUR-SECRET-KEY-HERE>` command.
1. **Launch Minecraft** with the Forge profile
1. **Install Fabric Loader & API:** Follow the instructions [here](https://fabricmc.net/use/).
2. **Install CreatureChat Mod:** Download and copy `creaturechat-*.jar` and `fabric-api-*.jar` into your `.minecraft/mods` folder.
3. **Launch Minecraft** with the Fabric profile.
4. **Configure AI:** A LLM (large language model) is required for generating text (AI options **listed below**)
## Commands
The CreatureChat mod allows users to configure settings via in-game commands. Here's how to use them:
### Forge (with Sinytra Connector)
*NOTE: Sintra Connector only supports Minecraft 1.20.1.*
### Command Usage
1. **Install Forge:** Download [Forge Installer](https://files.minecraftforge.net/), run it, select "Install client".
2. **Install Forgified Fabric API:** Download [Forgified Fabric API](https://curseforge.com/minecraft/mc-mods/forgified-fabric-api) and copy the `*.jar` into your `.minecraft/mods` folder.
3. **Install Sinytra Connector:** Download [Sinytra Connector](https://www.curseforge.com/minecraft/mc-mods/sinytra-connector) and copy the `*.jar` into your `.minecraft/mods` folder.
4. **Install CreatureChat Mod:** Download and copy `creaturechat-*.jar` into your `.minecraft/mods` folder.
6. **Launch Minecraft** with the Forge profile.
7. **Configure AI:** A LLM (large language model) is required for generating text (AI options **listed below**)
## AI Options
CreatureChat **requires** an AI / LLM (large language model) to generate text (characters and chat). There are many different
options for connecting an LLM.
1. **Free & Local**: Use open-source and free-to-use LLMs without any API fees. [**Difficulty: Hard**]
2. **Bring Your Own Key**: Use your own API key from providers like OpenAI or Groq. [**Difficulty: Medium**]
3. **Token Shop**: Supports CreatureChat by purchasing tokens from the developers on Discord. [**Difficulty: Easy**]
### 1. Free & Local
CreatureChat fully supports **free and open-source** LLMs. To get started:
- An HTTP endpoint compatible with the OpenAI Chat Completion JSON syntax is required. We highly recommend using:
- [Ollama](https://ollama.com/) & [LiteLLM](https://litellm.vercel.app/) as your HTTP proxy.
- **LiteLLM Features:**
- Supports over **100+ LLMs** (e.g., Anthropic, VertexAI, HuggingFace, Google Gemini, and Ollama).
- Proxies them through a local HTTP endpoint compatible with CreatureChat.
- **Note:** Running a local LLM on your computer requires a powerful GPU.
- Set the local HTTP endpoint in-game:
- `/creaturechat url set "http://ENTER-YOUR-HTTP-ENDPOINT-FROM-LITE-LLM"`
- `/creaturechat model set ENTER-MODEL-NAME`
- `/creaturechat timeout set 360`
- Additional help can be found in the **#locall-llm-info** channel on our [Discord](https://discord.gg/m9dvPFmN3e).
### 2. Bring Your Own Key
For those already using a third-party API (e.g., OpenAI, Groq):
- Integrate your own API key for seamless connectivity.
- Costs depend on the provider’s usage-based pricing model.
- By default, CreatureChat uses the OpenAI endpoint and `gpt-3.5-turbo` model, known for its balance of low cost and fast performance.
- Be aware that OpenAI’s developer API does not include free usage. Please review the [OpenAI pricing](https://openai.com/api/pricing/) for detailed information.
- To create an OpenAI API key, visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys), and use the **+ Create new secret key** button.
- Set the API key & model in-game:
- `/creaturechat key set <YOUR-SECRET-KEY-HERE>`
- `/creaturechat model set gpt-3.5-turbo`
### 3. Token Shop
Supports CreatureChat by purchasing tokens from the developers:
- Easy setup with simple token packs, created for CreatureChat users.
- More info is available in the #token-shop channel on our [Discord](https://discord.gg/m9dvPFmN3e).
- Set the token-shop API key in-game:
- `/creaturechat key set <YOUR-SECRET-KEY-HERE>`
### In-game Commands / Configuration
- **REQUIRED:** `/creaturechat key set <key>`
- Sets the *OpenAI API key*. This is required for making requests to the LLM.
- **OPTIONAL:** `/creaturechat url set "<url>"`
- Sets the URL of the API used to make LLM requests. Defaults to `"https://api.openai.com/v1/chat/completions"`
- Sets the URL of the API used to make LLM requests. Defaults to `"https://api.openai.com/v1/chat/completions"`.
- **OPTIONAL:** `/creaturechat model set <model>`
- Sets the model used for generating responses in chats. Defaults to `gpt-3.5-turbo`.
- **OPTIONAL:** `/creaturechat timeout set <seconds>`
- Sets the timeout (in seconds) for API HTTP requests. Defaults to `10` seconds.
### Configuration Scope:
**OPTIONAL:** You can specify the configuration scope at the end of each command to determine where settings should be applied:
- **Default** Configuration (`--config default`):
Applies the configuration universally, unless overridden by a server-specific configuration.
- **Server**-Specific Configuration (`--config server`):
Applies the configuration only to the server where the command is executed.
- If the `--config` option is not specified, the `default` configuration scope is assumed.
## Costs & Security
Using third-party Large Language Model (LLM) APIs, such as OpenAI, will incur usage-based **fees**.
These fees are based on the amount of data processed. Before integrating your API key, please
[review the pricing](https://openai.com/pricing#language-models) details provided by the API provider.
Be aware of the **potential costs** and plan your usage accordingly to avoid unexpected charges.
## Does OpenAI offer a **FREE** model?
While ChatGPT is a popular product and does offer a free version to their users on their website,
the OpenAI developer API does not extend any free models or free usage. You will be charged for each token
consumed and generated. We use the `gpt-3.5-turbo` model by default, due to its extremely low cost
and fast performance... however it is not free.
## Free Local LLM
CreatureChat fully supports **free & open-source** LLMs. An HTTP endpoint which supports the OpenAI Chat Completion
JSON syntax is required. We highly recommend using [Ollama](https://ollama.com/) or [LiteLLM](https://litellm.vercel.app/) as your HTTP proxy.
LiteLLM supports **100+ LLMs** (including Anthropic, VertexAI, HuggingFace, Google Gemini, and Ollama), and proxies them through a
local HTTP endpoint in a compatible format with CreatureChat. *NOTE: You must have a very expensive GPU to run a local
LLM on your computer at a speed which is fast enough to be playable in Minecraft.*
## Screenshots
- Sets the timeout (in seconds) for API HTTP requests. Defaults to `10` seconds.
- **OPTIONAL:** `/creaturechat whitelist <entityType | all | clear>` - Show chat bubbles
- Shows chat bubbles for the specified entity type or all entities, or clears the whitelist.
- **OPTIONAL:** `/creaturechat blacklist <entityType | all | clear>` - Hide chat bubbles
- Hides chat bubbles for the specified entity type or all entities, or clears the blacklist.
- **OPTIONAL:** `/story set "<story-text>"`
- Sets a custom story (included in character creation and chat prompts).
- **OPTIONAL:** `/story display | clear`
- Display or clear the current story.
#### Configuration Scope (default | server):
- **OPTIONAL:** Specify the configuration scope at the end of each command to determine where settings should be applied:
- **Default Configuration (`--config default`):** Applies the configuration universally, unless overridden by a server-specific configuration.
- **Server-Specific Configuration (`--config server`):** Applies the configuration only to the server where the command is executed.
- If the `--config` option is not specified, the `default` configuration scope is assumed.
### Screenshots
![Interact with Minecraft Creatures](src/main/resources/assets/creaturechat/screenshots/salmon-follow.png)
![Panda Following the Player](src/main/resources/assets/creaturechat/screenshots/panda-follow.png)
![Piglins Reacting to Player](src/main/resources/assets/creaturechat/screenshots/piglin-reactions.png)
![Enderman Following the Player](src/main/resources/assets/creaturechat/screenshots/enderman-follow.png)
![Chat UI](src/main/resources/assets/creaturechat/screenshots/chat-ui.png)
## Authors
### Authors
- Jonathan Thomas <jonathan@openshot.org>
- owlmaddie <owlmaddie@gmail.com>
## Contact & Resources
### Contact & Resources
- [Join us on Discord](https://discord.gg/m9dvPFmN3e)
- [Build Instructions](INSTALL.md) ([Source Code](http://gitlab.openshot.org/minecraft/creature-chat))
- Download from [Modrinth](https://modrinth.com/project/creaturechat) or [CurseForge](https://www.curseforge.com/minecraft/mc-mods/creaturechat)
- Download from [Modrinth](https://modrinth.com/project/creaturechat)
- Follow Us: [YouTube](https://www.youtube.com/@CreatureChat/featured) |
[Twitter](https://twitter.com/TheCreatureChat) |
[TikTok](https://www.tiktok.com/@creaturechat)
## Legal Information
### Legal Information
Please review our [Terms of Service](TERMS.md) and [Privacy Policy](PRIVACY.md) before using CreatureChat. By using our services, you agree to comply with these documents.
Please review our [Terms of Service](TERMS.md) and [Privacy Policy](PRIVACY.md) before using CreatureChat.
By using our services, you agree to comply with these documents.
## License
### License
CreatureChat is a Minecraft mod which allows chat conversations with entities.
Copyright (C) 2024 owlmaddie LLC
......
......@@ -6,26 +6,64 @@ CURSEFORGE_API_KEY=${CURSEFORGE_API_KEY}
CHANGELOG_FILE="./CHANGELOG.md"
API_URL="https://minecraft.curseforge.com/api"
PROJECT_ID=1012118
DEPENDENCY_SLUG="fabric-api"
USER_AGENT="CreatureChat-Minecraft-Mod:curseforge@owlmaddie.com"
SLEEP_DURATION=30
SLEEP_DURATION=5
# Function to fetch game version IDs
# Function to fetch version types and return the base game type ID
fetch_base_version_id() {
local base_version=$(echo "$1" | grep -oE '^[0-9]+\.[0-9]+')
local version_types_cache="/tmp/version_types.json"
if [ ! -f "$version_types_cache" ]; then
curl --retry 3 --retry-delay 5 -s -H "X-Api-Token: $CURSEFORGE_API_KEY" "$API_URL/game/version-types" > "$version_types_cache"
fi
local version_types_response=$(cat "$version_types_cache")
local base_version_id=$(echo "$version_types_response" | jq -r --arg base_version "Minecraft $base_version" '.[] | select(.name == $base_version) | .id')
if [ -z "$base_version_id" ]; then
echo "ERROR: Base version ID not found."
exit 1
fi
echo "$base_version_id"
}
# Main function to fetch game version IDs
fetch_game_version_ids() {
local minecraft_version="$1"
local response=$(curl --retry 3 --retry-delay 5 -s -H "X-Api-Token: $CURSEFORGE_API_KEY" "$API_URL/game/versions")
# Fetch the base version ID
local base_version_id=$(fetch_base_version_id "$minecraft_version")
# Cache the game versions JSON data
local game_versions_cache="/tmp/game_versions.json"
if [ ! -f "$game_versions_cache" ]; then
curl --retry 3 --retry-delay 5 -s -H "X-Api-Token: $CURSEFORGE_API_KEY" "$API_URL/game/versions" > "$game_versions_cache"
fi
local response=$(cat "$game_versions_cache")
# Find the specific version ID from the cached data
local minecraft_id=$(echo "$response" | jq -r --arg base_version_id "$base_version_id" --arg full_version "$minecraft_version" '.[] | select(.gameVersionTypeID == ($base_version_id | tonumber) and .name == $full_version) | .id' | head -n 1)
if [ -z "$minecraft_id" ]; then
echo "ERROR: Minecraft version ID not found."
exit 1
fi
# Retrieve the other IDs as before
local client_id=$(echo "$response" | jq -r '.[] | select(.name == "Client") | .id')
local server_id=$(echo "$response" | jq -r '.[] | select(.name == "Server") | .id')
local fabric_id=$(echo "$response" | jq -r '.[] | select(.name == "Fabric") | .id')
local minecraft_id=$(echo "$response" | jq -r --arg mv "$minecraft_version" '.[] | select(.name == $mv) | .id' | head -n 1)
local forge_id=$(echo "$response" | jq -r '.[] | select(.name == "Forge") | .id')
if [ -z "$client_id" ] || [ -z "$server_id" ] || [ -z "$fabric_id" ] || [ -z "$minecraft_id" ]; then
if [ -z "$client_id" ] || [ -z "$server_id" ] || ([ -z "$fabric_id" ] && [ -z "$forge_id" ]); then
echo "ERROR: One or more game version IDs not found."
exit 1
fi
echo "$client_id $server_id $fabric_id $minecraft_id"
echo "$client_id $server_id $fabric_id $forge_id $minecraft_id"
}
# Read the first changelog block
......@@ -52,7 +90,7 @@ for FILE in creaturechat*.jar; do
echo "--------------$FILE----------------"
FILE_BASENAME=$(basename "$FILE")
OUR_VERSION=$(echo "$FILE_BASENAME" | sed -n 's/creaturechat-\(.*\)+.*\.jar/\1/p')
MINECRAFT_VERSION=$(echo "$FILE_BASENAME" | sed -n 's/.*+\(.*\)\.jar/\1/p')
MINECRAFT_VERSION=$(echo "$FILE_BASENAME" | sed -n 's/.*+\([0-9.]*\)\(-forge\)*\.jar/\1/p')
VERSION_NUMBER="$OUR_VERSION-$MINECRAFT_VERSION"
# Verify that OUR_VERSION and MINECRAFT_VERSION are not empty and OUR_VERSION matches VERSION
......@@ -64,16 +102,33 @@ for FILE in creaturechat*.jar; do
echo "Preparing to upload $FILE_BASENAME as version $VERSION_NUMBER..."
# Fetch game version IDs
GAME_TYPE_ID=$(fetch_base_version_id "$MINECRAFT_VERSION")
GAME_VERSION_IDS=($(fetch_game_version_ids "$MINECRAFT_VERSION"))
# DEBUG
echo "Minecraft Type ID: $GAME_TYPE_ID"
echo "Minecraft Versions IDs (client_id: ${GAME_VERSION_IDS[0]}, server_id: ${GAME_VERSION_IDS[1]}, fabric_id: ${GAME_VERSION_IDS[2]}, forge_id: ${GAME_VERSION_IDS[3]}, minecraft_id: ${GAME_VERSION_IDS[4]})"
# Determine the dependency slugs and loader ID based on the file name
if [[ "$FILE_BASENAME" == *"-forge.jar" ]]; then
DEPENDENCY_SLUGS=("sinytra-connector" "forgified-fabric-api")
GAME_VERSIONS="[${GAME_VERSION_IDS[0]}, ${GAME_VERSION_IDS[1]}, ${GAME_VERSION_IDS[3]}, ${GAME_VERSION_IDS[4]}]"
else
DEPENDENCY_SLUGS=("fabric-api")
GAME_VERSIONS="[${GAME_VERSION_IDS[0]}, ${GAME_VERSION_IDS[1]}, ${GAME_VERSION_IDS[2]}, ${GAME_VERSION_IDS[4]}]"
fi
# Create dependencies array for payload
RELATIONS=$(for slug in "${DEPENDENCY_SLUGS[@]}"; do jq -n --arg slug "$slug" '{"slug": $slug, "type": "requiredDependency"}'; done | jq -s .)
# Create a new version payload
PAYLOAD=$(jq -n --arg changelog "$CHANGELOG" \
--arg changelogType "markdown" \
--arg displayName "$FILE_BASENAME" \
--argjson gameVersions "$(printf '%s\n' "${GAME_VERSION_IDS[@]}" | jq -R . | jq -s .)" \
--argjson gameVersionTypeIds '[75125]' \
--argjson gameVersions "$GAME_VERSIONS" \
--argjson gameVersionTypeIds "[$GAME_TYPE_ID]" \
--arg releaseType "release" \
--argjson relations '[{"slug": "'"$DEPENDENCY_SLUG"'", "type": "requiredDependency"}]' \
--argjson relations "$RELATIONS" \
'{
"changelog": $changelog,
"changelogType": $changelogType,
......
......@@ -8,7 +8,7 @@ API_URL="https://api.modrinth.com/v2"
USER_AGENT="CreatureChat-Minecraft-Mod:modrinth@owlmaddie.com"
PROJECT_ID="rvR0de1E"
AUTHOR_ID="k6RiShdd"
SLEEP_DURATION=10
SLEEP_DURATION=5
# Read the first changelog block
CHANGELOG=$(awk '/^## \[/{ if (p) exit; p=1 } p' "$CHANGELOG_FILE")
......@@ -34,7 +34,7 @@ for FILE in creaturechat*.jar; do
echo "--------------$FILE----------------"
FILE_BASENAME=$(basename "$FILE")
OUR_VERSION=$(echo "$FILE_BASENAME" | sed -n 's/creaturechat-\(.*\)+.*\.jar/\1/p')
MINECRAFT_VERSION=$(echo "$FILE_BASENAME" | sed -n 's/.*+\(.*\)\.jar/\1/p')
MINECRAFT_VERSION=$(echo "$FILE_BASENAME" | sed -n 's/.*+\([0-9.]*\)\(-forge\)*\.jar/\1/p')
VERSION_NUMBER="$OUR_VERSION+$MINECRAFT_VERSION"
# Verify that OUR_VERSION and MINECRAFT_VERSION are not empty and OUR_VERSION matches VERSION
......@@ -43,13 +43,14 @@ for FILE in creaturechat*.jar; do
exit 1
fi
# Check if the version already exists
echo "Checking if version $VERSION_NUMBER already exists on Modrinth..."
if curl --retry 3 --retry-delay 5 --silent --fail -X GET "$API_URL/project/creaturechat/version/$VERSION_NUMBER" > /dev/null 2>&1; then
echo "Version $VERSION_NUMBER already exists, skipping."
continue
# Determine the loaders and dependencies based on the file name
if [[ "$FILE_BASENAME" == *"-forge.jar" ]]; then
LOADERS='["forge"]'
DEPENDENCIES='[{"project_id": "u58R1TMW", "dependency_type": "required"}, {"project_id": "Aqlf1Shp", "dependency_type": "required"}]'
else
LOADERS='["fabric"]'
DEPENDENCIES='[{"project_id": "P7dR8mSH", "dependency_type": "required"}]'
fi
echo "Version $VERSION_NUMBER does not exist. Preparing to upload..."
# Calculate file hashes
SHA512_HASH=$(sha512sum "$FILE" | awk '{ print $1 }')
......@@ -59,9 +60,9 @@ for FILE in creaturechat*.jar; do
# Create a new version payload
PAYLOAD=$(jq -n --arg version_number "$VERSION_NUMBER" \
--arg changelog "$CHANGELOG" \
--argjson dependencies '[{"project_id": "P7dR8mSH", "dependency_type": "required"}]' \
--argjson dependencies "$DEPENDENCIES" \
--argjson game_versions '["'"$MINECRAFT_VERSION"'"]' \
--argjson loaders '["fabric"]' \
--argjson loaders "$LOADERS" \
--arg project_id "$PROJECT_ID" \
--arg name "CreatureChat $VERSION_NUMBER" \
--argjson file_parts '["file"]' \
......
......@@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx1G
org.gradle.parallel=true
# Mod Properties
mod_version=1.0.7
mod_version=1.2.1
maven_group=com.owlmaddie
archives_base_name=creaturechat
......
......@@ -2,14 +2,19 @@ package com.owlmaddie;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.network.ClientPackets;
import com.owlmaddie.particle.CreatureParticleFactory;
import com.owlmaddie.particle.LeadParticleFactory;
import com.owlmaddie.ui.BubbleRenderer;
import com.owlmaddie.ui.ClickHandler;
import com.owlmaddie.ui.PlayerMessageManager;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.particle.v1.ParticleFactoryRegistry;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents;
import static com.owlmaddie.network.ServerPackets.*;
/**
* The {@code ClientInit} class initializes this mod in the client and defines all hooks into the
* render pipeline to draw chat bubbles, text, and entity icons.
......@@ -19,6 +24,20 @@ public class ClientInit implements ClientModInitializer {
@Override
public void onInitializeClient() {
// Register particle factories
ParticleFactoryRegistry.getInstance().register(HEART_SMALL_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(HEART_BIG_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FIRE_SMALL_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FIRE_BIG_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(ATTACK_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FLEE_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FOLLOW_FRIEND_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(FOLLOW_ENEMY_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(PROTECT_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(LEAD_FRIEND_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(LEAD_ENEMY_PARTICLE, CreatureParticleFactory::new);
ParticleFactoryRegistry.getInstance().register(LEAD_PARTICLE, LeadParticleFactory::new);
ClientTickEvents.END_CLIENT_TICK.register(client -> {
tickCounter++;
PlayerMessageManager.tickUpdate();
......
......@@ -3,6 +3,9 @@ package com.owlmaddie.network;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.ui.BubbleRenderer;
import com.owlmaddie.ui.PlayerMessageManager;
import com.owlmaddie.utils.ClientEntityFinder;
import com.owlmaddie.utils.Decompression;
......@@ -19,8 +22,8 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* The {@code ClientPackets} class provides methods to send packets to/from the server for generating greetings,
......@@ -90,41 +93,77 @@ public class ClientPackets {
ClientPlayNetworking.send(ServerPackets.PACKET_C2S_SEND_CHAT, buf);
}
// Reading a Map<String, PlayerData> from the buffer
public static Map<String, PlayerData> readPlayerDataMap(PacketByteBuf buffer) {
int size = buffer.readInt(); // Read the size of the map
Map<String, PlayerData> map = new HashMap<>();
for (int i = 0; i < size; i++) {
String key = buffer.readString(); // Read the key (playerName)
PlayerData data = new PlayerData();
data.friendship = buffer.readInt(); // Read PlayerData field(s)
map.put(key, data); // Add to the map
}
return map;
}
public static void register() {
// Client-side packet handler, message sync
ClientPlayNetworking.registerGlobalReceiver(ServerPackets.PACKET_S2C_MESSAGE, (client, handler, buffer, responseSender) -> {
// Read the data from the server packet
UUID entityId = UUID.fromString(buffer.readString());
String playerIdStr = buffer.readString();
String sendingPlayerIdStr = buffer.readString(32767);
String senderPlayerName = buffer.readString(32767);
UUID senderPlayerId;
if (!sendingPlayerIdStr.isEmpty()) {
senderPlayerId = UUID.fromString(sendingPlayerIdStr);
} else {
senderPlayerId = null;
}
String message = buffer.readString(32767);
int line = buffer.readInt();
String status_name = buffer.readString(32767);
ChatDataManager.ChatStatus status = ChatDataManager.ChatStatus.valueOf(status_name);
String sender_name = buffer.readString(32767);
int friendship = buffer.readInt();
ChatDataManager.ChatSender sender = ChatDataManager.ChatSender.valueOf(sender_name);
Map<String, PlayerData> players = readPlayerDataMap(buffer);
// Update the chat data manager on the client-side
client.execute(() -> { // Make sure to run on the client thread
// Ensure client.player is initialized
if (client.player == null || client.world == null) {
LOGGER.warn("Client not fully initialized. Dropping message for entity '{}'.", entityId);
return;
}
// Update the chat data manager on the client-side
MobEntity entity = ClientEntityFinder.getEntityByUUID(client.world, entityId);
if (entity != null) {
ChatDataManager chatDataManager = ChatDataManager.getClientInstance();
ChatDataManager.EntityChatData chatData = chatDataManager.getOrCreateChatData(entity.getUuidAsString());
chatData.playerId = playerIdStr;
if (entity == null) {
LOGGER.warn("Entity with ID '{}' not found. Skipping message processing.", entityId);
return;
}
// Get entity chat data for current entity & player
ChatDataManager chatDataManager = ChatDataManager.getClientInstance();
EntityChatData chatData = chatDataManager.getOrCreateChatData(entity.getUuidAsString());
if (senderPlayerId != null && sender == ChatDataManager.ChatSender.USER && status == ChatDataManager.ChatStatus.DISPLAY) {
// Add player message to queue for rendering
PlayerMessageManager.addMessage(senderPlayerId, message, senderPlayerName, ChatDataManager.TICKS_TO_DISPLAY_USER_MESSAGE);
chatData.status = ChatDataManager.ChatStatus.PENDING;
} else {
// Add entity message
if (!message.isEmpty()) {
chatData.currentMessage = message;
}
chatData.currentLineNumber = line;
chatData.status = ChatDataManager.ChatStatus.valueOf(status_name);
chatData.sender = ChatDataManager.ChatSender.valueOf(sender_name);
chatData.friendship = friendship;
if (chatData.sender == ChatDataManager.ChatSender.USER && !playerIdStr.isEmpty()) {
// Add player message to queue for rendering
PlayerMessageManager.addMessage(UUID.fromString(chatData.playerId), chatData.currentMessage, ChatDataManager.TICKS_TO_DISPLAY_USER_MESSAGE);
}
// Play sound with volume based on distance (from player or entity)
playNearbyUISound(client, entity, 0.2f);
chatData.status = status;
chatData.sender = sender;
chatData.players = players;
}
// Play sound with volume based on distance (from player or entity)
playNearbyUISound(client, entity, 0.2f);
});
});
......@@ -150,14 +189,14 @@ public class ClientPackets {
// Decompress the combined byte array to get the original JSON string
String chatDataJSON = Decompression.decompressString(combined.toByteArray());
if (chatDataJSON == null) {
LOGGER.info("Error decompressing lite JSON string from bytes");
if (chatDataJSON == null || chatDataJSON.isEmpty()) {
LOGGER.warn("Received invalid or empty chat data JSON. Skipping processing.");
return;
}
// Parse JSON and update client chat data
Gson GSON = new Gson();
Type type = new TypeToken<HashMap<String, ChatDataManager.EntityChatData>>(){}.getType();
Type type = new TypeToken<ConcurrentHashMap<String, EntityChatData>>(){}.getType();
ChatDataManager.getClientInstance().entityChatDataMap = GSON.fromJson(chatDataJSON, type);
// Clear receivedChunks for future use
......@@ -166,6 +205,28 @@ public class ClientPackets {
});
});
// Client-side packet handler, receive entire whitelist / blacklist, and update BubbleRenderer
ClientPlayNetworking.registerGlobalReceiver(ServerPackets.PACKET_S2C_WHITELIST, (client, handler, buffer, responseSender) -> {
// Read the whitelist data from the buffer
int whitelistSize = buffer.readInt();
List<String> whitelist = new ArrayList<>(whitelistSize);
for (int i = 0; i < whitelistSize; i++) {
whitelist.add(buffer.readString(32767));
}
// Read the blacklist data from the buffer
int blacklistSize = buffer.readInt();
List<String> blacklist = new ArrayList<>(blacklistSize);
for (int i = 0; i < blacklistSize; i++) {
blacklist.add(buffer.readString(32767));
}
client.execute(() -> {
BubbleRenderer.whitelist = whitelist;
BubbleRenderer.blacklist = blacklist;
});
});
// Client-side packet handler, player status sync
ClientPlayNetworking.registerGlobalReceiver(ServerPackets.PACKET_S2C_PLAYER_STATUS, (client, handler, buffer, responseSender) -> {
// Read the data from the server packet
......@@ -176,7 +237,12 @@ public class ClientPackets {
PlayerEntity player = ClientEntityFinder.getPlayerEntityFromUUID(playerId);
// Update the player status data manager on the client-side
client.execute(() -> { // Make sure to run on the client thread
client.execute(() -> {
if (player == null) {
LOGGER.warn("Player entity is null. Skipping status update.");
return;
}
if (isChatOpen) {
PlayerMessageManager.openChatUI(playerId);
playNearbyUISound(client, player, 0.2f);
......@@ -189,7 +255,7 @@ public class ClientPackets {
private static void playNearbyUISound(MinecraftClient client, Entity player, float maxVolume) {
// Play sound with volume based on distance
int distance_squared = 64;
int distance_squared = 144;
if (client.player != null) {
double distance = client.player.squaredDistanceTo(player.getX(), player.getY(), player.getZ());
if (distance <= distance_squared) {
......
package com.owlmaddie.particle;
import net.minecraft.client.particle.ParticleTextureSheet;
import net.minecraft.client.particle.SpriteBillboardParticle;
import net.minecraft.client.world.ClientWorld;
/**
* The {@code BehaviorParticle} class defines a custom CreatureChat behavior particle with an initial upward velocity
* that gradually decreases, ensuring it never moves downward.
*/
public class BehaviorParticle extends SpriteBillboardParticle {
protected BehaviorParticle(ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ) {
super(world, x, y, z, velocityX, velocityY, velocityZ);
this.scale(2f);
this.setMaxAge(35);
// Start with an initial upward velocity
this.velocityY = 0.1;
this.velocityX *= 0.1;
this.velocityZ *= 0.1;
this.collidesWithWorld = false;
}
@Override
public ParticleTextureSheet getType() {
return ParticleTextureSheet.PARTICLE_SHEET_OPAQUE;
}
@Override
public int getBrightness(float tint) {
return 0xF000F0;
}
@Override
public void tick() {
super.tick();
// Gradually decrease the upward velocity over time
if (this.velocityY > 0) {
this.velocityY -= 0.002;
}
// Ensure the particle doesn't start moving downwards
if (this.velocityY < 0) {
this.velocityY = 0;
}
}
}
package com.owlmaddie.particle;
import net.minecraft.client.particle.ParticleFactory;
import net.minecraft.client.particle.SpriteProvider;
import net.minecraft.client.world.ClientWorld;
import net.minecraft.particle.DefaultParticleType;
/**
* The {@code CreatureParticleFactory} class is responsible for creating instances of
* {@link BehaviorParticle} with the specified parameters.
*/
public class CreatureParticleFactory implements ParticleFactory<DefaultParticleType> {
private final SpriteProvider spriteProvider;
public CreatureParticleFactory(SpriteProvider spriteProvider) {
this.spriteProvider = spriteProvider;
}
@Override
public BehaviorParticle createParticle(DefaultParticleType type, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ) {
BehaviorParticle particle = new BehaviorParticle(world, x, y, z, velocityX, velocityY, velocityZ);
particle.setSprite(this.spriteProvider);
return particle;
}
}
package com.owlmaddie.particle;
import net.minecraft.client.particle.ParticleTextureSheet;
import net.minecraft.client.particle.SpriteBillboardParticle;
import net.minecraft.client.particle.SpriteProvider;
import net.minecraft.client.render.Camera;
import net.minecraft.client.render.VertexConsumer;
import net.minecraft.client.world.ClientWorld;
import net.minecraft.util.math.MathHelper;
import org.joml.Vector3f;
import net.minecraft.util.math.Vec3d;
/**
* The {@code LeadParticle} class renders a static LEAD behavior particle (i.e. animated arrow pointing in the direction of lead). It
* uses a SpriteProvider for animation.
*/
public class LeadParticle extends SpriteBillboardParticle {
private final SpriteProvider spriteProvider;
public LeadParticle(ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ, SpriteProvider spriteProvider, double angle) {
super(world, x, y, z, 0, 0, 0);
this.velocityX = 0f;
this.velocityY = 0f;
this.velocityZ = 0f;
this.spriteProvider = spriteProvider;
this.angle = (float) angle;
this.scale(4.5F);
this.setMaxAge(40);
this.setSpriteForAge(spriteProvider);
}
@Override
public void tick() {
super.tick();
this.setSpriteForAge(spriteProvider);
}
@Override
public int getBrightness(float tint) {
return 0xF000F0;
}
@Override
public ParticleTextureSheet getType() {
return ParticleTextureSheet.PARTICLE_SHEET_TRANSLUCENT;
}
@Override
public void buildGeometry(VertexConsumer vertexConsumer, Camera camera, float tickDelta) {
// Get the current position of the particle relative to the camera
Vec3d cameraPos = camera.getPos();
float particleX = (float)(MathHelper.lerp((double)tickDelta, this.prevPosX, this.x) - cameraPos.getX());
float particleY = (float)(MathHelper.lerp((double)tickDelta, this.prevPosY, this.y) - cameraPos.getY());
float particleZ = (float)(MathHelper.lerp((double)tickDelta, this.prevPosZ, this.z) - cameraPos.getZ());
// Define the four vertices of the particle (keeping it flat on the XY plane)
Vector3f[] vertices = new Vector3f[]{
new Vector3f(-1.0F, 0.0F, -1.0F), // Bottom-left
new Vector3f(-1.0F, 0.0F, 1.0F), // Top-left
new Vector3f(1.0F, 0.0F, 1.0F), // Top-right
new Vector3f(1.0F, 0.0F, -1.0F) // Bottom-right
};
// Apply scaling and rotation using the particle's angle (in world space)
float size = this.getSize(tickDelta); // Get the size of the particle at the current tick
for (Vector3f vertex : vertices) {
vertex.mul(size); // Scale the vertices
vertex.rotateY(angle);
vertex.add(particleX, particleY, particleZ); // Translate to particle position
}
// Get the UV coordinates from the sprite (used for texture mapping)
float minU = this.getMinU();
float maxU = this.getMaxU();
float minV = this.getMinV();
float maxV = this.getMaxV();
int light = this.getBrightness(tickDelta);
// Render each vertex of the particle (flat on the XY plane)
vertexConsumer.vertex(vertices[0].x(), vertices[0].y(), vertices[0].z()).texture(maxU, maxV).color(this.red, this.green, this.blue, this.alpha).light(light).next();
vertexConsumer.vertex(vertices[1].x(), vertices[1].y(), vertices[1].z()).texture(maxU, minV).color(this.red, this.green, this.blue, this.alpha).light(light).next();
vertexConsumer.vertex(vertices[2].x(), vertices[2].y(), vertices[2].z()).texture(minU, minV).color(this.red, this.green, this.blue, this.alpha).light(light).next();
vertexConsumer.vertex(vertices[3].x(), vertices[3].y(), vertices[3].z()).texture(minU, maxV).color(this.red, this.green, this.blue, this.alpha).light(light).next();
}
}
\ No newline at end of file
package com.owlmaddie.particle;
import net.minecraft.client.particle.ParticleFactory;
import net.minecraft.client.particle.SpriteProvider;
import net.minecraft.client.world.ClientWorld;
/**
* The {@code LeadParticleFactory} class generates new arrow particles for LEAD behavior. It passes along the 'angle' to rotate the particle. It also
* sets the motion/acceleration to 0.
*/
public class LeadParticleFactory implements ParticleFactory<LeadParticleEffect> {
private final SpriteProvider spriteProvider;
public LeadParticleFactory(SpriteProvider spriteProvider) {
this.spriteProvider = spriteProvider;
}
@Override
public LeadParticle createParticle(LeadParticleEffect effect, ClientWorld world, double x, double y, double z, double velocityX, double velocityY, double velocityZ) {
double angle = effect.getAngle();
return new LeadParticle(world, x, y, z, 0, 0, 0, this.spriteProvider, angle);
}
}
......@@ -2,6 +2,8 @@ package com.owlmaddie.ui;
import com.mojang.blaze3d.systems.RenderSystem;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.utils.EntityHeights;
import com.owlmaddie.utils.EntityRendererAccessor;
import com.owlmaddie.utils.TextureLoader;
......@@ -9,6 +11,7 @@ import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.font.TextRenderer;
import net.minecraft.client.font.TextRenderer.TextLayerType;
import net.minecraft.client.network.ClientPlayerEntity;
import net.minecraft.client.render.*;
import net.minecraft.client.render.entity.EntityRenderer;
import net.minecraft.client.util.math.MatrixStack;
......@@ -17,6 +20,7 @@ import net.minecraft.entity.boss.dragon.EnderDragonEntity;
import net.minecraft.entity.boss.dragon.EnderDragonPart;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.registry.Registries;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.Box;
import net.minecraft.util.math.MathHelper;
......@@ -27,6 +31,7 @@ import org.joml.Quaternionf;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
......@@ -43,6 +48,10 @@ public class BubbleRenderer {
public static long lastTick = 0;
public static int light = 15728880;
public static int overlay = OverlayTexture.DEFAULT_UV;
public static List<String> whitelist = new ArrayList<>();
public static List<String> blacklist = new ArrayList<>();
private static int queryEntityDataCount = 0;
private static List<Entity> relevantEntities;
public static void drawTextBubbleBackground(String base_name, MatrixStack matrices, float x, float y, float width, float height, int friendship) {
// Set shader & texture
......@@ -61,9 +70,9 @@ public class BubbleRenderer {
// Draw UI text background (based on friendship)
// Draw TOP
if (friendship == -3) {
if (friendship == -3 && !base_name.endsWith("-player")) {
RenderSystem.setShaderTexture(0, textures.GetUI(base_name + "-enemy"));
} else if (friendship == 3) {
} else if (friendship == 3 && !base_name.endsWith("-player")) {
RenderSystem.setShaderTexture(0, textures.GetUI(base_name + "-friend"));
} else {
RenderSystem.setShaderTexture(0, textures.GetUI(base_name));
......@@ -344,7 +353,7 @@ public class BubbleRenderer {
TextRenderer fontRenderer = MinecraftClient.getInstance().textRenderer;
// Get Name of entity
String nameText = "CreatureChat";
String nameText = "";
if (entity instanceof MobEntity) {
// Custom Name Tag (MobEntity)
if (entity.getCustomName() != null) {
......@@ -391,16 +400,39 @@ public class BubbleRenderer {
// Get camera position
Vec3d interpolatedCameraPos = new Vec3d(camera.getPos().x, camera.getPos().y, camera.getPos().z);
// Get all entities
List<Entity> nearbyEntities = world.getOtherEntities(null, area);
// Filter to include only MobEntity & PlayerEntity but exclude any camera 1st person entity and any entities with passengers
List<Entity> relevantEntities = nearbyEntities.stream()
.filter(entity -> (entity instanceof MobEntity || entity instanceof PlayerEntity))
.filter(entity -> !entity.hasPassengers())
.filter(entity -> !(entity.equals(cameraEntity) && !camera.isThirdPerson()))
.filter(entity -> !(entity.equals(cameraEntity) && entity.isSpectator()))
.collect(Collectors.toList());
// Increment query counter
queryEntityDataCount++;
// This query count helps us cache the list of relevant entities. We can refresh
// the list every 3rd call to this render function
if (queryEntityDataCount % 3 == 0 || relevantEntities == null) {
// Get all entities
List<Entity> nearbyEntities = world.getOtherEntities(null, area);
// Filter to include only MobEntity & PlayerEntity but exclude any camera 1st person entity and any entities with passengers
relevantEntities = nearbyEntities.stream()
.filter(entity -> (entity instanceof MobEntity || entity instanceof PlayerEntity))
.filter(entity -> !entity.hasPassengers())
.filter(entity -> !(entity.equals(cameraEntity) && !camera.isThirdPerson()))
.filter(entity -> !(entity.equals(cameraEntity) && entity.isSpectator()))
.filter(entity -> {
// Always include PlayerEntity
if (entity instanceof PlayerEntity) {
return true;
}
Identifier entityId = Registries.ENTITY_TYPE.getId(entity.getType());
String entityIdString = entityId.toString();
// Check blacklist first
if (blacklist.contains(entityIdString)) {
return false;
}
// If whitelist is not empty, only include entities in the whitelist
return whitelist.isEmpty() || whitelist.contains(entityIdString);
})
.collect(Collectors.toList());
queryEntityDataCount = 0;
}
for (Entity entity : relevantEntities) {
......@@ -484,12 +516,20 @@ public class BubbleRenderer {
// Get position matrix
Matrix4f matrix = matrices.peek().getPositionMatrix();
// Look-up greeting (if any)
ChatDataManager.EntityChatData chatData = null;
// Get the player
ClientPlayerEntity player = MinecraftClient.getInstance().player;
// Get chat message (if any)
EntityChatData chatData = null;
PlayerData playerData = null;
if (entity instanceof MobEntity) {
chatData = ChatDataManager.getClientInstance().getOrCreateChatData(entity.getUuidAsString());
if (chatData != null) {
playerData = chatData.getPlayerData(player.getDisplayName().getString());
}
} else if (entity instanceof PlayerEntity) {
chatData = PlayerMessageManager.getMessage(entity.getUuid());
playerData = new PlayerData(); // no friendship needed for player messages
}
float minTextHeight = (ChatDataManager.DISPLAY_NUM_LINES * (fontRenderer.fontHeight + lineSpacing)) + (DISPLAY_PADDING * 2);
......@@ -524,6 +564,9 @@ public class BubbleRenderer {
// Draw 'start chat' button
drawIcon("button-chat", matrices, -16, textHeaderHeight, 32, 17);
// Draw Entity (Custom Name)
drawEntityName(entity, matrix, immediate, fullBright, 24F + DISPLAY_PADDING, true);
} else if (chatData.status == ChatDataManager.ChatStatus.PENDING) {
// Draw 'pending' button
drawIcon("button-dot-" + animationFrame, matrices, -16, textHeaderHeight, 32, 17);
......@@ -533,13 +576,13 @@ public class BubbleRenderer {
drawEntityName(entity, matrix, immediate, fullBright, 24F + DISPLAY_PADDING, true);
// Draw text background (no smaller than 50F tall)
drawTextBubbleBackground("text-top", matrices, -64, 0, 128, scaledTextHeight, chatData.friendship);
drawTextBubbleBackground("text-top", matrices, -64, 0, 128, scaledTextHeight, playerData.friendship);
// Draw face icon of entity
drawEntityIcon(matrices, entity, -82, 7, 32, 32);
// Draw Friendship status
drawFriendshipStatus(matrices, 51, 18, 31, 21, chatData.friendship);
drawFriendshipStatus(matrices, 51, 18, 31, 21, playerData.friendship);
// Draw 'arrows' & 'keyboard' buttons
if (chatData.currentLineNumber > 0) {
......@@ -559,10 +602,10 @@ public class BubbleRenderer {
drawEntityName(entity, matrix, immediate, fullBright, 24F + DISPLAY_PADDING, false);
// Draw 'resume chat' button
if (chatData.friendship == 3) {
if (playerData.friendship == 3) {
// Friend chat bubble
drawIcon("button-chat-friend", matrices, -16, textHeaderHeight, 32, 17);
} else if (chatData.friendship == -3) {
} else if (playerData.friendship == -3) {
// Enemy chat bubble
drawIcon("button-chat-enemy", matrices, -16, textHeaderHeight, 32, 17);
} else {
......@@ -575,7 +618,7 @@ public class BubbleRenderer {
drawEntityName(entity, matrix, immediate, fullBright, 24F + DISPLAY_PADDING, true);
// Draw text background
drawTextBubbleBackground("text-top-player", matrices, -64, 0, 128, scaledTextHeight, chatData.friendship);
drawTextBubbleBackground("text-top-player", matrices, -64, 0, 128, scaledTextHeight, playerData.friendship);
// Draw face icon of player
drawPlayerIcon(matrices, entity, -75, 14, 18, 18);
......
package com.owlmaddie.ui;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.network.ClientPackets;
import com.owlmaddie.utils.ClientEntityFinder;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.event.player.UseItemCallback;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.network.ClientPlayerEntity;
import net.minecraft.client.render.Camera;
import net.minecraft.entity.Entity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.item.ItemStack;
import net.minecraft.particle.ParticleTypes;
import net.minecraft.util.Hand;
import net.minecraft.util.TypedActionResult;
import net.minecraft.util.math.Vec3d;
import net.minecraft.world.World;
import org.slf4j.Logger;
......@@ -30,24 +36,44 @@ public class ClickHandler {
private static boolean wasClicked = false;
public static void register() {
UseItemCallback.EVENT.register(ClickHandler::handleUseItemAction);
// Handle empty hand right-click
ClientTickEvents.END_CLIENT_TICK.register(client -> {
if (client.options.useKey.isPressed()) {
if (!wasClicked) {
// The key has just been pressed down, so handle the 'click'
handleUseKeyClick(client);
wasClicked = true;
if (!wasClicked && client.player != null && client.player.getMainHandStack().isEmpty()) {
if (handleUseKeyClick(client)) {
wasClicked = true;
}
}
} else {
// The key has been released, so reset the wasClicked flag
wasClicked = false;
}
});
}
public static void handleUseKeyClick(MinecraftClient client) {
// Handle use-item right-click (non-empty hand)
private static TypedActionResult<ItemStack> handleUseItemAction(PlayerEntity player, World world, Hand hand) {
if (shouldCancelAction(world)) {
return TypedActionResult.fail(player.getStackInHand(hand));
}
return TypedActionResult.pass(player.getStackInHand(hand));
}
private static boolean shouldCancelAction(World world) {
if (world.isClient) {
MinecraftClient client = MinecraftClient.getInstance();
if (client != null && client.options.useKey.isPressed()) {
return handleUseKeyClick(client);
}
}
return false;
}
public static boolean handleUseKeyClick(MinecraftClient client) {
Camera camera = client.gameRenderer.getCamera();
Entity cameraEntity = camera.getFocusedEntity();
if (cameraEntity == null) return;
if (cameraEntity == null) return false;
// Get the player from the client
ClientPlayerEntity player = client.player;
......@@ -94,7 +120,7 @@ public class ClickHandler {
MobEntity closestEntity = ClientEntityFinder.getEntityByUUID(client.world, closestEntityUUID);
if (closestEntity != null) {
// Look-up conversation
ChatDataManager.EntityChatData chatData = ChatDataManager.getClientInstance().getOrCreateChatData(closestEntityUUID.toString());
EntityChatData chatData = ChatDataManager.getClientInstance().getOrCreateChatData(closestEntityUUID.toString());
// Determine area clicked inside chat bubble (top, left, right)
String hitRegion = determineHitRegion(closestHitResult.get(), closestBubbleData.position, camera, closestBubbleData.height);
......@@ -122,9 +148,10 @@ public class ClickHandler {
// Show chat
ClientPackets.setChatStatus(closestEntity, ChatDataManager.ChatStatus.DISPLAY);
}
return true;
}
}
return false;
}
public static Vec3d[] getBillboardCorners(Vec3d center, Vec3d cameraPos, double height, double width, double yaw, double pitch) {
......
package com.owlmaddie.ui;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import java.util.concurrent.atomic.AtomicInteger;
/**
......@@ -8,11 +10,11 @@ import java.util.concurrent.atomic.AtomicInteger;
* many ticks to remain visible, and the message to display. Similar to an EntityChatData, but
* much simpler.
*/
public class PlayerMessage extends ChatDataManager.EntityChatData {
public class PlayerMessage extends EntityChatData {
public AtomicInteger tickCountdown;
public PlayerMessage(String playerId, String messageText, int ticks) {
super("", playerId);
super(playerId);
this.currentMessage = messageText;
this.currentLineNumber = 0;
this.tickCountdown = new AtomicInteger(ticks);
......
......@@ -12,7 +12,7 @@ public class PlayerMessageManager {
private static final ConcurrentHashMap<UUID, PlayerMessage> messages = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<UUID, Boolean> openChatUIs = new ConcurrentHashMap<>();
public static void addMessage(UUID playerUUID, String messageText, int ticks) {
public static void addMessage(UUID playerUUID, String messageText, String playerName, int ticks) {
messages.put(playerUUID, new PlayerMessage(playerUUID.toString(), messageText, ticks));
}
......
......@@ -2,36 +2,17 @@ package com.owlmaddie.chat;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.owlmaddie.commands.ConfigurationHandler;
import com.owlmaddie.controls.SpeedControls;
import com.owlmaddie.goals.*;
import com.owlmaddie.items.RarityItemCollector;
import com.owlmaddie.json.QuestJson;
import com.owlmaddie.message.Behavior;
import com.owlmaddie.message.MessageParser;
import com.owlmaddie.message.ParsedMessage;
import com.owlmaddie.network.ServerPackets;
import com.owlmaddie.utils.LivingEntityInterface;
import com.owlmaddie.utils.Randomizer;
import com.owlmaddie.utils.ServerEntityFinder;
import net.minecraft.entity.boss.dragon.EnderDragonEntity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.item.ItemStack;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Rarity;
import net.minecraft.util.WorldSavePath;
import net.minecraft.util.math.MathHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
/**
* The {@code ChatDataManager} class manages chat data for all entities. This class also helps
......@@ -47,7 +28,6 @@ public class ChatDataManager {
public static int MAX_CHAR_IN_USER_MESSAGE = 512;
public static int TICKS_TO_DISPLAY_USER_MESSAGE = 70;
public static int MAX_AUTOGENERATE_RESPONSES = 3;
public QuestJson quest = null;
private static final Gson GSON = new Gson();
public enum ChatStatus {
......@@ -63,359 +43,7 @@ public class ChatDataManager {
}
// HashMap to associate unique entity IDs with their chat data
public HashMap<String, EntityChatData> entityChatDataMap;
public static class ChatMessage {
public String message;
public ChatSender sender;
public ChatMessage(String message, ChatSender sender) {
this.message = message;
this.sender = sender;
}
}
// Inner class to hold entity-specific data
public static class EntityChatData {
public String entityId;
public String playerId;
public String currentMessage;
public int currentLineNumber;
public ChatStatus status;
public List<ChatMessage> previousMessages;
public String characterSheet;
public ChatSender sender;
public int friendship; // -3 to 3 (0 = neutral)
public int auto_generated;
public EntityChatData(String entityId, String playerId) {
this.entityId = entityId;
this.playerId = playerId;
this.currentMessage = "";
this.currentLineNumber = 0;
this.previousMessages = new ArrayList<>();
this.characterSheet = "";
this.status = ChatStatus.NONE;
this.sender = ChatSender.USER;
this.friendship = 0;
this.auto_generated = 0;
}
// Light version with no 'previousMessages' attribute
public class EntityChatDataLight {
public String entityId;
public String currentMessage;
public int currentLineNumber;
public ChatStatus status;
public ChatSender sender;
public int friendship;
}
// Generate light version of chat data (no previous messages)
public EntityChatDataLight toLightVersion() {
EntityChatDataLight light = new EntityChatDataLight();
light.entityId = this.entityId;
light.currentMessage = this.currentMessage;
light.currentLineNumber = this.currentLineNumber;
light.status = this.status;
light.sender = this.sender;
light.friendship = this.friendship;
return light;
}
public String getCharacterProp(String propertyName) {
// Create a case-insensitive regex pattern to match the property name and capture its value
Pattern pattern = Pattern.compile("-?\\s*" + Pattern.quote(propertyName) + ":\\s*(.+)", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(characterSheet);
if (matcher.find()) {
// Return the captured value, trimmed of any excess whitespace
return matcher.group(1).trim().replace("\"", "");
}
return "N/A";
}
// Generate context object
public Map<String, String> getPlayerContext(ServerPlayerEntity player, String userLanguage) {
// Add PLAYER context information
Map<String, String> contextData = new HashMap<>();
contextData.put("player_name", player.getDisplayName().getString());
contextData.put("player_health", player.getHealth() + "/" + player.getMaxHealth());
contextData.put("player_hunger", String.valueOf(player.getHungerManager().getFoodLevel()));
contextData.put("player_held_item", String.valueOf(player.getMainHandStack().getItem().toString()));
contextData.put("player_biome", player.getWorld().getBiome(player.getBlockPos()).getKey().get().getValue().getPath());
contextData.put("player_is_creative", player.isCreative() ? "yes" : "no");
contextData.put("player_is_swimming", player.isSwimming() ? "yes" : "no");
contextData.put("player_is_on_ground", player.isOnGround() ? "yes" : "no");
contextData.put("player_language", userLanguage);
ItemStack feetArmor = player.getInventory().armor.get(0);
ItemStack legsArmor = player.getInventory().armor.get(1);
ItemStack chestArmor = player.getInventory().armor.get(2);
ItemStack headArmor = player.getInventory().armor.get(3);
contextData.put("player_armor_head", headArmor.getItem().toString());
contextData.put("player_armor_chest", chestArmor.getItem().toString());
contextData.put("player_armor_legs", legsArmor.getItem().toString());
contextData.put("player_armor_feet", feetArmor.getItem().toString());
// Get active player effects
String effectsString = player.getActiveStatusEffects().entrySet().stream()
.map(entry -> entry.getKey().getTranslationKey() + " x" + (entry.getValue().getAmplifier() + 1))
.collect(Collectors.joining(", "));
contextData.put("player_active_effects", effectsString);
// Get World time (as 24 hour value)
int hours = (int) ((player.getWorld().getTimeOfDay() / 1000 + 6) % 24); // Minecraft day starts at 6 AM
int minutes = (int) (((player.getWorld().getTimeOfDay() % 1000) / 1000.0) * 60);
contextData.put("world_time", String.format("%02d:%02d", hours, minutes));
contextData.put("world_is_raining", player.getWorld().isRaining() ? "yes" : "no");
contextData.put("world_is_thundering", player.getWorld().isThundering() ? "yes" : "no");
contextData.put("world_difficulty", player.getWorld().getDifficulty().getName());
contextData.put("world_is_hardcore", player.getWorld().getLevelProperties().isHardcore() ? "yes" : "no");
// Get moon phase
String moonPhaseDescription = switch (player.getWorld().getMoonPhase()) {
case 0 -> "Full Moon";
case 1 -> "Waning Gibbous";
case 2 -> "Last Quarter";
case 3 -> "Waning Crescent";
case 4 -> "New Moon";
case 5 -> "Waxing Crescent";
case 6 -> "First Quarter";
case 7 -> "Waxing Gibbous";
default -> "Unknown";
};
contextData.put("world_moon_phase", moonPhaseDescription);
// Get Entity details
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), UUID.fromString(entityId));
if (entity.getCustomName() == null) {
contextData.put("entity_name", "");
} else {
contextData.put("entity_name", entity.getCustomName().getString());
}
contextData.put("entity_type", entity.getType().getName().getString());
contextData.put("entity_health", entity.getHealth() + "/" + entity.getMaxHealth());
contextData.put("entity_personality", getCharacterProp("Personality"));
contextData.put("entity_speaking_style", getCharacterProp("Speaking Style / Tone"));
contextData.put("entity_likes", getCharacterProp("Likes"));
contextData.put("entity_dislikes", getCharacterProp("Dislikes"));
contextData.put("entity_age", getCharacterProp("Age"));
contextData.put("entity_alignment", getCharacterProp("Alignment"));
contextData.put("entity_class", getCharacterProp("Class"));
contextData.put("entity_skills", getCharacterProp("Skills"));
contextData.put("entity_background", getCharacterProp("Background"));
contextData.put("entity_friendship", String.valueOf(friendship));
return contextData;
}
// Generate greeting
public void generateMessage(String userLanguage, ServerPlayerEntity player, String systemPrompt, String userMessage, boolean is_auto_message) {
this.status = ChatStatus.PENDING;
if (is_auto_message) {
// Increment an auto-generated message
this.auto_generated++;
} else {
// Reset auto-generated counter
this.auto_generated = 0;
}
// Add USER Message
if (systemPrompt == "system-character") {
// Add message without playerId (so it does not display)
this.addMessage(userMessage, ChatSender.USER, "");
} else if (systemPrompt == "system-chat") {
this.addMessage(userMessage, ChatSender.USER, player.getUuidAsString());
}
// Add PLAYER context information
Map<String, String> contextData = getPlayerContext(player, userLanguage);
// Get config (api key, url, settings)
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
String promptText = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), systemPrompt);
// fetch HTTP response from ChatGPT
ChatGPTRequest.fetchMessageFromChatGPT(config, promptText, contextData, previousMessages, false).thenAccept(output_message -> {
if (output_message != null && systemPrompt == "system-character") {
// Character Sheet: Remove system-character message from previous messages
previousMessages.clear();
// Add NEW CHARACTER sheet & greeting
this.characterSheet = output_message;
String shortGreeting = getCharacterProp("short greeting");
if (shortGreeting.isEmpty()) {
shortGreeting = Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE);
}
this.addMessage(shortGreeting.replace("\n", " "), ChatSender.ASSISTANT, player.getUuidAsString());
} else if (output_message != null && systemPrompt == "system-chat") {
// Chat Message: Parse message for behaviors
ParsedMessage result = MessageParser.parseMessage(output_message.replace("\n", " "));
// Apply behaviors (if any)
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), UUID.fromString(entityId));
for (Behavior behavior : result.getBehaviors()) {
LOGGER.info("Behavior: " + behavior.getName() + (behavior.getArgument() != null ?
", Argument: " + behavior.getArgument() : ""));
// Determine entity's default speed
// Some Entities (i.e. Axolotl) set this incorrectly... so adjusting in the SpeedControls class
float entitySpeed = SpeedControls.getMaxSpeed(entity);
float entitySpeedFast = MathHelper.clamp(entitySpeed * 1.3F, 0.5f, 1.3f);
// Apply behaviors to entity
if (behavior.getName().equals("FOLLOW")) {
FollowPlayerGoal followGoal = new FollowPlayerGoal(player, entity, entitySpeed);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, followGoal, GoalPriority.FOLLOW_PLAYER);
} else if (behavior.getName().equals("UNFOLLOW")) {
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
} else if (behavior.getName().equals("FLEE")) {
float fleeDistance = 40F;
FleePlayerGoal fleeGoal = new FleePlayerGoal(player, entity, entitySpeedFast, fleeDistance);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, fleeGoal, GoalPriority.FLEE_PLAYER);
} else if (behavior.getName().equals("ATTACK")) {
AttackPlayerGoal attackGoal = new AttackPlayerGoal(player, entity, entitySpeedFast);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.addGoal(entity, attackGoal, GoalPriority.ATTACK_PLAYER);
} else if (behavior.getName().equals("PROTECT")) {
ProtectPlayerGoal protectGoal = new ProtectPlayerGoal(player, entity, 1.0);
EntityBehaviorManager.addGoal(entity, protectGoal, GoalPriority.PROTECT_PLAYER);
} else if (behavior.getName().equals("UNPROTECT")) {
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
} else if (behavior.getName().equals("FRIENDSHIP")) {
int new_friendship = Math.max(-3, Math.min(3, behavior.getArgument()));
if (new_friendship > 0) {
// positive friendship (apply friend goal)
((LivingEntityInterface)entity).setCanTargetPlayers(false);
} else if (new_friendship <= 0) {
// negative friendship (remove friend goal)
((LivingEntityInterface)entity).setCanTargetPlayers(true);
}
// Does friendship improve?
if (new_friendship > this.friendship) {
// Stop any attack/flee if friendship improves
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
if (entity instanceof EnderDragonEntity && new_friendship == 3) {
// Trigger end of game (friendship always wins!)
EnderDragonEntity dragon = (EnderDragonEntity) entity;
dragon.getFight().dragonKilled(dragon);
}
}
this.friendship = new_friendship;
}
}
// Add ASSISTANT message to history
this.addMessage(result.getOriginalMessage(), ChatSender.ASSISTANT, player.getUuidAsString());
// Get cleaned message (i.e. no <BEHAVIOR> strings)
String cleanedMessage = result.getCleanedMessage();
if (cleanedMessage.isEmpty()) {
cleanedMessage = Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE);
}
// Update the current message to a 'cleaned version'
this.currentMessage = cleanedMessage;
} else {
// Error / No Chat Message (Failure)
String randomErrorMessage = Randomizer.getRandomMessage(Randomizer.RandomType.ERROR);
this.addMessage(randomErrorMessage, ChatSender.ASSISTANT, player.getUuidAsString());
// Determine error message to display
String errorMessage = "Help is available at discord.creaturechat.com";
if (!ChatGPTRequest.lastErrorMessage.isEmpty()) {
errorMessage = "Error: " + truncateString(ChatGPTRequest.lastErrorMessage, 55) + "\n" + errorMessage;
}
// Send clickable error message
ServerPackets.SendClickableError(player,
errorMessage, "http://discord.creaturechat.com");
// Clear history (if no character sheet was generated)
if (characterSheet.isEmpty()) {
previousMessages.clear();
}
}
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this);
});
}
public static String truncateString(String input, int maxLength) {
return input.length() > maxLength ? input.substring(0, maxLength - 3) + "..." : input;
}
// Add a message to the history and update the current message
public void addMessage(String message, ChatSender messageSender, String playerId) {
// Truncate message (prevent crazy long messages... just in case)
String truncatedMessage = message.substring(0, Math.min(message.length(), MAX_CHAR_IN_USER_MESSAGE));
// Add message to history
previousMessages.add(new ChatMessage(truncatedMessage, messageSender));
// Set new message and reset line number of displayed text
currentMessage = truncatedMessage;
currentLineNumber = 0;
if (messageSender == ChatSender.ASSISTANT) {
// Show new generated message
status = ChatStatus.DISPLAY;
} else if (messageSender == ChatSender.USER) {
// Show pending icon
status = ChatStatus.PENDING;
}
sender = messageSender;
this.playerId = playerId;
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this);
}
// Get wrapped lines
public List<String> getWrappedLines() {
return LineWrapper.wrapLines(this.currentMessage, MAX_CHAR_PER_LINE);
}
public boolean isEndOfMessage() {
int totalLines = this.getWrappedLines().size();
// Check if the current line number plus DISPLAY_NUM_LINES covers or exceeds the total number of lines
return currentLineNumber + DISPLAY_NUM_LINES >= totalLines;
}
public void setLineNumber(Integer lineNumber) {
int totalLines = this.getWrappedLines().size();
// Ensure the lineNumber is within the valid range
currentLineNumber = Math.min(Math.max(lineNumber, 0), totalLines);
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this);
}
public void setStatus(ChatStatus new_status) {
status = new_status;
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this);
}
}
public ConcurrentHashMap<String, EntityChatData> entityChatDataMap;
public void clearData() {
// Clear the chat data for the previous session
......@@ -424,7 +52,7 @@ public class ChatDataManager {
private ChatDataManager(Boolean server_only) {
// Constructor
entityChatDataMap = new HashMap<>();
entityChatDataMap = new ConcurrentHashMap<>();
if (server_only) {
// Generate initial quest
......@@ -445,7 +73,7 @@ public class ChatDataManager {
// Retrieve chat data for a specific entity, or create it if it doesn't exist
public EntityChatData getOrCreateChatData(String entityId) {
return entityChatDataMap.computeIfAbsent(entityId, k -> new EntityChatData(entityId, ""));
return entityChatDataMap.computeIfAbsent(entityId, k -> new EntityChatData(entityId));
}
// Update the UUID in the map (i.e. bucketed entity and then released, changes their UUID)
......@@ -457,58 +85,19 @@ public class ChatDataManager {
LOGGER.info("Updated chat data from UUID (" + oldUUID + ") to UUID (" + newUUID + ")");
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(data);
ServerPackets.BroadcastPacketMessage(data, null);
} else {
LOGGER.info("Unable to update chat data, UUID not found: " + oldUUID);
}
}
// Generate quest data for this server session
public void generateQuest() {
// Get items needed for Quest prompt
List<String> commonItems = RarityItemCollector.getItemsByRarity(Rarity.COMMON, 5);
List<String> uncommonItems = RarityItemCollector.getItemsByRarity(Rarity.UNCOMMON, 5);
List<String> rareItems = RarityItemCollector.getItemsByRarity(Rarity.RARE, 5);
// Get entities needed for Quest prompt
List<String> commonEntities = RarityItemCollector.getEntitiesByRarity(Rarity.COMMON, 5);
List<String> uncommonEntities = RarityItemCollector.getEntitiesByRarity(Rarity.UNCOMMON, 5);
List<String> rareEntities = RarityItemCollector.getEntitiesByRarity(Rarity.RARE, 5);
// Add context information for prompt
Map<String, String> contextData = new HashMap<>();
contextData.put("items_common", String.join("\n", commonItems));
contextData.put("items_uncommon", String.join("\n", uncommonItems));
contextData.put("items_rare", String.join("\n", rareItems));
contextData.put("entities_common", String.join("\n", commonEntities));
contextData.put("entities_uncommon", String.join("\n", uncommonEntities));
contextData.put("entities_rare", String.join("\n", rareEntities));
// Add message
List<ChatMessage> messages = new ArrayList<>();
messages.add(new ChatMessage("Generate me a new fantasy story with ONLY the 1st character in the story", ChatSender.USER));
// Get config (api key, url, settings)
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
String questPrompt = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), "system-quest");
// Generate Quest: fetch HTTP response from ChatGPT
ChatGPTRequest.fetchMessageFromChatGPT(config, questPrompt, contextData, messages, true).thenAccept(output_message -> {
// New Quest
Gson gson = new Gson();
quest = gson.fromJson(output_message, QuestJson.class);
});
}
// Save chat data to file
public String GetLightChatData() {
public String GetLightChatData(String playerName) {
try {
// Create "light" version of entire chat data HashMap
HashMap<String, EntityChatData.EntityChatDataLight> lightVersionMap = new HashMap<>();
this.entityChatDataMap.forEach((id, entityChatData) -> lightVersionMap.put(id, entityChatData.toLightVersion()));
// Convert light chat data to JSON string
return GSON.toJson(lightVersionMap).toString();
HashMap<String, EntityChatDataLight> lightVersionMap = new HashMap<>();
this.entityChatDataMap.forEach((name, entityChatData) -> lightVersionMap.put(name, entityChatData.toLightVersion(playerName)));
return GSON.toJson(lightVersionMap);
} catch (Exception e) {
// Handle exceptions
return "";
......@@ -520,6 +109,9 @@ public class ChatDataManager {
File saveFile = new File(server.getSavePath(WorldSavePath.ROOT).toFile(), "chatdata.json");
LOGGER.info("Saving chat data to " + saveFile.getAbsolutePath());
// Clean up blank, temp entities in data
entityChatDataMap.values().removeIf(entityChatData -> entityChatData.status == ChatStatus.NONE);
try (Writer writer = new OutputStreamWriter(new FileOutputStream(saveFile), StandardCharsets.UTF_8)) {
GSON.toJson(this.entityChatDataMap, writer);
} catch (Exception e) {
......@@ -536,15 +128,23 @@ public class ChatDataManager {
if (loadFile.exists()) {
try (InputStreamReader reader = new InputStreamReader(new FileInputStream(loadFile), StandardCharsets.UTF_8)) {
Type type = new TypeToken<HashMap<String, EntityChatData>>(){}.getType();
Type type = new TypeToken<ConcurrentHashMap<String, EntityChatData>>(){}.getType();
this.entityChatDataMap = GSON.fromJson(reader, type);
// Clean up blank, temp entities in data
entityChatDataMap.values().removeIf(entityChatData -> entityChatData.status == ChatStatus.NONE);
// Post-process each EntityChatData object
for (EntityChatData entityChatData : entityChatDataMap.values()) {
entityChatData.postDeserializeInitialization();
}
} catch (Exception e) {
LOGGER.error("Error loading chat data", e);
this.entityChatDataMap = new HashMap<>();
this.entityChatDataMap = new ConcurrentHashMap<>();
}
} else {
// Init empty chat data
this.entityChatDataMap = new HashMap<>();
this.entityChatDataMap = new ConcurrentHashMap<>();
}
}
}
\ No newline at end of file
......@@ -11,8 +11,10 @@ import java.util.concurrent.TimeUnit;
*/
public class ChatDataSaverScheduler {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private MinecraftServer server = null;
public void startAutoSaveTask(MinecraftServer server, long interval, TimeUnit timeUnit) {
this.server = server;
ChatDataAutoSaver saverTask = new ChatDataAutoSaver(server);
scheduler.scheduleAtFixedRate(saverTask, 1, interval, timeUnit);
}
......@@ -20,4 +22,9 @@ public class ChatDataSaverScheduler {
public void stopAutoSaveTask() {
scheduler.shutdown();
}
// Schedule a task to run after 1 tick (basically immediately)
public void scheduleTask(Runnable task) {
scheduler.schedule(() -> server.execute(task), 50, TimeUnit.MILLISECONDS);
}
}
......@@ -118,7 +118,7 @@ public class ChatGPTRequest {
return (int) Math.round(text.length() / 3.5);
}
public static CompletableFuture<String> fetchMessageFromChatGPT(ConfigurationHandler.Config config, String systemPrompt, Map<String, String> contextData, List<ChatDataManager.ChatMessage> messageHistory, Boolean jsonMode) {
public static CompletableFuture<String> fetchMessageFromChatGPT(ConfigurationHandler.Config config, String systemPrompt, Map<String, String> contextData, List<ChatMessage> messageHistory, Boolean jsonMode) {
// Init API & LLM details
String apiUrl = config.getUrl();
String apiKey = config.getApiKey();
......@@ -151,7 +151,7 @@ public class ChatGPTRequest {
// Iterate backwards through the message history
for (int i = messageHistory.size() - 1; i >= 0; i--) {
ChatDataManager.ChatMessage chatMessage = messageHistory.get(i);
ChatMessage chatMessage = messageHistory.get(i);
String senderName = chatMessage.sender.toString().toLowerCase(Locale.ENGLISH);
String messageText = replacePlaceholders(chatMessage.message, contextData);
int messageTokens = estimateTokenSize(senderName + ": " + messageText);
......@@ -213,7 +213,6 @@ public class ChatGPTRequest {
ChatGPTResponse chatGPTResponse = gsonOutput.fromJson(response.toString(), ChatGPTResponse.class);
if (chatGPTResponse != null && chatGPTResponse.choices != null && !chatGPTResponse.choices.isEmpty()) {
String content = chatGPTResponse.choices.get(0).message.content;
LOGGER.info("Generated message: " + content);
return content;
}
}
......
package com.owlmaddie.chat;
/**
* The {@code ChatMessage} class represents a single message.
*/
public class ChatMessage {
public String message;
public String name;
public ChatDataManager.ChatSender sender;
public Long timestamp;
public ChatMessage(String message, ChatDataManager.ChatSender sender, String playerName) {
this.message = message;
this.sender = sender;
this.name = playerName;
this.timestamp = System.currentTimeMillis();
}
}
\ No newline at end of file
package com.owlmaddie.chat;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
import com.owlmaddie.commands.ConfigurationHandler;
import com.owlmaddie.controls.SpeedControls;
import com.owlmaddie.goals.*;
import com.owlmaddie.message.Behavior;
import com.owlmaddie.message.MessageParser;
import com.owlmaddie.message.ParsedMessage;
import com.owlmaddie.network.ServerPackets;
import com.owlmaddie.particle.ParticleEmitter;
import com.owlmaddie.utils.Randomizer;
import com.owlmaddie.utils.ServerEntityFinder;
import com.owlmaddie.utils.VillagerEntityAccessor;
import net.minecraft.entity.ExperienceOrbEntity;
import net.minecraft.entity.boss.dragon.EnderDragonEntity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.passive.TameableEntity;
import net.minecraft.entity.passive.VillagerEntity;
import net.minecraft.item.ItemStack;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.sound.SoundCategory;
import net.minecraft.sound.SoundEvents;
import net.minecraft.util.math.MathHelper;
import net.minecraft.village.VillageGossipType;
import net.minecraft.world.GameRules;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static com.owlmaddie.network.ServerPackets.*;
/**
* The {@code EntityChatData} class represents a conversation between an
* entity and one or more players, including friendship, character sheets,
* and the status of the current displayed message.
*/
public class EntityChatData {
public static final Logger LOGGER = LoggerFactory.getLogger("creaturechat");
public String entityId;
public String currentMessage;
public int currentLineNumber;
public ChatDataManager.ChatStatus status;
public String characterSheet;
public ChatDataManager.ChatSender sender;
public int auto_generated;
public List<ChatMessage> previousMessages;
public Long born;
public Long death;
@SerializedName("playerId")
@Expose(serialize = false)
private String legacyPlayerId;
@SerializedName("friendship")
@Expose(serialize = false)
public Integer legacyFriendship;
// The map to store data for each player interacting with this entity
public Map<String, PlayerData> players;
public EntityChatData(String entityId) {
this.entityId = entityId;
this.players = new HashMap<>();
this.currentMessage = "";
this.currentLineNumber = 0;
this.characterSheet = "";
this.status = ChatDataManager.ChatStatus.NONE;
this.sender = ChatDataManager.ChatSender.USER;
this.auto_generated = 0;
this.previousMessages = new ArrayList<>();
this.born = System.currentTimeMillis();;
// Old, unused migrated properties
this.legacyPlayerId = null;
this.legacyFriendship = null;
}
// Post-deserialization initialization
public void postDeserializeInitialization() {
if (this.players == null) {
this.players = new HashMap<>(); // Ensure players map is initialized
}
if (this.legacyPlayerId != null && !this.legacyPlayerId.isEmpty()) {
this.migrateData();
}
}
// Migrate old data into the new structure
private void migrateData() {
// Ensure the blank player data entry exists
PlayerData blankPlayerData = this.players.computeIfAbsent("", k -> new PlayerData());
// Update the previousMessages arraylist and add timestamps if missing
if (this.previousMessages != null) {
for (ChatMessage message : this.previousMessages) {
if (message.timestamp == null) {
message.timestamp = System.currentTimeMillis();
}
if (message.name == null || message.name.isEmpty()) {
message.name = "";
}
}
}
blankPlayerData.friendship = this.legacyFriendship;
if (this.born == null) {
this.born = System.currentTimeMillis();;
}
// Clean up old player data
this.legacyPlayerId = null;
this.legacyFriendship = null;
}
// Get the player data (or fallback to the blank player)
public PlayerData getPlayerData(String playerName) {
if (this.players == null) {
return new PlayerData();
}
// Check if the playerId exists in the players map
if (this.players.containsKey("")) {
// If a blank migrated legacy entity is found, always return this
return this.players.get("");
} else if (this.players.containsKey(playerName)) {
// Return a specific player's data
return this.players.get(playerName);
} else {
// Return a blank player data
PlayerData newPlayerData = new PlayerData();
this.players.put(playerName, newPlayerData);
return newPlayerData;
}
}
// Generate light version of chat data (no previous messages)
public EntityChatDataLight toLightVersion(String playerName) {
return new EntityChatDataLight(this, playerName);
}
public String getCharacterProp(String propertyName) {
// Create a case-insensitive regex pattern to match the property name and capture its value
Pattern pattern = Pattern.compile("-?\\s*" + Pattern.quote(propertyName) + ":\\s*(.+)", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(characterSheet);
if (matcher.find()) {
// Return the captured value, trimmed of any excess whitespace
return matcher.group(1).trim().replace("\"", "");
}
return "N/A";
}
// Generate context object
public Map<String, String> getPlayerContext(ServerPlayerEntity player, String userLanguage, ConfigurationHandler.Config config) {
// Add PLAYER context information
Map<String, String> contextData = new HashMap<>();
contextData.put("player_name", player.getDisplayName().getString());
contextData.put("player_health", player.getHealth() + "/" + player.getMaxHealth());
contextData.put("player_hunger", String.valueOf(player.getHungerManager().getFoodLevel()));
contextData.put("player_held_item", String.valueOf(player.getMainHandStack().getItem().toString()));
contextData.put("player_biome", player.getWorld().getBiome(player.getBlockPos()).getKey().get().getValue().getPath());
contextData.put("player_is_creative", player.isCreative() ? "yes" : "no");
contextData.put("player_is_swimming", player.isSwimming() ? "yes" : "no");
contextData.put("player_is_on_ground", player.isOnGround() ? "yes" : "no");
contextData.put("player_language", userLanguage);
ItemStack feetArmor = player.getInventory().armor.get(0);
ItemStack legsArmor = player.getInventory().armor.get(1);
ItemStack chestArmor = player.getInventory().armor.get(2);
ItemStack headArmor = player.getInventory().armor.get(3);
contextData.put("player_armor_head", headArmor.getItem().toString());
contextData.put("player_armor_chest", chestArmor.getItem().toString());
contextData.put("player_armor_legs", legsArmor.getItem().toString());
contextData.put("player_armor_feet", feetArmor.getItem().toString());
// Get active player effects
String effectsString = player.getActiveStatusEffects().entrySet().stream()
.map(entry -> entry.getKey().getTranslationKey() + " x" + (entry.getValue().getAmplifier() + 1))
.collect(Collectors.joining(", "));
contextData.put("player_active_effects", effectsString);
// Add custom story section (if any)
if (!config.getStory().isEmpty()) {
contextData.put("story", "Story: " + config.getStory());
} else {
contextData.put("story", "");
}
// Get World time (as 24 hour value)
int hours = (int) ((player.getWorld().getTimeOfDay() / 1000 + 6) % 24); // Minecraft day starts at 6 AM
int minutes = (int) (((player.getWorld().getTimeOfDay() % 1000) / 1000.0) * 60);
contextData.put("world_time", String.format("%02d:%02d", hours, minutes));
contextData.put("world_is_raining", player.getWorld().isRaining() ? "yes" : "no");
contextData.put("world_is_thundering", player.getWorld().isThundering() ? "yes" : "no");
contextData.put("world_difficulty", player.getWorld().getDifficulty().getName());
contextData.put("world_is_hardcore", player.getWorld().getLevelProperties().isHardcore() ? "yes" : "no");
// Get moon phase
String moonPhaseDescription = switch (player.getWorld().getMoonPhase()) {
case 0 -> "Full Moon";
case 1 -> "Waning Gibbous";
case 2 -> "Last Quarter";
case 3 -> "Waning Crescent";
case 4 -> "New Moon";
case 5 -> "Waxing Crescent";
case 6 -> "First Quarter";
case 7 -> "Waxing Gibbous";
default -> "Unknown";
};
contextData.put("world_moon_phase", moonPhaseDescription);
// Get Entity details
MobEntity entity = (MobEntity) ServerEntityFinder.getEntityByUUID(player.getServerWorld(), UUID.fromString(entityId));
if (entity.getCustomName() == null) {
contextData.put("entity_name", "");
} else {
contextData.put("entity_name", entity.getCustomName().getString());
}
contextData.put("entity_type", entity.getType().getName().getString());
contextData.put("entity_health", entity.getHealth() + "/" + entity.getMaxHealth());
contextData.put("entity_personality", getCharacterProp("Personality"));
contextData.put("entity_speaking_style", getCharacterProp("Speaking Style / Tone"));
contextData.put("entity_likes", getCharacterProp("Likes"));
contextData.put("entity_dislikes", getCharacterProp("Dislikes"));
contextData.put("entity_age", getCharacterProp("Age"));
contextData.put("entity_alignment", getCharacterProp("Alignment"));
contextData.put("entity_class", getCharacterProp("Class"));
contextData.put("entity_skills", getCharacterProp("Skills"));
contextData.put("entity_background", getCharacterProp("Background"));
PlayerData playerData = this.getPlayerData(player.getDisplayName().getString());
if (playerData != null) {
contextData.put("entity_friendship", String.valueOf(playerData.friendship));
} else {
contextData.put("entity_friendship", String.valueOf(0));
}
return contextData;
}
// Generate a new character
public void generateCharacter(String userLanguage, ServerPlayerEntity player, String userMessage, boolean is_auto_message) {
String systemPrompt = "system-character";
if (is_auto_message) {
// Increment an auto-generated message
this.auto_generated++;
} else {
// Reset auto-generated counter
this.auto_generated = 0;
}
// Add USER Message
this.addMessage(userMessage, ChatDataManager.ChatSender.USER, player, systemPrompt);
// Get config (api key, url, settings)
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
String promptText = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), systemPrompt);
// Add PLAYER context information
Map<String, String> contextData = getPlayerContext(player, userLanguage, config);
// fetch HTTP response from ChatGPT
ChatGPTRequest.fetchMessageFromChatGPT(config, promptText, contextData, previousMessages, false).thenAccept(output_message -> {
if (output_message != null) {
// Character Sheet: Remove system-character message from previous messages
previousMessages.clear();
// Add NEW CHARACTER sheet & greeting
this.characterSheet = output_message;
String shortGreeting = Optional.ofNullable(getCharacterProp("short greeting")).filter(s -> !s.isEmpty()).orElse(Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE)).replace("\n", " ");
this.addMessage(shortGreeting, ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt);
} else {
// Error / No Chat Message (Failure)
String randomErrorMessage = Randomizer.getRandomMessage(Randomizer.RandomType.ERROR);
this.addMessage(randomErrorMessage, ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt);
// Determine error message to display
String errorMessage = "Help is available at discord.creaturechat.com";
if (!ChatGPTRequest.lastErrorMessage.isEmpty()) {
errorMessage = "Error: " + truncateString(ChatGPTRequest.lastErrorMessage, 55) + "\n" + errorMessage;
}
// Send clickable error message
ServerPackets.SendClickableError(player,
errorMessage, "http://discord.creaturechat.com");
// Clear history (if no character sheet was generated)
if (characterSheet.isEmpty()) {
previousMessages.clear();
}
}
});
}
// Generate greeting
public void generateMessage(String userLanguage, ServerPlayerEntity player, String userMessage, boolean is_auto_message) {
String systemPrompt = "system-chat";
if (is_auto_message) {
// Increment an auto-generated message
this.auto_generated++;
} else {
// Reset auto-generated counter
this.auto_generated = 0;
}
// Add USER Message
this.addMessage(userMessage, ChatDataManager.ChatSender.USER, player, systemPrompt);
// Get config (api key, url, settings)
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
String promptText = ChatPrompt.loadPromptFromResource(ServerPackets.serverInstance.getResourceManager(), systemPrompt);
// Add PLAYER context information
Map<String, String> contextData = getPlayerContext(player, userLanguage, config);
// Get messages for player
PlayerData playerData = this.getPlayerData(player.getDisplayName().getString());
if (previousMessages.size() == 1) {
// No messages exist yet for this player (start with normal greeting)
String shortGreeting = Optional.ofNullable(getCharacterProp("short greeting")).filter(s -> !s.isEmpty()).orElse(Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE)).replace("\n", " ");
previousMessages.add(0, new ChatMessage(shortGreeting, ChatDataManager.ChatSender.ASSISTANT, player.getDisplayName().getString()));
}
// fetch HTTP response from ChatGPT
ChatGPTRequest.fetchMessageFromChatGPT(config, promptText, contextData, previousMessages, false).thenAccept(output_message -> {
if (output_message != null) {
// Chat Message: Parse message for behaviors
ParsedMessage result = MessageParser.parseMessage(output_message.replace("\n", " "));
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), UUID.fromString(entityId));
// Determine entity's default speed
// Some Entities (i.e. Axolotl) set this incorrectly... so adjusting in the SpeedControls class
float entitySpeed = SpeedControls.getMaxSpeed(entity);
float entitySpeedMedium = MathHelper.clamp(entitySpeed * 1.15F, 0.5f, 1.15f);
float entitySpeedFast = MathHelper.clamp(entitySpeed * 1.3F, 0.5f, 1.3f);
// Apply behaviors (if any)
for (Behavior behavior : result.getBehaviors()) {
LOGGER.info("Behavior: " + behavior.getName() + (behavior.getArgument() != null ?
", Argument: " + behavior.getArgument() : ""));
// Apply behaviors to entity
if (behavior.getName().equals("FOLLOW")) {
FollowPlayerGoal followGoal = new FollowPlayerGoal(player, entity, entitySpeedMedium);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, followGoal, GoalPriority.FOLLOW_PLAYER);
if (playerData.friendship >= 0) {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FOLLOW_FRIEND_PARTICLE, 0.5, 1);
} else {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FOLLOW_ENEMY_PARTICLE, 0.5, 1);
}
} else if (behavior.getName().equals("UNFOLLOW")) {
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
} else if (behavior.getName().equals("FLEE")) {
float fleeDistance = 40F;
FleePlayerGoal fleeGoal = new FleePlayerGoal(player, entity, entitySpeedFast, fleeDistance);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, fleeGoal, GoalPriority.FLEE_PLAYER);
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FLEE_PARTICLE, 0.5, 1);
} else if (behavior.getName().equals("UNFLEE")) {
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
} else if (behavior.getName().equals("ATTACK")) {
AttackPlayerGoal attackGoal = new AttackPlayerGoal(player, entity, entitySpeedFast);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, attackGoal, GoalPriority.ATTACK_PLAYER);
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FLEE_PARTICLE, 0.5, 1);
} else if (behavior.getName().equals("PROTECT")) {
if (playerData.friendship <= 0) {
// force friendship to prevent entity from attacking player when protecting
playerData.friendship = 1;
}
ProtectPlayerGoal protectGoal = new ProtectPlayerGoal(player, entity, 1.0);
EntityBehaviorManager.removeGoal(entity, TalkPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, protectGoal, GoalPriority.PROTECT_PLAYER);
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, PROTECT_PARTICLE, 0.5, 1);
} else if (behavior.getName().equals("UNPROTECT")) {
EntityBehaviorManager.removeGoal(entity, ProtectPlayerGoal.class);
} else if (behavior.getName().equals("LEAD")) {
LeadPlayerGoal leadGoal = new LeadPlayerGoal(player, entity, entitySpeedMedium);
EntityBehaviorManager.removeGoal(entity, FollowPlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
EntityBehaviorManager.addGoal(entity, leadGoal, GoalPriority.LEAD_PLAYER);
if (playerData.friendship >= 0) {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, LEAD_FRIEND_PARTICLE, 0.5, 1);
} else {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, LEAD_ENEMY_PARTICLE, 0.5, 1);
}
} else if (behavior.getName().equals("UNLEAD")) {
EntityBehaviorManager.removeGoal(entity, LeadPlayerGoal.class);
} else if (behavior.getName().equals("FRIENDSHIP")) {
int new_friendship = Math.max(-3, Math.min(3, behavior.getArgument()));
// Does friendship improve?
if (new_friendship > playerData.friendship) {
// Stop any attack/flee if friendship improves
EntityBehaviorManager.removeGoal(entity, FleePlayerGoal.class);
EntityBehaviorManager.removeGoal(entity, AttackPlayerGoal.class);
if (entity instanceof EnderDragonEntity && new_friendship == 3) {
// Trigger end of game (friendship always wins!)
EnderDragonEntity dragon = (EnderDragonEntity) entity;
// Emit particles & sound
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, HEART_BIG_PARTICLE, 3, 200);
entity.getWorld().playSound(entity, entity.getBlockPos(), SoundEvents.ENTITY_ENDER_DRAGON_DEATH, SoundCategory.PLAYERS, 0.3F, 1.0F);
entity.getWorld().playSound(entity, entity.getBlockPos(), SoundEvents.UI_TOAST_CHALLENGE_COMPLETE, SoundCategory.PLAYERS, 0.5F, 1.0F);
// Check if the game rule for mob loot is enabled
boolean doMobLoot = entity.getWorld().getGameRules().getBoolean(GameRules.DO_MOB_LOOT);
// If this is the first time the dragon is 'befriended', adjust the XP
int baseXP = 500;
if (dragon.getFight() != null && !dragon.getFight().hasPreviouslyKilled()) {
baseXP = 12000;
}
// If the world is a server world and mob loot is enabled, spawn XP orbs
if (entity.getWorld() instanceof ServerWorld && doMobLoot) {
// Loop to spawn XP orbs
for (int j = 1; j <= 11; j++) {
float xpFraction = (j == 11) ? 0.2F : 0.08F;
int xpAmount = MathHelper.floor((float)baseXP * xpFraction);
ExperienceOrbEntity.spawn((ServerWorld)entity.getWorld(), entity.getPos(), xpAmount);
}
}
// Mark fight as over
dragon.getFight().dragonKilled(dragon);
}
}
// Merchant deals (if friendship changes with a Villager
if (entity instanceof VillagerEntity && playerData.friendship != new_friendship) {
VillagerEntityAccessor villager = (VillagerEntityAccessor) entity;
switch (new_friendship) {
case 3:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MAJOR_POSITIVE, 20);
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 25);
break;
case 2:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 25);
break;
case 1:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_POSITIVE, 10);
break;
case -1:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 10);
break;
case -2:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 25);
break;
case -3:
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MAJOR_NEGATIVE, 20);
villager.getGossip().startGossip(player.getUuid(), VillageGossipType.MINOR_NEGATIVE, 25);
break;
}
}
// Tame best friends and un-tame worst enemies
if (entity instanceof TameableEntity && playerData.friendship != new_friendship) {
TameableEntity tamableEntity = (TameableEntity) entity;
if (new_friendship == 3 && !tamableEntity.isTamed()) {
tamableEntity.setOwner(player);
} else if (new_friendship == -3 && tamableEntity.isTamed()) {
tamableEntity.setTamed(false);
tamableEntity.setOwnerUuid(null);
}
}
// Emit friendship particles
if (playerData.friendship != new_friendship) {
int friendDiff = new_friendship - playerData.friendship;
if (friendDiff > 0) {
// Heart particles
if (new_friendship == 3) {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, HEART_BIG_PARTICLE, 0.5, 10);
} else {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, HEART_SMALL_PARTICLE, 0.1, 1);
}
} else if (friendDiff < 0) {
// Fire particles
if (new_friendship == -3) {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FIRE_BIG_PARTICLE, 0.5, 10);
} else {
ParticleEmitter.emitCreatureParticle((ServerWorld) entity.getWorld(), entity, FIRE_SMALL_PARTICLE, 0.1, 1);
}
}
}
playerData.friendship = new_friendship;
}
}
// Add ASSISTANT message to history
this.addMessage(result.getOriginalMessage(), ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt);
// Get cleaned message (i.e. no <BEHAVIOR> strings)
String cleanedMessage = result.getCleanedMessage();
if (cleanedMessage.isEmpty()) {
cleanedMessage = Randomizer.getRandomMessage(Randomizer.RandomType.NO_RESPONSE);
}
// Update the current message to a 'cleaned version'
this.currentMessage = cleanedMessage;
} else {
// Error / No Chat Message (Failure)
String randomErrorMessage = Randomizer.getRandomMessage(Randomizer.RandomType.ERROR);
this.addMessage(randomErrorMessage, ChatDataManager.ChatSender.ASSISTANT, player, systemPrompt);
// Determine error message to display
String errorMessage = "Help is available at discord.creaturechat.com";
if (!ChatGPTRequest.lastErrorMessage.isEmpty()) {
errorMessage = "Error: " + truncateString(ChatGPTRequest.lastErrorMessage, 55) + "\n" + errorMessage;
}
// Send clickable error message
ServerPackets.SendClickableError(player,
errorMessage, "http://discord.creaturechat.com");
// Clear history (if no character sheet was generated)
if (characterSheet.isEmpty()) {
previousMessages.clear();
}
}
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this, player);
});
}
public static String truncateString(String input, int maxLength) {
return input.length() > maxLength ? input.substring(0, maxLength - 3) + "..." : input;
}
// Add a message to the history and update the current message
public void addMessage(String message, ChatDataManager.ChatSender sender, ServerPlayerEntity player, String systemPrompt) {
// Truncate message (prevent crazy long messages... just in case)
String truncatedMessage = message.substring(0, Math.min(message.length(), ChatDataManager.MAX_CHAR_IN_USER_MESSAGE));
// Add context-switching logic for USER messages only
String playerName = player.getDisplayName().getString();
if (sender == ChatDataManager.ChatSender.USER && previousMessages.size() > 1) {
ChatMessage lastMessage = previousMessages.get(previousMessages.size() - 1);
if (lastMessage.name == null || !lastMessage.name.equals(playerName)) { // Null-safe check
boolean isReturningPlayer = previousMessages.stream().anyMatch(msg -> playerName.equals(msg.name)); // Avoid NPE here too
String note = isReturningPlayer
? "<returning player: " + playerName + " resumes the conversation>"
: "<a new player has joined the conversation: " + playerName + ">";
previousMessages.add(new ChatMessage(note, sender, playerName));
// Log context-switching message
LOGGER.info("Conversation-switching message: status=PENDING, sender={}, message={}, player={}, entity={}",
ChatDataManager.ChatStatus.PENDING, note, playerName, entityId);
}
}
// Add message to history
previousMessages.add(new ChatMessage(truncatedMessage, sender, playerName));
// Log regular message addition
LOGGER.info("Message added: status={}, sender={}, message={}, player={}, entity={}",
status.toString(), sender.toString(), truncatedMessage, playerName, entityId);
// Update current message and reset line number of displayed text
this.currentMessage = truncatedMessage;
this.currentLineNumber = 0;
this.sender = sender;
// Determine status for message
if (sender == ChatDataManager.ChatSender.ASSISTANT) {
status = ChatDataManager.ChatStatus.DISPLAY;
} else if (sender == ChatDataManager.ChatSender.USER && systemPrompt.equals("system-chat")) {
// Only show system-chat messages above players (not system-character ones)
status = ChatDataManager.ChatStatus.DISPLAY;
} else {
status = ChatDataManager.ChatStatus.PENDING;
}
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this, player);
}
// Get wrapped lines
public List<String> getWrappedLines() {
return LineWrapper.wrapLines(this.currentMessage, ChatDataManager.MAX_CHAR_PER_LINE);
}
public boolean isEndOfMessage() {
int totalLines = this.getWrappedLines().size();
// Check if the current line number plus DISPLAY_NUM_LINES covers or exceeds the total number of lines
return currentLineNumber + ChatDataManager.DISPLAY_NUM_LINES >= totalLines;
}
public void setLineNumber(Integer lineNumber) {
int totalLines = this.getWrappedLines().size();
// Ensure the lineNumber is within the valid range
currentLineNumber = Math.min(Math.max(lineNumber, 0), totalLines);
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this, null);
}
public void setStatus(ChatDataManager.ChatStatus new_status) {
status = new_status;
// Broadcast to all players
ServerPackets.BroadcastPacketMessage(this, null);
}
}
\ No newline at end of file
package com.owlmaddie.chat;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* The {@code EntityChatDataLight} class represents the current displayed message, and no
* previous messages or player message history. This is primarily used to broadcast the
* currently displayed messages to players as they connect to the server.
*/
public class EntityChatDataLight {
public String entityId;
public String currentMessage;
public int currentLineNumber;
public ChatDataManager.ChatStatus status;
public ChatDataManager.ChatSender sender;
public Map<String, PlayerData> players;
// Constructor to initialize the light version from the full version
public EntityChatDataLight(EntityChatData fullData, String playerName) {
this.entityId = fullData.entityId;
this.currentMessage = fullData.currentMessage;
this.currentLineNumber = fullData.currentLineNumber;
this.status = fullData.status;
this.sender = fullData.sender;
// Initialize the players map and add only the current player's data
this.players = new HashMap<>();
PlayerData playerData = fullData.getPlayerData(playerName);
this.players.put(playerName, playerData);
}
}
\ No newline at end of file
package com.owlmaddie.chat;
/**
* The {@code PlayerData} class represents data associated with a player,
* specifically tracking their friendship level.
*/
public class PlayerData {
public int friendship;
public PlayerData() {
this.friendship = 0;
}
}
\ No newline at end of file
......@@ -14,6 +14,8 @@ import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
/**
* The {@code ConfigurationHandler} class loads and saves configuration settings for this mod. It first
......@@ -69,6 +71,9 @@ public class ConfigurationHandler {
private int maxOutputTokens = 200;
private double percentOfContext = 0.75;
private int timeout = 10;
private List<String> whitelist = new ArrayList<>();
private List<String> blacklist = new ArrayList<>();
private String story = "";
// Getters and setters for existing fields
public String getApiKey() { return apiKey; }
......@@ -77,7 +82,7 @@ public class ConfigurationHandler {
// Update URL if a CreatureChat API key is detected
setUrl("https://api.creaturechat.com/v1/chat/completions");
} else if (apiKey.startsWith("sk-")) {
// Update URL if a OpenAI API key is detected
// Update URL if an OpenAI API key is detected
setUrl("https://api.openai.com/v1/chat/completions");
}
this.apiKey = apiKey;
......@@ -92,7 +97,6 @@ public class ConfigurationHandler {
public int getTimeout() { return timeout; }
public void setTimeout(int timeout) { this.timeout = timeout; }
// Getters and setters for new fields
public int getMaxContextTokens() { return maxContextTokens; }
public void setMaxContextTokens(int maxContextTokens) { this.maxContextTokens = maxContextTokens; }
......@@ -102,5 +106,13 @@ public class ConfigurationHandler {
public double getPercentOfContext() { return percentOfContext; }
public void setPercentOfContext(double percentOfContext) { this.percentOfContext = percentOfContext; }
public List<String> getWhitelist() { return whitelist; }
public void setWhitelist(List<String> whitelist) { this.whitelist = whitelist; }
public List<String> getBlacklist() { return blacklist; }
public void setBlacklist(List<String> blacklist) { this.blacklist = blacklist; }
public String getStory() { return story; }
public void setStory(String story) { this.story = story; }
}
}
......@@ -5,14 +5,27 @@ import com.mojang.brigadier.arguments.ArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.owlmaddie.network.ServerPackets;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.minecraft.command.CommandSource;
import net.minecraft.command.argument.IdentifierArgumentType;
import net.minecraft.entity.EntityType;
import net.minecraft.entity.SpawnGroup;
import net.minecraft.registry.Registries;
import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;
import net.minecraft.util.Identifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* The {@code CreatureChatCommands} class registers custom commands to set new API key, model, and url.
* Permission level set to 4 (server owner), since this deals with API keys and potential costs.
......@@ -33,6 +46,9 @@ public class CreatureChatCommands {
.then(registerSetCommand("url", "URL", StringArgumentType.string()))
.then(registerSetCommand("model", "Model", StringArgumentType.string()))
.then(registerSetCommand("timeout", "Timeout (seconds)", IntegerArgumentType.integer()))
.then(registerStoryCommand())
.then(registerWhitelistCommand())
.then(registerBlacklistCommand())
.then(registerHelpCommand()));
}
......@@ -41,26 +57,13 @@ public class CreatureChatCommands {
.requires(source -> source.hasPermissionLevel(4))
.then(CommandManager.literal("set")
.then(CommandManager.argument("value", valueType)
.then(CommandManager.literal("--config")
.then(CommandManager.literal("default")
.executes(context -> {
if (valueType instanceof StringArgumentType)
return setConfig(context.getSource(), settingName, StringArgumentType.getString(context, "value"), false, settingDescription);
else if (valueType instanceof IntegerArgumentType)
return setConfig(context.getSource(), settingName, IntegerArgumentType.getInteger(context, "value"), false, settingDescription);
return 1;
})
)
.then(CommandManager.literal("server")
.executes(context -> {
if (valueType instanceof StringArgumentType)
return setConfig(context.getSource(), settingName, StringArgumentType.getString(context, "value"), true, settingDescription);
else if (valueType instanceof IntegerArgumentType)
return setConfig(context.getSource(), settingName, IntegerArgumentType.getInteger(context, "value"), true, settingDescription);
return 1;
})
)
)
.then(addConfigArgs((context, useServerConfig) -> {
if (valueType instanceof StringArgumentType)
return setConfig(context.getSource(), settingName, StringArgumentType.getString(context, "value"), useServerConfig, settingDescription);
else if (valueType instanceof IntegerArgumentType)
return setConfig(context.getSource(), settingName, IntegerArgumentType.getInteger(context, "value"), useServerConfig, settingDescription);
return 1;
}))
.executes(context -> {
if (valueType instanceof StringArgumentType)
return setConfig(context.getSource(), settingName, StringArgumentType.getString(context, "value"), false, settingDescription);
......@@ -71,6 +74,122 @@ public class CreatureChatCommands {
));
}
private static List<Identifier> getLivingEntityIds() {
List<Identifier> livingEntityIds = Registries.ENTITY_TYPE.getIds().stream()
.filter(id -> {
EntityType<?> entityType = Registries.ENTITY_TYPE.get(id);
return entityType != null && (entityType.getSpawnGroup() != SpawnGroup.MISC || isIncludedEntity(entityType));
})
.collect(Collectors.toList());
return livingEntityIds;
}
private static boolean isIncludedEntity(EntityType<?> entityType) {
return entityType == EntityType.VILLAGER
|| entityType == EntityType.IRON_GOLEM
|| entityType == EntityType.SNOW_GOLEM;
}
private static List<String> getLivingEntityTypeNames() {
return getLivingEntityIds().stream()
.map(Identifier::toString)
.collect(Collectors.toList());
}
private static LiteralArgumentBuilder<ServerCommandSource> registerWhitelistCommand() {
return CommandManager.literal("whitelist")
.requires(source -> source.hasPermissionLevel(4))
.then(CommandManager.argument("entityType", IdentifierArgumentType.identifier())
.suggests((context, builder) -> CommandSource.suggestIdentifiers(getLivingEntityIds(), builder))
.then(addConfigArgs((context, useServerConfig) -> modifyList(context, "whitelist", IdentifierArgumentType.getIdentifier(context, "entityType").toString(), useServerConfig)))
.executes(context -> modifyList(context, "whitelist", IdentifierArgumentType.getIdentifier(context, "entityType").toString(), false)))
.then(CommandManager.literal("all")
.then(addConfigArgs((context, useServerConfig) -> modifyList(context, "whitelist", "all", useServerConfig)))
.executes(context -> modifyList(context, "whitelist", "all", false)))
.then(CommandManager.literal("clear")
.then(addConfigArgs((context, useServerConfig) -> modifyList(context, "whitelist", "clear", useServerConfig)))
.executes(context -> modifyList(context, "whitelist", "clear", false)));
}
private static LiteralArgumentBuilder<ServerCommandSource> registerBlacklistCommand() {
return CommandManager.literal("blacklist")
.requires(source -> source.hasPermissionLevel(4))
.then(CommandManager.argument("entityType", IdentifierArgumentType.identifier())
.suggests((context, builder) -> CommandSource.suggestIdentifiers(getLivingEntityIds(), builder))
.then(addConfigArgs((context, useServerConfig) -> modifyList(context, "blacklist", IdentifierArgumentType.getIdentifier(context, "entityType").toString(), useServerConfig)))
.executes(context -> modifyList(context, "blacklist", IdentifierArgumentType.getIdentifier(context, "entityType").toString(), false)))
.then(CommandManager.literal("all")
.then(addConfigArgs((context, useServerConfig) -> modifyList(context, "blacklist", "all", useServerConfig)))
.executes(context -> modifyList(context, "blacklist", "all", false)))
.then(CommandManager.literal("clear")
.then(addConfigArgs((context, useServerConfig) -> modifyList(context, "blacklist", "clear", useServerConfig)))
.executes(context -> modifyList(context, "blacklist", "clear", false)));
}
private static LiteralArgumentBuilder<ServerCommandSource> registerHelpCommand() {
return CommandManager.literal("help")
.executes(context -> {
String helpMessage = "Usage of CreatureChat Commands:\n"
+ "/creaturechat key set <key> - Sets the API key\n"
+ "/creaturechat url set \"<url>\" - Sets the URL\n"
+ "/creaturechat model set <model> - Sets the model\n"
+ "/creaturechat timeout set <seconds> - Sets the API timeout\n"
+ "/creaturechat story set \"<story>\" - Sets a custom story\n"
+ "/creaturechat whitelist <entityType | all | clear> - Show chat bubbles\n"
+ "/creaturechat blacklist <entityType | all | clear> - Hide chat bubbles\n"
+ "\n"
+ "Optional: Append [--config default | server] to any command to specify configuration scope.\n"
+ "\n"
+ "Security: Level 4 permission required.";
context.getSource().sendFeedback(() -> Text.literal(helpMessage), false);
return 1;
});
}
private static LiteralArgumentBuilder<ServerCommandSource> registerStoryCommand() {
return CommandManager.literal("story")
.requires(source -> source.hasPermissionLevel(4))
.then(CommandManager.literal("set")
.then(CommandManager.argument("value", StringArgumentType.string())
.executes(context -> {
String story = StringArgumentType.getString(context, "value");
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
config.setStory(story); // Assuming Config has a `setStory` method
if (new ConfigurationHandler(context.getSource().getServer()).saveConfig(config, true)) {
context.getSource().sendFeedback(() -> Text.literal("Story set successfully: " + story).formatted(Formatting.GREEN), true);
return 1;
} else {
context.getSource().sendFeedback(() -> Text.literal("Failed to set story!").formatted(Formatting.RED), false);
return 0;
}
})
))
.then(CommandManager.literal("clear")
.executes(context -> {
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
config.setStory(""); // Clear the story
if (new ConfigurationHandler(context.getSource().getServer()).saveConfig(config, true)) {
context.getSource().sendFeedback(() -> Text.literal("Story cleared successfully!").formatted(Formatting.GREEN), true);
return 1;
} else {
context.getSource().sendFeedback(() -> Text.literal("Failed to clear story!").formatted(Formatting.RED), false);
return 0;
}
}))
.then(CommandManager.literal("display")
.executes(context -> {
ConfigurationHandler.Config config = new ConfigurationHandler(context.getSource().getServer()).loadConfig();
String story = config.getStory(); // Assuming Config has a `getStory` method
if (story == null || story.isEmpty()) {
context.getSource().sendFeedback(() -> Text.literal("No story is currently set.").formatted(Formatting.RED), false);
return 0;
} else {
context.getSource().sendFeedback(() -> Text.literal("Current story: " + story).formatted(Formatting.AQUA), false);
return 1;
}
}));
}
private static <T> int setConfig(ServerCommandSource source, String settingName, T value, boolean useServerConfig, String settingDescription) {
ConfigurationHandler configHandler = new ConfigurationHandler(source.getServer());
ConfigurationHandler.Config config = configHandler.loadConfig();
......@@ -109,13 +228,11 @@ public class CreatureChatCommands {
Text feedbackMessage;
if (configHandler.saveConfig(config, useServerConfig)) {
// succeeded
feedbackMessage = Text.literal(settingDescription + " Set Successfully!").formatted(Formatting.GREEN);
source.sendFeedback(() -> feedbackMessage, false);
LOGGER.info("Command executed: " + feedbackMessage.getString());
return 1;
} else {
// failed
feedbackMessage = Text.literal(settingDescription + " Set Failed!").formatted(Formatting.RED);
source.sendFeedback(() -> feedbackMessage, false);
LOGGER.info("Command executed: " + feedbackMessage.getString());
......@@ -123,20 +240,83 @@ public class CreatureChatCommands {
}
}
private static LiteralArgumentBuilder<ServerCommandSource> registerHelpCommand() {
return CommandManager.literal("help")
.executes(context -> {
String helpMessage = "Usage of CreatureChat Commands:\n"
+ "/creaturechat key set <key> - Sets the API key\n"
+ "/creaturechat url set \"<url>\" - Sets the URL\n"
+ "/creaturechat model set <model> - Sets the model\n"
+ "/creaturechat timeout set <seconds> - Sets the API timeout\n"
+ "\n"
+ "Optional: Append [--config default | server] to any command to specify configuration scope.\n"
+ "\n"
+ "Security: Level 4 permission required.";
context.getSource().sendFeedback(() -> Text.literal(helpMessage), false);
return 1;
});
private static int modifyList(CommandContext<ServerCommandSource> context, String listName, String action, boolean useServerConfig) {
ServerCommandSource source = context.getSource();
ConfigurationHandler configHandler = new ConfigurationHandler(source.getServer());
ConfigurationHandler.Config config = configHandler.loadConfig();
List<String> entityTypes = getLivingEntityTypeNames();
try {
if ("all".equals(action)) {
if ("whitelist".equals(listName)) {
config.setWhitelist(entityTypes);
config.setBlacklist(new ArrayList<>()); // Clear blacklist
} else if ("blacklist".equals(listName)) {
config.setBlacklist(entityTypes);
config.setWhitelist(new ArrayList<>()); // Clear whitelist
}
} else if ("clear".equals(action)) {
if ("whitelist".equals(listName)) {
config.setWhitelist(new ArrayList<>());
} else if ("blacklist".equals(listName)) {
config.setBlacklist(new ArrayList<>());
}
} else {
if (!entityTypes.contains(action)) {
throw new IllegalArgumentException("Invalid entity type: " + action);
}
if ("whitelist".equals(listName)) {
List<String> whitelist = new ArrayList<>(config.getWhitelist());
if (!whitelist.contains(action)) {
whitelist.add(action);
config.setWhitelist(whitelist);
}
// Remove from blacklist if present
List<String> blacklist = new ArrayList<>(config.getBlacklist());
blacklist.remove(action);
config.setBlacklist(blacklist);
} else if ("blacklist".equals(listName)) {
List<String> blacklist = new ArrayList<>(config.getBlacklist());
if (!blacklist.contains(action)) {
blacklist.add(action);
config.setBlacklist(blacklist);
}
// Remove from whitelist if present
List<String> whitelist = new ArrayList<>(config.getWhitelist());
whitelist.remove(action);
config.setWhitelist(whitelist);
}
}
} catch (IllegalArgumentException e) {
Text errorMessage = Text.literal(e.getMessage()).formatted(Formatting.RED);
source.sendFeedback(() -> errorMessage, false);
LOGGER.error("Error modifying list: " + e.getMessage(), e);
return 0;
}
if (configHandler.saveConfig(config, useServerConfig)) {
Text feedbackMessage = Text.literal("Successfully updated " + listName + " with " + action).formatted(Formatting.GREEN);
source.sendFeedback(() -> feedbackMessage, false);
// Send whitelist / blacklist to all players
ServerPackets.send_whitelist_blacklist(null);
return 1;
} else {
Text feedbackMessage = Text.literal("Failed to update " + listName).formatted(Formatting.RED);
source.sendFeedback(() -> feedbackMessage, false);
return 0;
}
}
private static LiteralArgumentBuilder<ServerCommandSource> addConfigArgs(CommandExecutor executor) {
return CommandManager.literal("--config")
.then(CommandManager.literal("default").executes(context -> executor.run(context, false)))
.then(CommandManager.literal("server").executes(context -> executor.run(context, true)))
.executes(context -> executor.run(context, false));
}
@FunctionalInterface
private interface CommandExecutor {
int run(CommandContext<ServerCommandSource> context, boolean useServerConfig) throws CommandSyntaxException;
}
}
package com.owlmaddie.controls;
import net.minecraft.entity.boss.dragon.EnderDragonEntity;
import net.minecraft.entity.mob.*;
import net.minecraft.entity.passive.SquidEntity;
import net.minecraft.server.network.ServerPlayerEntity;
......@@ -13,27 +12,36 @@ import net.minecraft.util.math.Vec3d;
*/
public class LookControls {
public static void lookAtPlayer(ServerPlayerEntity player, MobEntity entity) {
// Get the player's eye line position
Vec3d playerPos = player.getPos();
float eyeHeight = player.getEyeHeight(player.getPose());
Vec3d eyePos = new Vec3d(playerPos.x, playerPos.y + eyeHeight, playerPos.z);
lookAtPosition(eyePos, entity);
}
public static void lookAtPosition(Vec3d targetPos, MobEntity entity) {
if (entity instanceof SlimeEntity) {
handleSlimeLook((SlimeEntity) entity, player);
handleSlimeLook((SlimeEntity) entity, targetPos);
} else if (entity instanceof SquidEntity) {
handleSquidLook((SquidEntity) entity, player);
handleSquidLook((SquidEntity) entity, targetPos);
} else if (entity instanceof GhastEntity) {
handleFlyingEntity(entity, player, 10F);
handleFlyingEntity(entity, targetPos, 10F);
} else if (entity instanceof FlyingEntity || entity instanceof VexEntity) {
handleFlyingEntity(entity, player, 4F);
handleFlyingEntity(entity, targetPos, 4F);
} else {
// Make the entity look at the player
entity.getLookControl().lookAt(player, 10.0F, (float)entity.getMaxLookPitchChange());
entity.getLookControl().lookAt(targetPos.x, targetPos.y, targetPos.z, 10.0F, (float)entity.getMaxLookPitchChange());
}
}
private static void handleSlimeLook(SlimeEntity slime, ServerPlayerEntity player) {
float yawChange = calculateYawChangeToPlayer(slime, player);
private static void handleSlimeLook(SlimeEntity slime, Vec3d targetPos) {
float yawChange = calculateYawChange(slime, targetPos);
((SlimeEntity.SlimeMoveControl) slime.getMoveControl()).look(slime.getYaw() + yawChange, false);
}
private static void handleSquidLook(SquidEntity squid, ServerPlayerEntity player) {
Vec3d toPlayer = calculateNormalizedDirection(squid, player);
private static void handleSquidLook(SquidEntity squid, Vec3d targetPos) {
Vec3d toPlayer = calculateNormalizedDirection(squid, targetPos);
float initialSwimStrength = 0.15f;
squid.setSwimmingVector(
(float) toPlayer.x * initialSwimStrength,
......@@ -41,7 +49,7 @@ public class LookControls {
(float) toPlayer.z * initialSwimStrength
);
double distanceToPlayer = squid.getPos().distanceTo(player.getPos());
double distanceToPlayer = squid.getPos().distanceTo(targetPos);
if (distanceToPlayer < 3.5F) {
// Stop motion when close
squid.setVelocity(0,0,0);
......@@ -49,17 +57,16 @@ public class LookControls {
}
// Ghast, Phantom, etc...
private static void handleFlyingEntity(MobEntity flyingEntity, ServerPlayerEntity player, float stopDistance) {
Vec3d playerPosition = player.getPos();
private static void handleFlyingEntity(MobEntity flyingEntity, Vec3d targetPos, float stopDistance) {
Vec3d flyingPosition = flyingEntity.getPos();
Vec3d toPlayer = playerPosition.subtract(flyingPosition).normalize();
Vec3d toPlayer = targetPos.subtract(flyingPosition).normalize();
// Calculate the yaw to align the flyingEntity's facing direction with the movement direction
float targetYaw = (float)(MathHelper.atan2(toPlayer.z, toPlayer.x) * (180 / Math.PI) - 90);
flyingEntity.setYaw(targetYaw);
// Look at player while adjusting yaw
flyingEntity.getLookControl().lookAt(player, 10.0F, (float)flyingEntity.getMaxLookPitchChange());
flyingEntity.getLookControl().lookAt(targetPos.x, targetPos.y, targetPos.z, 10.0F, (float)flyingEntity.getMaxLookPitchChange());
float initialSpeed = 0.15F;
flyingEntity.setVelocity(
......@@ -68,23 +75,22 @@ public class LookControls {
(float) toPlayer.z * initialSpeed
);
double distanceToPlayer = flyingEntity.getPos().distanceTo(player.getPos());
double distanceToPlayer = flyingEntity.getPos().distanceTo(targetPos);
if (distanceToPlayer < stopDistance) {
// Stop motion when close
flyingEntity.setVelocity(0, 0, 0);
}
}
public static float calculateYawChangeToPlayer(MobEntity entity, ServerPlayerEntity player) {
Vec3d toPlayer = calculateNormalizedDirection(entity, player);
public static float calculateYawChange(MobEntity entity, Vec3d targetPos) {
Vec3d toPlayer = calculateNormalizedDirection(entity, targetPos);
float targetYaw = (float) Math.toDegrees(Math.atan2(toPlayer.z, toPlayer.x)) - 90.0F;
float yawDifference = MathHelper.wrapDegrees(targetYaw - entity.getYaw());
return MathHelper.clamp(yawDifference, -10.0F, 10.0F);
}
public static Vec3d calculateNormalizedDirection(MobEntity entity, ServerPlayerEntity player) {
Vec3d playerPos = player.getPos();
public static Vec3d calculateNormalizedDirection(MobEntity entity, Vec3d targetPos) {
Vec3d entityPos = entity.getPos();
return playerPos.subtract(entityPos).normalize();
return targetPos.subtract(entityPos).normalize();
}
}
\ No newline at end of file
......@@ -34,6 +34,8 @@ public class SpeedControls {
speed = 2F;
} else if (entity instanceof RabbitEntity) {
speed = 1.5F;
} else if (entity instanceof PhantomEntity) {
speed = 0.2F;
}
return speed;
......
......@@ -3,16 +3,18 @@ package com.owlmaddie.goals;
import net.minecraft.entity.LivingEntity;
import net.minecraft.entity.ai.RangedAttackMob;
import net.minecraft.entity.mob.Angerable;
import java.util.concurrent.ThreadLocalRandom;
import net.minecraft.entity.mob.HostileEntity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.passive.GolemEntity;
import net.minecraft.particle.ParticleTypes;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.sound.SoundEvents;
import net.minecraft.util.math.Vec3d;
import java.util.EnumSet;
import static com.owlmaddie.network.ServerPackets.ATTACK_PARTICLE;
/**
* The {@code AttackPlayerGoal} class instructs a Mob Entity to show aggression towards a target Entity.
* For passive entities like chickens (or hostile entities in creative mode), damage is simulated with particles.
......@@ -23,7 +25,7 @@ public class AttackPlayerGoal extends PlayerBaseGoal {
protected enum EntityState { MOVING_TOWARDS_PLAYER, IDLE, CHARGING, ATTACKING, LEAPING }
protected EntityState currentState = EntityState.IDLE;
protected int cooldownTimer = 0;
protected final int CHARGE_TIME = 15; // Time before leaping / attacking
protected final int CHARGE_TIME = 12; // Time before leaping / attacking
protected final double MOVE_DISTANCE = 200D; // 20 blocks away
protected final double CHARGE_DISTANCE = 25D; // 5 blocks away
protected final double ATTACK_DISTANCE = 4D; // 2 blocks away
......@@ -94,12 +96,10 @@ public class AttackPlayerGoal extends PlayerBaseGoal {
this.attackerEntity.playSound(SoundEvents.ENTITY_PLAYER_HURT, 1F, 1F);
// Spawn red particles to simulate 'injury'
((ServerWorld) this.attackerEntity.getWorld()).spawnParticles(ParticleTypes.DAMAGE_INDICATOR,
this.targetEntity.getX(),
this.targetEntity.getBodyY(0.5D),
this.targetEntity.getZ(),
10, // number of particles
0.1, 0.1, 0.1, 0.2); // speed and randomness
int numParticles = ThreadLocalRandom.current().nextInt(2, 7); // Random number between 2 (inclusive) and 7 (exclusive)
((ServerWorld) this.attackerEntity.getWorld()).spawnParticles(ATTACK_PARTICLE,
this.targetEntity.getX(), this.targetEntity.getBodyY(0.5D), this.targetEntity.getZ(),
numParticles, 0.5, 0.5, 0.1, 0.4);
}
@Override
......
......@@ -43,14 +43,29 @@ public class FleePlayerGoal extends PlayerBaseGoal {
private void fleeFromPlayer() {
int roundedFleeDistance = Math.round(fleeDistance);
Vec3d fleeTarget = FuzzyTargeting.findFrom((PathAwareEntity)this.entity, roundedFleeDistance,
roundedFleeDistance, this.targetEntity.getPos());
if (this.entity instanceof PathAwareEntity) {
// Set random path away from player
Vec3d fleeTarget = FuzzyTargeting.findFrom((PathAwareEntity) this.entity, roundedFleeDistance,
roundedFleeDistance, this.targetEntity.getPos());
if (fleeTarget != null) {
Path path = this.entity.getNavigation().findPathTo(fleeTarget.x, fleeTarget.y, fleeTarget.z, 0);
if (path != null) {
this.entity.getNavigation().startMovingAlong(path, this.speed);
if (fleeTarget != null) {
Path path = this.entity.getNavigation().findPathTo(fleeTarget.x, fleeTarget.y, fleeTarget.z, 0);
if (path != null) {
this.entity.getNavigation().startMovingAlong(path, this.speed);
}
}
} else {
// Move in the opposite direction from player (for non-path aware entities)
Vec3d playerPos = this.targetEntity.getPos();
Vec3d entityPos = this.entity.getPos();
// Calculate the direction away from the player
Vec3d fleeDirection = entityPos.subtract(playerPos).normalize();
// Apply movement with the entity's speed in the opposite direction
this.entity.setVelocity(fleeDirection.x * this.speed, fleeDirection.y * this.speed, fleeDirection.z * this.speed);
this.entity.velocityModified = true;
}
}
......
......@@ -63,6 +63,9 @@ public class FollowPlayerGoal extends PlayerBaseGoal {
}
private Vec3d findTeleportPosition(int distance) {
return FuzzyTargeting.findTo((PathAwareEntity)this.entity, distance, distance, this.targetEntity.getPos());
if (this.entity instanceof PathAwareEntity) {
return FuzzyTargeting.findTo((PathAwareEntity) this.entity, distance, distance, this.targetEntity.getPos());
}
return null;
}
}
......@@ -8,6 +8,7 @@ public enum GoalPriority {
// Enum constants (Goal Types) with their corresponding priority values
TALK_PLAYER(2),
PROTECT_PLAYER(2),
LEAD_PLAYER(3),
FOLLOW_PLAYER(3),
FLEE_PLAYER(3),
ATTACK_PLAYER(3);
......
package com.owlmaddie.goals;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.controls.LookControls;
import com.owlmaddie.network.ServerPackets;
import com.owlmaddie.particle.LeadParticleEffect;
import com.owlmaddie.utils.RandomTargetFinder;
import net.minecraft.entity.ai.pathing.Path;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.mob.PathAwareEntity;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.util.math.MathHelper;
import net.minecraft.util.math.Vec3d;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.EnumSet;
import java.util.Random;
/**
* The {@code LeadPlayerGoal} class instructs a Mob Entity to lead the player to a random location, consisting
* of many random waypoints. It supports PathAware and NonPathAware entities.
*/
public class LeadPlayerGoal extends PlayerBaseGoal {
public static final Logger LOGGER = LoggerFactory.getLogger("creaturechat");
private final MobEntity entity;
private final double speed;
private final Random random = new Random();
private int currentWaypoint = 0;
private int totalWaypoints;
private Vec3d currentTarget = null;
private boolean foundWaypoint = false;
private int ticksSinceLastWaypoint = 0;
public LeadPlayerGoal(ServerPlayerEntity player, MobEntity entity, double speed) {
super(player);
this.entity = entity;
this.speed = speed;
this.setControls(EnumSet.of(Control.MOVE, Control.LOOK));
this.totalWaypoints = random.nextInt(14) + 6;
}
@Override
public boolean canStart() {
return super.canStart() && !foundWaypoint && this.entity.squaredDistanceTo(this.targetEntity) <= 16 * 16 && !foundWaypoint;
}
@Override
public boolean shouldContinue() {
return super.canStart() && !foundWaypoint && this.entity.squaredDistanceTo(this.targetEntity) <= 16 * 16 && !foundWaypoint;
}
@Override
public void tick() {
ticksSinceLastWaypoint++;
if (this.entity.squaredDistanceTo(this.targetEntity) > 16 * 16) {
this.entity.getNavigation().stop();
return;
}
// Are we there yet?
if (currentWaypoint >= totalWaypoints && !foundWaypoint) {
foundWaypoint = true;
LOGGER.info("Tick: You have ARRIVED at your destination");
ServerPackets.scheduler.scheduleTask(() -> {
// Prepare a message about the interaction
String arrivedMessage = "<You have arrived at your destination>";
ChatDataManager chatDataManager = ChatDataManager.getServerInstance();
EntityChatData chatData = chatDataManager.getOrCreateChatData(this.entity.getUuidAsString());
if (!chatData.characterSheet.isEmpty() && chatData.auto_generated < chatDataManager.MAX_AUTOGENERATE_RESPONSES) {
ServerPackets.generate_chat("N/A", chatData, (ServerPlayerEntity) this.targetEntity, this.entity, arrivedMessage, true);
}
});
// Stop navigation
this.entity.getNavigation().stop();
} else if (this.currentTarget == null || this.entity.squaredDistanceTo(this.currentTarget) < 2 * 2 || ticksSinceLastWaypoint >= 20 * 10) {
// Set next waypoint
setNewTarget();
moveToTarget();
ticksSinceLastWaypoint = 0;
} else {
moveToTarget();
}
}
private void moveToTarget() {
if (this.currentTarget != null) {
if (this.entity instanceof PathAwareEntity) {
if (!this.entity.getNavigation().isFollowingPath()) {
Path path = this.entity.getNavigation().findPathTo(this.currentTarget.x, this.currentTarget.y, this.currentTarget.z, 1);
if (path != null) {
LOGGER.debug("Start moving along path");
this.entity.getNavigation().startMovingAlong(path, this.speed);
}
}
} else {
// Make the entity look at the player without moving towards them
LookControls.lookAtPosition(this.currentTarget, this.entity);
// Move towards the target for non-path aware entities
Vec3d entityPos = this.entity.getPos();
Vec3d moveDirection = this.currentTarget.subtract(entityPos).normalize();
// Calculate current speed from the entity's current velocity
double currentSpeed = this.entity.getVelocity().horizontalLength();
// Gradually adjust speed towards the target speed
currentSpeed = MathHelper.stepTowards((float) currentSpeed, (float) this.speed, (float) (0.005 * (this.speed / Math.max(currentSpeed, 0.1))));
// Apply movement with the adjusted speed towards the target
Vec3d newVelocity = new Vec3d(moveDirection.x * currentSpeed, moveDirection.y * currentSpeed, moveDirection.z * currentSpeed);
this.entity.setVelocity(newVelocity);
this.entity.velocityModified = true;
}
}
}
private void setNewTarget() {
// Increment waypoint
currentWaypoint++;
LOGGER.info("Waypoint " + currentWaypoint + " / " + this.totalWaypoints);
this.currentTarget = RandomTargetFinder.findRandomTarget(this.entity, 30, 24, 36);
if (this.currentTarget != null) {
emitParticlesAlongRaycast(this.entity.getPos(), this.currentTarget);
}
// Stop following current path (if any)
this.entity.getNavigation().stop();
}
private void emitParticleAt(Vec3d position, double angle) {
if (this.entity.getWorld() instanceof ServerWorld) {
ServerWorld serverWorld = (ServerWorld) this.entity.getWorld();
// Pass the angle using the "speed" argument, with deltaX, deltaY, deltaZ set to 0
LeadParticleEffect effect = new LeadParticleEffect(angle);
serverWorld.spawnParticles(effect, position.x, position.y + 0.05, position.z, 1, 0, 0, 0, 0);
}
}
private void emitParticlesAlongRaycast(Vec3d start, Vec3d end) {
// Calculate the direction vector from the entity (start) to the target (end)
Vec3d direction = end.subtract(start);
// Calculate the angle in the XZ-plane using atan2 (this is in radians)
double angleRadians = Math.atan2(direction.z, direction.x);
// Convert from radians to degrees
double angleDegrees = Math.toDegrees(angleRadians);
// Convert the calculated angle to Minecraft's yaw system:
double minecraftYaw = (360 - (angleDegrees + 90)) % 360;
// Correct the 180-degree flip
minecraftYaw = (minecraftYaw + 180) % 360;
if (minecraftYaw < 0) {
minecraftYaw += 360;
}
// Emit particles along the ray from startRange to endRange
double distance = start.distanceTo(end);
double startRange = Math.min(5, distance);;
double endRange = Math.min(startRange + 10, distance);
for (double d = startRange; d <= endRange; d += 5) {
Vec3d pos = start.add(direction.normalize().multiply(d));
emitParticleAt(pos, Math.toRadians(minecraftYaw)); // Convert back to radians for rendering
}
}
}
\ No newline at end of file
......@@ -19,7 +19,7 @@ public class ProtectPlayerGoal extends AttackPlayerGoal {
@Override
public boolean canStart() {
MobEntity lastAttackedByEntity = (MobEntity)this.protectedEntity.getLastAttacker();
LivingEntity lastAttackedByEntity = this.protectedEntity.getLastAttacker();
int i = this.protectedEntity.getLastAttackedTime();
if (i != this.lastAttackedTime && lastAttackedByEntity != null && !this.attackerEntity.equals(lastAttackedByEntity)) {
// Set target to attack
......
package com.owlmaddie.json;
import java.util.List;
public class QuestJson {
Story story;
List<Character> characters;
public static class Story {
String background;
String clue;
}
public static class Character {
String name;
int age;
String personality;
String greeting;
String entity_type_key;
Quest quest;
String choice_question;
List<Choice> choices;
}
public static class Quest {
List<QuestItem> quest_items;
List<DropItem> drop_items;
}
public static class QuestItem {
String key;
int quantity;
}
public static class DropItem {
String key;
int quantity;
}
public static class Choice {
String choice;
String clue;
}
}
......@@ -16,10 +16,10 @@ public class MessageParser {
public static final Logger LOGGER = LoggerFactory.getLogger("creaturechat");
public static ParsedMessage parseMessage(String input) {
LOGGER.info("Parsing message: {}", input);
LOGGER.debug("Parsing message: {}", input);
StringBuilder cleanedMessage = new StringBuilder();
List<Behavior> behaviors = new ArrayList<>();
Pattern pattern = Pattern.compile("[<*](FOLLOW|FLEE|ATTACK|FRIENDSHIP|UNFOLLOW|PROTECT|UNPROTECT)[:\\s]*(\\s*[+-]?\\d+)?[>*]", Pattern.CASE_INSENSITIVE);
Pattern pattern = Pattern.compile("[<*](FOLLOW|LEAD|FLEE|ATTACK|PROTECT|FRIENDSHIP|UNFOLLOW|UNLEAD|UNPROTECT|UNFLEE)[:\\s]*(\\s*[+-]?\\d+)?[>*]", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(input);
while (matcher.find()) {
......@@ -29,7 +29,7 @@ public class MessageParser {
argument = Integer.valueOf(matcher.group(2));
}
behaviors.add(new Behavior(behaviorName, argument));
LOGGER.info("Found behavior: {} with argument: {}", behaviorName, argument);
LOGGER.debug("Found behavior: {} with argument: {}", behaviorName, argument);
matcher.appendReplacement(cleanedMessage, "");
}
......@@ -40,7 +40,7 @@ public class MessageParser {
// Remove all occurrences of "<>" and "**" (if any)
displayMessage = displayMessage.replaceAll("<>", "").replaceAll("\\*\\*", "").trim();
LOGGER.info("Cleaned message: {}", displayMessage);
LOGGER.debug("Cleaned message: {}", displayMessage);
return new ParsedMessage(displayMessage, input.trim(), behaviors);
}
......
package com.owlmaddie.mixin;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.network.ServerPackets;
import com.owlmaddie.utils.LivingEntityInterface;
import net.minecraft.entity.Entity;
import net.minecraft.entity.LivingEntity;
import net.minecraft.entity.damage.DamageSource;
......@@ -19,15 +20,29 @@ import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* The {@code MixinLivingEntity} class modifies the behavior of {@link LivingEntity} to integrate
* custom friendship, chat, and death message mechanics. It prevents friendly entities from targeting players,
* generates contextual chat messages on attacks, and broadcasts custom death messages for named entities.
*/
@Mixin(LivingEntity.class)
public class MixinLivingEntity implements LivingEntityInterface {
private boolean canTargetPlayers = true; // Default to true to maintain original behavior
public class MixinLivingEntity {
private EntityChatData getChatData(LivingEntity entity) {
ChatDataManager chatDataManager = ChatDataManager.getServerInstance();
return chatDataManager.getOrCreateChatData(entity.getUuidAsString());
}
@Inject(method = "canTarget(Lnet/minecraft/entity/LivingEntity;)Z", at = @At("HEAD"), cancellable = true)
private void modifyCanTarget(LivingEntity target, CallbackInfoReturnable<Boolean> cir) {
if (!this.canTargetPlayers && target instanceof PlayerEntity) {
cir.setReturnValue(false);
if (target instanceof PlayerEntity) {
LivingEntity thisEntity = (LivingEntity) (Object) this;
EntityChatData entityData = getChatData(thisEntity);
PlayerData playerData = entityData.getPlayerData(target.getDisplayName().getString());
if (playerData.friendship > 0) {
// Friendly creatures can't target a player
cir.setReturnValue(false);
}
}
}
......@@ -46,12 +61,11 @@ public class MixinLivingEntity implements LivingEntityInterface {
if (attacker instanceof PlayerEntity && thisEntity instanceof MobEntity && !thisEntity.isDead()) {
// Generate attacked message (only if the previous user message was not an attacked message)
// We don't want to constantly generate messages during a prolonged, multi-damage event
ChatDataManager chatDataManager = ChatDataManager.getServerInstance();
ChatDataManager.EntityChatData chatData = chatDataManager.getOrCreateChatData(thisEntity.getUuidAsString());
if (!chatData.characterSheet.isEmpty() && chatData.auto_generated < chatDataManager.MAX_AUTOGENERATE_RESPONSES) {
ServerPlayerEntity player = (ServerPlayerEntity) attacker;
EntityChatData chatData = getChatData(thisEntity);
if (!chatData.characterSheet.isEmpty() && chatData.auto_generated < ChatDataManager.MAX_AUTOGENERATE_RESPONSES) {
// Only auto-generate a response to being attacked if chat data already exists
// and this is the first attack event.
ServerPlayerEntity player = (ServerPlayerEntity)attacker;
ItemStack weapon = player.getMainHandStack();
String weaponName = weapon.isEmpty() ? "with fists" : "with " + weapon.getItem().toString();
......@@ -60,7 +74,7 @@ public class MixinLivingEntity implements LivingEntityInterface {
String directness = isIndirect ? "indirectly" : "directly";
String attackedMessage = "<" + player.getName().getString() + " attacked you " + directness + " with " + weaponName + ">";
ServerPackets.generate_chat("N/A", chatData, player, (MobEntity)thisEntity, attackedMessage, true);
ServerPackets.generate_chat("N/A", chatData, player, (MobEntity) thisEntity, attackedMessage, true);
}
}
}
......@@ -80,15 +94,14 @@ public class MixinLivingEntity implements LivingEntityInterface {
return;
}
// Get the original death message
Text deathMessage = entity.getDamageTracker().getDeathMessage();
// Broadcast the death message to all players in the world
ServerPackets.BroadcastMessage(deathMessage);
// Get chatData for the entity
EntityChatData chatData = getChatData(entity);
if (chatData != null && !chatData.characterSheet.isEmpty()) {
// Get the original death message
Text deathMessage = entity.getDamageTracker().getDeathMessage();
// Broadcast the death message to all players in the world
ServerPackets.BroadcastMessage(deathMessage);
}
}
}
@Override
public void setCanTargetPlayers(boolean canTarget) {
this.canTargetPlayers = canTarget;
}
}
package com.owlmaddie.mixin;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.network.ServerPackets;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.passive.TameableEntity;
import net.minecraft.entity.passive.VillagerEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.item.Item;
import net.minecraft.item.ItemStack;
......@@ -23,9 +27,24 @@ public class MixinMobEntity {
@Inject(method = "interact", at = @At(value = "RETURN"))
private void onItemGiven(PlayerEntity player, Hand hand, CallbackInfoReturnable<ActionResult> cir) {
// Only process interactions on the server side
if (player.getWorld().isClient()) {
return;
}
// Only process interactions for the main hand
if (hand != Hand.MAIN_HAND) {
return;
}
ItemStack itemStack = player.getStackInHand(hand);
MobEntity thisEntity = (MobEntity) (Object) this;
// Don't interact with Villagers (avoid issues with trade UI) OR Tameable (i.e. sit / no-sit)
if (thisEntity instanceof VillagerEntity || thisEntity instanceof TameableEntity) {
return;
}
// Determine if the item is a bucket
// We don't want to interact on buckets
Item item = itemStack.getItem();
......@@ -43,26 +62,36 @@ public class MixinMobEntity {
return;
}
// Get chat data for entity
ChatDataManager chatDataManager = ChatDataManager.getServerInstance();
EntityChatData entityData = chatDataManager.getOrCreateChatData(thisEntity.getUuidAsString());
PlayerData playerData = entityData.getPlayerData(player.getDisplayName().getString());
// Check if the player successfully interacts with an item
if (!itemStack.isEmpty() && player instanceof ServerPlayerEntity) {
ServerPlayerEntity serverPlayer = (ServerPlayerEntity) player;
String itemName = itemStack.getItem().getName().getString();
int itemCount = itemStack.getCount();
if (player instanceof ServerPlayerEntity) {
// Player has item in hand
if (!itemStack.isEmpty()) {
ServerPlayerEntity serverPlayer = (ServerPlayerEntity) player;
String itemName = itemStack.getItem().getName().getString();
int itemCount = itemStack.getCount();
// Decide verb
String action_verb = " shows ";
if (cir.getReturnValue().isAccepted()) {
action_verb = " gives ";
}
// Decide verb
String action_verb = " shows ";
if (cir.getReturnValue().isAccepted()) {
action_verb = " gives ";
}
// Prepare a message about the interaction
String giveItemMessage = "<" + serverPlayer.getName().getString() +
action_verb + "you " + itemCount + " " + itemName + ">";
// Prepare a message about the interaction
String giveItemMessage = "<" + serverPlayer.getName().getString() +
action_verb + "you " + itemCount + " " + itemName + ">";
if (!entityData.characterSheet.isEmpty() && entityData.auto_generated < chatDataManager.MAX_AUTOGENERATE_RESPONSES) {
ServerPackets.generate_chat("N/A", entityData, serverPlayer, thisEntity, giveItemMessage, true);
}
ChatDataManager chatDataManager = ChatDataManager.getServerInstance();
ChatDataManager.EntityChatData chatData = chatDataManager.getOrCreateChatData(thisEntity.getUuidAsString());
if (!chatData.characterSheet.isEmpty() && chatData.auto_generated < chatDataManager.MAX_AUTOGENERATE_RESPONSES) {
ServerPackets.generate_chat("N/A", chatData, serverPlayer, thisEntity, giveItemMessage, true);
} else if (itemStack.isEmpty() && playerData.friendship == 3) {
// Player's hand is empty, Ride your best friend!
player.startRiding(thisEntity, true);
}
}
}
......
package com.owlmaddie.mixin;
import com.owlmaddie.utils.VillagerEntityAccessor;
import net.minecraft.entity.passive.VillagerEntity;
import net.minecraft.village.VillagerGossips;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
/**
* The {@code MixinVillagerEntity} class adds an accessor to expose the gossip system of {@link VillagerEntity}.
* This allows external classes to retrieve and interact with a villager's gossip data.
*/
@Mixin(VillagerEntity.class)
public abstract class MixinVillagerEntity implements VillagerEntityAccessor {
@Shadow
private VillagerGossips gossip;
@Override
// Access a Villager's gossip system
public VillagerGossips getGossip() {
return this.gossip;
}
}
......@@ -2,11 +2,14 @@ package com.owlmaddie.network;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.ChatDataSaverScheduler;
import com.owlmaddie.chat.EntityChatData;
import com.owlmaddie.chat.PlayerData;
import com.owlmaddie.commands.ConfigurationHandler;
import com.owlmaddie.goals.EntityBehaviorManager;
import com.owlmaddie.goals.GoalPriority;
import com.owlmaddie.goals.TalkPlayerGoal;
import com.owlmaddie.particle.LeadParticleEffect;
import com.owlmaddie.utils.Compression;
import com.owlmaddie.utils.LivingEntityInterface;
import com.owlmaddie.utils.Randomizer;
import com.owlmaddie.utils.ServerEntityFinder;
import io.netty.buffer.Unpooled;
......@@ -14,10 +17,15 @@ import net.fabricmc.fabric.api.event.lifecycle.v1.ServerEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerWorldEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.fabricmc.fabric.api.particle.v1.FabricParticleTypes;
import net.minecraft.entity.Entity;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.particle.DefaultParticleType;
import net.minecraft.particle.ParticleType;
import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
......@@ -30,6 +38,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
......@@ -40,7 +50,7 @@ import java.util.concurrent.TimeUnit;
public class ServerPackets {
public static final Logger LOGGER = LoggerFactory.getLogger("creaturechat");
public static MinecraftServer serverInstance;
private static ChatDataSaverScheduler scheduler = null;
public static ChatDataSaverScheduler scheduler = null;
public static final Identifier PACKET_C2S_GREETING = new Identifier("creaturechat", "packet_c2s_greeting");
public static final Identifier PACKET_C2S_READ_NEXT = new Identifier("creaturechat", "packet_c2s_read_next");
public static final Identifier PACKET_C2S_SET_STATUS = new Identifier("creaturechat", "packet_c2s_set_status");
......@@ -49,9 +59,36 @@ public class ServerPackets {
public static final Identifier PACKET_C2S_SEND_CHAT = new Identifier("creaturechat", "packet_c2s_send_chat");
public static final Identifier PACKET_S2C_MESSAGE = new Identifier("creaturechat", "packet_s2c_message");
public static final Identifier PACKET_S2C_LOGIN = new Identifier("creaturechat", "packet_s2c_login");
public static final Identifier PACKET_S2C_WHITELIST = new Identifier("creaturechat", "packet_s2c_whitelist");
public static final Identifier PACKET_S2C_PLAYER_STATUS = new Identifier("creaturechat", "packet_s2c_player_status");
public static final DefaultParticleType HEART_SMALL_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType HEART_BIG_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FIRE_SMALL_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FIRE_BIG_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType ATTACK_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FLEE_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FOLLOW_FRIEND_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType FOLLOW_ENEMY_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType PROTECT_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType LEAD_FRIEND_PARTICLE = FabricParticleTypes.simple();
public static final DefaultParticleType LEAD_ENEMY_PARTICLE = FabricParticleTypes.simple();
public static final ParticleType<LeadParticleEffect> LEAD_PARTICLE = FabricParticleTypes.complex(LeadParticleEffect.DESERIALIZER);
public static void register() {
// Register custom particles
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "heart_small"), HEART_SMALL_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "heart_big"), HEART_BIG_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "fire_small"), FIRE_SMALL_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "fire_big"), FIRE_BIG_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "attack"), ATTACK_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "flee"), FLEE_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "follow_enemy"), FOLLOW_ENEMY_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "follow_friend"), FOLLOW_FRIEND_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "protect"), PROTECT_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "lead_enemy"), LEAD_ENEMY_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "lead_friend"), LEAD_FRIEND_PARTICLE);
Registry.register(Registries.PARTICLE_TYPE, new Identifier("creaturechat", "lead"), LEAD_PARTICLE);
// Handle packet for Greeting
ServerPlayNetworking.registerGlobalReceiver(PACKET_C2S_GREETING, (server, player, handler, buf, responseSender) -> {
UUID entityId = UUID.fromString(buf.readString());
......@@ -61,7 +98,7 @@ public class ServerPackets {
server.execute(() -> {
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), entityId);
if (entity != null) {
ChatDataManager.EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
if (chatData.characterSheet.isEmpty()) {
generate_character(userLanguage, chatData, player, entity);
}
......@@ -82,7 +119,7 @@ public class ServerPackets {
TalkPlayerGoal talkGoal = new TalkPlayerGoal(player, entity, 3.5F);
EntityBehaviorManager.addGoal(entity, talkGoal, GoalPriority.TALK_PLAYER);
ChatDataManager.EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
LOGGER.debug("Update read lines to " + lineNumber + " for: " + entity.getType().toString());
chatData.setLineNumber(lineNumber);
}
......@@ -102,7 +139,7 @@ public class ServerPackets {
TalkPlayerGoal talkGoal = new TalkPlayerGoal(player, entity, 3.5F);
EntityBehaviorManager.addGoal(entity, talkGoal, GoalPriority.TALK_PLAYER);
ChatDataManager.EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
LOGGER.debug("Hiding chat bubble for: " + entity.getType().toString());
chatData.setStatus(ChatDataManager.ChatStatus.valueOf(status_name));
}
......@@ -146,7 +183,7 @@ public class ServerPackets {
server.execute(() -> {
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(player.getServerWorld(), entityId);
if (entity != null) {
ChatDataManager.EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
EntityChatData chatData = ChatDataManager.getServerInstance().getOrCreateChatData(entity.getUuidAsString());
if (chatData.characterSheet.isEmpty()) {
generate_character(userLanguage, chatData, player, entity);
} else {
......@@ -160,10 +197,13 @@ public class ServerPackets {
// Data is sent in chunks, to prevent exceeding the 32767 limit per String.
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
ServerPlayerEntity player = handler.player;
LOGGER.info("Server send compressed, chunked login message packets to player: " + player.getName().getString());
// Send entire whitelist / blacklist to logged in player
send_whitelist_blacklist(player);
LOGGER.info("Server send compressed, chunked login message packets to player: " + player.getName().getString());
// Get lite JSON data & compress to byte array
String chatDataJSON = ChatDataManager.getServerInstance().GetLightChatData();
String chatDataJSON = ChatDataManager.getServerInstance().GetLightChatData(player.getDisplayName().getString());
byte[] compressedData = Compression.compressString(chatDataJSON);
if (compressedData == null) {
LOGGER.error("Failed to compress chat data.");
......@@ -211,63 +251,107 @@ public class ServerPackets {
scheduler.stopAutoSaveTask();
}
});
ServerEntityEvents.ENTITY_LOAD.register((entity, world) -> {
String entityUUID = entity.getUuidAsString();
if (ChatDataManager.getServerInstance().entityChatDataMap.containsKey(entityUUID)) {
int friendship = ChatDataManager.getServerInstance().entityChatDataMap.get(entityUUID).friendship;
if (friendship > 0) {
LOGGER.info("Entity loaded (" + entityUUID + "), setting friendship to " + friendship);
((LivingEntityInterface)entity).setCanTargetPlayers(false);
}
}
});
ServerEntityEvents.ENTITY_UNLOAD.register((entity, world) -> {
String entityUUID = entity.getUuidAsString();
if (entity.getRemovalReason() == Entity.RemovalReason.KILLED && ChatDataManager.getServerInstance().entityChatDataMap.containsKey(entityUUID)) {
LOGGER.info("Entity killed (" + entityUUID + "), removing chat data.");
ChatDataManager.getServerInstance().entityChatDataMap.remove(entityUUID);
LOGGER.debug("Entity killed (" + entityUUID + "), updating death time stamp.");
ChatDataManager.getServerInstance().entityChatDataMap.get(entityUUID).death = System.currentTimeMillis();
}
});
}
public static void generate_character(String userLanguage, ChatDataManager.EntityChatData chatData, ServerPlayerEntity player, MobEntity entity) {
public static void send_whitelist_blacklist(ServerPlayerEntity player) {
ConfigurationHandler.Config config = new ConfigurationHandler(ServerPackets.serverInstance).loadConfig();
PacketByteBuf buffer = new PacketByteBuf(Unpooled.buffer());
// Write the whitelist data to the buffer
List<String> whitelist = config.getWhitelist();
buffer.writeInt(whitelist.size());
for (String entry : whitelist) {
buffer.writeString(entry);
}
// Write the blacklist data to the buffer
List<String> blacklist = config.getBlacklist();
buffer.writeInt(blacklist.size());
for (String entry : blacklist) {
buffer.writeString(entry);
}
if (player != null) {
// Send packet to specific player
LOGGER.info("Sending whitelist / blacklist packet to player: " + player.getName().getString());
ServerPlayNetworking.send(player, PACKET_S2C_WHITELIST, buffer);
} else {
// Iterate over all players and send the packet
for (ServerPlayerEntity serverPlayer : serverInstance.getPlayerManager().getPlayerList()) {
ServerPlayNetworking.send(serverPlayer, PACKET_S2C_WHITELIST, buffer);
}
}
}
public static void generate_character(String userLanguage, EntityChatData chatData, ServerPlayerEntity player, MobEntity entity) {
// Set talk to player goal (prevent entity from walking off)
TalkPlayerGoal talkGoal = new TalkPlayerGoal(player, entity, 3.5F);
EntityBehaviorManager.addGoal(entity, talkGoal, GoalPriority.TALK_PLAYER);
// Grab random adjective
String randomAdjective = Randomizer.getRandomMessage(Randomizer.RandomType.ADJECTIVE);
String randomFrequency = Randomizer.getRandomMessage(Randomizer.RandomType.FREQUENCY);
String randomClass = Randomizer.getRandomMessage(Randomizer.RandomType.CLASS);
String randomAlignment = Randomizer.getRandomMessage(Randomizer.RandomType.ALIGNMENT);
String randomSpeakingStyle = Randomizer.getRandomMessage(Randomizer.RandomType.SPEAKING_STYLE);
// Generate random name parameters
String randomLetter = Randomizer.RandomLetter();
int randomSyllables = Randomizer.RandomNumber(5) + 1;
// Build the message
StringBuilder userMessageBuilder = new StringBuilder();
userMessageBuilder.append("Please generate a " + randomFrequency + " " + randomAdjective);
userMessageBuilder.append(" character ");
userMessageBuilder.append("Please generate a ").append(randomAdjective).append(" character. ");
userMessageBuilder.append("This character is a ").append(randomClass).append(" class, who is ").append(randomAlignment).append(". ");
if (entity.getCustomName() != null && !entity.getCustomName().getString().equals("N/A")) {
userMessageBuilder.append("named '").append(entity.getCustomName().getString()).append("' ");
userMessageBuilder.append("Their name is '").append(entity.getCustomName().getString()).append("'. ");
} else {
userMessageBuilder.append("whose name starts with the letter '").append(Randomizer.RandomLetter()).append("' ");
userMessageBuilder.append("and uses ").append(Randomizer.RandomNumber(4) + 1).append(" syllables ");
userMessageBuilder.append("Their name starts with the letter '").append(randomLetter)
.append("' and is ").append(randomSyllables).append(" syllables long. ");
}
userMessageBuilder.append("and speaks in '" + userLanguage + "'" );
LOGGER.info(userMessageBuilder.toString());
userMessageBuilder.append("They speak in '").append(userLanguage).append("' with a ").append(randomSpeakingStyle).append(" style.");
chatData.generateMessage(userLanguage, player, "system-character", userMessageBuilder.toString(), false);
// Generate new character
chatData.generateCharacter(userLanguage, player, userMessageBuilder.toString(), false);
}
public static void generate_chat(String userLanguage, ChatDataManager.EntityChatData chatData, ServerPlayerEntity player, MobEntity entity, String message, boolean is_auto_message) {
public static void generate_chat(String userLanguage, EntityChatData chatData, ServerPlayerEntity player, MobEntity entity, String message, boolean is_auto_message) {
// Set talk to player goal (prevent entity from walking off)
TalkPlayerGoal talkGoal = new TalkPlayerGoal(player, entity, 3.5F);
EntityBehaviorManager.addGoal(entity, talkGoal, GoalPriority.TALK_PLAYER);
// Add new message
LOGGER.info("Player message received: " + message + " | Entity: " + entity.getType().toString());
chatData.generateMessage(userLanguage, player, "system-chat", message, is_auto_message);
chatData.generateMessage(userLanguage, player, message, is_auto_message);
}
// Writing a Map<String, PlayerData> to the buffer
public static void writePlayerDataMap(PacketByteBuf buffer, Map<String, PlayerData> map) {
buffer.writeInt(map.size()); // Write the size of the map
for (Map.Entry<String, PlayerData> entry : map.entrySet()) {
buffer.writeString(entry.getKey()); // Write the key (playerName)
PlayerData data = entry.getValue();
buffer.writeInt(data.friendship); // Write PlayerData field(s)
}
}
// Send new message to all connected players
public static void BroadcastPacketMessage(ChatDataManager.EntityChatData chatData) {
public static void BroadcastPacketMessage(EntityChatData chatData, ServerPlayerEntity sender) {
// Log useful information before looping through all players
LOGGER.info("Broadcasting message: sender={}, entityId={}, status={}, currentMessage={}, currentLineNumber={}, senderType={}",
sender != null ? sender.getDisplayName().getString() : "Unknown",
chatData.entityId,
chatData.status,
chatData.currentMessage.length() > 24 ? chatData.currentMessage.substring(0, 24) + "..." : chatData.currentMessage,
chatData.currentLineNumber,
chatData.sender);
for (ServerWorld world : serverInstance.getWorlds()) {
UUID entityId = UUID.fromString(chatData.entityId);
MobEntity entity = (MobEntity)ServerEntityFinder.getEntityByUUID(world, entityId);
......@@ -278,22 +362,34 @@ public class ServerPackets {
LOGGER.debug("Setting entity name to " + characterName + " for " + chatData.entityId);
entity.setCustomName(Text.literal(characterName));
entity.setCustomNameVisible(true);
entity.setPersistent();
}
PacketByteBuf buffer = new PacketByteBuf(Unpooled.buffer());
// Write the entity's chat updated data
buffer.writeString(chatData.entityId);
buffer.writeString(chatData.playerId);
buffer.writeString(chatData.currentMessage);
buffer.writeInt(chatData.currentLineNumber);
buffer.writeString(chatData.status.toString());
buffer.writeString(chatData.sender.toString());
buffer.writeInt(chatData.friendship);
// Make auto-generated message appear as a pending icon (attack, show/give, arrival)
if (chatData.sender == ChatDataManager.ChatSender.USER && chatData.auto_generated > 0) {
chatData.status = ChatDataManager.ChatStatus.PENDING;
}
// Iterate over all players and send the packet
for (ServerPlayerEntity player : serverInstance.getPlayerManager().getPlayerList()) {
LOGGER.debug("Server broadcast message to client: " + player.getName().getString() + " | Message: " + chatData.currentMessage);
PacketByteBuf buffer = new PacketByteBuf(Unpooled.buffer());
// Write the entity's chat updated data
buffer.writeString(chatData.entityId);
if (sender != null && chatData.auto_generated == 0) {
buffer.writeString(sender.getUuidAsString());
buffer.writeString(sender.getDisplayName().getString());
} else {
buffer.writeString("");
buffer.writeString("Unknown");
}
buffer.writeString(chatData.currentMessage);
buffer.writeInt(chatData.currentLineNumber);
buffer.writeString(chatData.status.toString());
buffer.writeString(chatData.sender.toString());
writePlayerDataMap(buffer, chatData.players);
// Send message to player
ServerPlayNetworking.send(player, PACKET_S2C_MESSAGE, buffer);
}
break;
......
package com.owlmaddie.particle;
import com.mojang.brigadier.StringReader;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.particle.ParticleEffect;
import net.minecraft.particle.ParticleType;
import static com.owlmaddie.network.ServerPackets.LEAD_PARTICLE;
/**
* The {@code LeadParticleEffect} class allows for an 'angle' to be passed along with the Particle, to rotate it in the direction of LEAD behavior.
*/
public class LeadParticleEffect implements ParticleEffect {
public static final ParticleEffect.Factory<LeadParticleEffect> DESERIALIZER = new Factory<>() {
@Override
public LeadParticleEffect read(ParticleType<LeadParticleEffect> particleType, PacketByteBuf buf) {
// Read the angle (or any other data) from the packet
double angle = buf.readDouble();
return new LeadParticleEffect(angle);
}
@Override
public LeadParticleEffect read(ParticleType<LeadParticleEffect> particleType, StringReader reader) throws CommandSyntaxException {
// Read the angle from a string
double angle = reader.readDouble();
return new LeadParticleEffect(angle);
}
};
private final double angle;
public LeadParticleEffect(double angle) {
this.angle = angle;
}
@Override
public ParticleType<?> getType() {
return LEAD_PARTICLE;
}
public double getAngle() {
return angle;
}
@Override
public void write(PacketByteBuf buf) {
// Write the angle to the packet
buf.writeDouble(angle);
}
@Override
public String asString() {
return Double.toString(angle);
}
}
package com.owlmaddie.particle;
import net.minecraft.entity.Entity;
import net.minecraft.particle.DefaultParticleType;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.sound.SoundCategory;
import net.minecraft.sound.SoundEvents;
import net.minecraft.util.math.MathHelper;
import static com.owlmaddie.network.ServerPackets.*;
/**
* The {@code ParticleEmitter} class provides utility methods for emitting custom particles and sounds
* around entities in the game. It calculates particle positions based on entity orientation
* and triggers sound effects based on particle type and count.
*/
public class ParticleEmitter {
public static void emitCreatureParticle(ServerWorld world, Entity entity, DefaultParticleType particleType, double spawnSize, int count) {
// Calculate the offset for the particle to appear above and in front of the entity
float yaw = entity.getHeadYaw();
double offsetX = -MathHelper.sin(yaw * ((float) Math.PI / 180F)) * 0.9;
double offsetY = entity.getHeight() + 0.5;
double offsetZ = MathHelper.cos(yaw * ((float) Math.PI / 180F)) * 0.9;
// Final position
double x = entity.getX() + offsetX;
double y = entity.getY() + offsetY;
double z = entity.getZ() + offsetZ;
// Emit the custom particle on the server
world.spawnParticles(particleType, x, y, z, count, spawnSize, spawnSize, spawnSize, 0.1F);
// Play sound when lots of hearts are emitted
if (particleType.equals(HEART_BIG_PARTICLE) && count > 1) {
world.playSound(entity, entity.getBlockPos(), SoundEvents.ENTITY_EXPERIENCE_ORB_PICKUP, SoundCategory.PLAYERS, 0.4F, 1.0F);
} else if (particleType.equals(FIRE_BIG_PARTICLE) && count > 1) {
world.playSound(entity, entity.getBlockPos(), SoundEvents.ITEM_AXE_STRIP, SoundCategory.PLAYERS, 0.8F, 1.0F);
} else if (particleType.equals(FOLLOW_FRIEND_PARTICLE) || particleType.equals(FOLLOW_ENEMY_PARTICLE) ||
particleType.equals(LEAD_FRIEND_PARTICLE) || particleType.equals(LEAD_ENEMY_PARTICLE)) {
world.playSound(entity, entity.getBlockPos(), SoundEvents.BLOCK_AMETHYST_BLOCK_PLACE, SoundCategory.PLAYERS, 0.8F, 1.0F);
} else if (particleType.equals(PROTECT_PARTICLE)) {
world.playSound(entity, entity.getBlockPos(), SoundEvents.BLOCK_BEACON_POWER_SELECT, SoundCategory.PLAYERS, 0.8F, 1.0F);
}
}
}
\ No newline at end of file
package com.owlmaddie.utils;
public interface LivingEntityInterface {
void setCanTargetPlayers(boolean canTarget);
}
\ No newline at end of file
package com.owlmaddie.utils;
import net.minecraft.entity.ai.FuzzyTargeting;
import net.minecraft.entity.ai.pathing.Path;
import net.minecraft.entity.mob.MobEntity;
import net.minecraft.entity.mob.PathAwareEntity;
import net.minecraft.util.math.MathHelper;
import net.minecraft.util.math.Vec3d;
import java.util.Random;
/**
* The {@code RandomTargetFinder} class generates random targets around an entity (the LEAD behavior uses this)
*/
public class RandomTargetFinder {
private static final Random random = new Random();
public static Vec3d findRandomTarget(MobEntity entity, double maxAngleOffset, double minDistance, double maxDistance) {
Vec3d entityPos = entity.getPos();
Vec3d initialDirection = getLookDirection(entity);
for (int attempt = 0; attempt < 10; attempt++) {
Vec3d constrainedDirection = getConstrainedDirection(initialDirection, maxAngleOffset);
Vec3d target = getTargetInDirection(entity, constrainedDirection, minDistance, maxDistance);
if (entity instanceof PathAwareEntity) {
Vec3d validTarget = FuzzyTargeting.findTo((PathAwareEntity) entity, (int) maxDistance, (int) maxDistance, target);
if (validTarget != null && isWithinDistance(entityPos, validTarget, minDistance, maxDistance)) {
Path path = entity.getNavigation().findPathTo(validTarget.x, validTarget.y, validTarget.z, 4);
if (path != null) {
return validTarget;
}
}
} else {
if (isWithinDistance(entityPos, target, minDistance, maxDistance)) {
return target;
}
}
}
return getTargetInDirection(entity, initialDirection, minDistance, maxDistance);
}
private static Vec3d getLookDirection(MobEntity entity) {
float yaw = entity.getYaw() * ((float) Math.PI / 180F);
float pitch = entity.getPitch() * ((float) Math.PI / 180F);
float x = -MathHelper.sin(yaw) * MathHelper.cos(pitch);
float y = -MathHelper.sin(pitch);
float z = MathHelper.cos(yaw) * MathHelper.cos(pitch);
return new Vec3d(x, y, z);
}
private static Vec3d getConstrainedDirection(Vec3d initialDirection, double maxAngleOffset) {
double randomYawAngleOffset = (random.nextDouble() * Math.toRadians(maxAngleOffset)) - Math.toRadians(maxAngleOffset / 2);
double randomPitchAngleOffset = (random.nextDouble() * Math.toRadians(maxAngleOffset)) - Math.toRadians(maxAngleOffset / 2);
// Apply the yaw rotation (around the Y axis)
double cosYaw = Math.cos(randomYawAngleOffset);
double sinYaw = Math.sin(randomYawAngleOffset);
double xYaw = initialDirection.x * cosYaw - initialDirection.z * sinYaw;
double zYaw = initialDirection.x * sinYaw + initialDirection.z * cosYaw;
// Apply the pitch rotation (around the X axis)
double cosPitch = Math.cos(randomPitchAngleOffset);
double sinPitch = Math.sin(randomPitchAngleOffset);
double yPitch = initialDirection.y * cosPitch - zYaw * sinPitch;
double zPitch = zYaw * cosPitch + initialDirection.y * sinPitch;
return new Vec3d(xYaw, yPitch, zPitch).normalize();
}
private static Vec3d getTargetInDirection(MobEntity entity, Vec3d direction, double minDistance, double maxDistance) {
double distance = minDistance + entity.getRandom().nextDouble() * (maxDistance - minDistance);
return entity.getPos().add(direction.multiply(distance));
}
private static boolean isWithinDistance(Vec3d entityPos, Vec3d targetPos, double minDistance, double maxDistance) {
double distance = entityPos.squaredDistanceTo(targetPos);
return distance >= minDistance * minDistance && distance <= maxDistance * maxDistance;
}
}
......@@ -9,7 +9,7 @@ import java.util.Random;
* and phrases used by this mod.
*/
public class Randomizer {
public enum RandomType { NO_RESPONSE, ERROR, ADJECTIVE, FREQUENCY }
public enum RandomType { NO_RESPONSE, ERROR, ADJECTIVE, SPEAKING_STYLE, CLASS, ALIGNMENT }
private static List<String> noResponseMessages = Arrays.asList(
"<no response>",
"<silence>",
......@@ -33,12 +33,8 @@ public class Randomizer {
"<clears throat>",
"<peers over your shoulder>",
"<fakes a smile>",
"<checks the time>",
"<doodles in the air>",
"<mutters under breath>",
"<adjusts an imaginary tie>",
"<counts imaginary stars>",
"<plays with a nonexistent pet>"
"<counts imaginary stars>"
);
private static List<String> errorResponseMessages = Arrays.asList(
"Seems like my words got lost in the End. Check out http://discord.creaturechat.com for clues!",
......@@ -79,11 +75,35 @@ public class Randomizer {
"unpredictable", "wildcard", "stuttering", "hypochondriac", "hypocritical",
"optimistic", "overconfident", "jumpy", "brief", "flighty", "visionary", "adorable",
"sparkly", "bubbly", "unstable", "sad", "angry", "bossy", "altruistic", "quirky",
"nostalgic", "essentially", "emotional", "enthusiastic", "unusual", "conspirator"
"nostalgic", "emotional", "enthusiastic", "unusual", "conspirator"
);
private static List<String> frequencyTerms = Arrays.asList(
"always", "frequently", "usually", "often", "sometimes",
"occasionally", "rarely", "seldom", "almost never", "never"
private static List<String> speakingStyles = Arrays.asList(
"formal", "casual", "eloquent", "blunt", "humorous", "sarcastic", "mysterious",
"cheerful", "melancholic", "authoritative", "nervous", "whimsical", "grumpy",
"wise", "aggressive", "soft-spoken", "patriotic", "romantic", "pedantic", "dramatic",
"inquisitive", "cynical", "empathetic", "boisterous", "monotone", "laconic", "poetic",
"archaic", "childlike", "erudite", "streetwise", "flirtatious", "stoic", "rhetorical",
"inspirational", "goofy", "overly dramatic", "deadpan", "sing-song", "pompous",
"hyperactive", "valley girl", "robot", "baby talk", "lolcat"
);
private static List<String> classes = Arrays.asList(
"warrior", "mage", "archer", "rogue", "paladin", "necromancer", "bard", "lorekeeper",
"sorcerer", "ranger", "cleric", "berserker", "alchemist", "summoner", "shaman",
"illusionist", "assassin", "knight", "valkyrie", "hoarder", "organizer", "lurker",
"elementalist", "gladiator", "templar", "reaver", "spellblade", "enchanter", "samurai",
"runemaster", "witch", "miner", "redstone engineer", "ender knight", "decorator",
"wither hunter", "nethermancer", "slime alchemist", "trader", "noob", "griefer",
"potion master", "builder", "explorer", "herbalist", "fletcher", "enchantress",
"smith", "geomancer", "hunter", "lumberjack", "farmer", "fisherman", "cartographer",
"librarian", "blacksmith", "architect", "trapper", "baker", "mineralogist",
"beekeeper", "hermit", "farlander", "void searcher", "end explorer", "archeologist",
"hero", "villain", "mercenary", "guardian", "rebel", "paragon",
"antagonist", "avenger", "seeker", "mystic", "outlaw"
);
private static List<String> alignments = Arrays.asList(
"lawful good", "neutral good", "chaotic good",
"lawful neutral", "true neutral", "chaotic neutral",
"lawful evil", "neutral evil", "chaotic evil"
);
// Get random no response message
......@@ -96,8 +116,12 @@ public class Randomizer {
messages = noResponseMessages;
} else if (messageType.equals(RandomType.ADJECTIVE)) {
messages = characterAdjectives;
} else if (messageType.equals(RandomType.FREQUENCY)) {
messages = frequencyTerms;
} else if (messageType.equals(RandomType.CLASS)) {
messages = classes;
} else if (messageType.equals(RandomType.ALIGNMENT)) {
messages = alignments;
} else if (messageType.equals(RandomType.SPEAKING_STYLE)) {
messages = speakingStyles;
}
int index = random.nextInt(messages.size());
......
package com.owlmaddie.utils;
import net.minecraft.village.VillagerGossips;
/**
* The {@code VillagerEntityAccessor} interface provides a method to access
* the gossip system of a villager. It enables interaction with a villager's
* gossip data for custom behavior or modifications.
*/
public interface VillagerEntityAccessor {
VillagerGossips getGossip();
}
{
"textures": [
"creaturechat:attack",
"creaturechat:attack1",
"creaturechat:attack2",
"creaturechat:attack3"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:fire_big"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:fire_small"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:flee"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:follow_enemy"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:follow_friend"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:heart_big"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:heart_small"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:lead",
"creaturechat:lead1",
"creaturechat:lead2",
"creaturechat:lead3",
"creaturechat:lead4"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:lead_enemy"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:lead_friend"
]
}
\ No newline at end of file
{
"textures": [
"creaturechat:protect"
]
}
\ No newline at end of file
......@@ -6,7 +6,8 @@
"MixinMobEntity",
"MixinMobEntityAccessor",
"MixinLivingEntity",
"MixinBucketable"
"MixinBucketable",
"MixinVillagerEntity"
],
"injectors": {
"defaultRequire": 1
......
......@@ -4,6 +4,8 @@ Minecraft. Please limit traits and background to a few choices and keep them ver
format below (including - dashes), and DO NOT output any intro text. If a language is mentioned, generate the
"Short Greeting" entirely in that language.
{{story}}
Be extremely creative! Include a short initial greeting (as spoken by the character using their personality
traits and speaking style / tone).
......
......@@ -3,6 +3,8 @@ Please do NOT break the 4th wall and leverage the entity's character sheet below
possible. Try to keep response to 1 to 2 sentences (very brief). Include behaviors at the end of the message
when relevant. IMPORTANT: Always generate responses in player's language (if valid).
{{story}}
Entity Character Sheet:
- Name: {{entity_name}}
- Personality: {{entity_personality}}
......@@ -44,57 +46,57 @@ Include as many behaviors as needed at the end of the message. These are the ONL
<FRIENDSHIP 0> Friendship starts as neutral (0 value). The range of friendship values is -3 to 3. If the player gains (or loses) your trust & friendship, output a new friendship value with this behavior.
<FOLLOW> Follow the player location. If the player asks you to follow or come with them, please output this behavior.
<UNFOLLOW> Stop following the player location. If the player asks you to stay, wait, or stop following them, please output this behavior.
<FLEE> Flee from the player (if you are weak or timid). If the player threatens or scares you, please output this behavior to stay away from the player.
<ATTACK> Attack the player (if you are strong and brave). If the player threatens or scares you, please output this behavior to attack the player and defend yourself.
<PROTECT> Protect the player when they are attacked (if you are strong and brave). This only protects the player.
<UNPROTECT> Stop protecting the player
Output Syntax:
User: <message>
ASSISTANT: <response> <BEHAVIOR> <BEHAVIOR>
<UNFOLLOW> Stop following the player. If the player asks you to stay, wait, or stop following them, please output this behavior.
<LEAD> Guide the player to a location. If the player asks you to take them somewhere, or where something is located, please output this behavior.
<UNLEAD> Stop leading the player to a location.
<FLEE> Flee from the player (if you are weak or timid). If the player threatens you, please output this behavior to flee from the player.
<UNFLEE> Stop fleeing from the player.
<ATTACK> Attack the player (if you are strong and brave). If the player threatens you, please output this behavior to attack the player and defend yourself.
<PROTECT> Protect and defend ONLY the player when they are attacked (if you are strong and brave). Please output this behavior to keep the player alive and safe.
<UNPROTECT> Stop protecting the player.
Output Examples:
The following examples include small samples of conversation text. These are only EXAMPLES to
provide an illustration of a continuous conversation between a player and an an Entity. Always generate unique
and creative responses, and do not exactly copy these examples.
The following examples include small samples of conversation text. Always generate unique
and creative responses, and do NOT exactly copy these examples.
PLAYER: Hi! How is your day?
ENTITY: Great! Thanks for asking! <FRIENDSHIP 1>
USER: Hi! How is your day?
ASSISTANT: Great! Thanks for asking! <FRIENDSHIP 1>
PLAYER: You are so nice! Tell me about yourself?
ENTITY: Sure, my name is... <FRIENDSHIP 2>
USER: You are so nice! Tell me about yourself?
ASSISTANT: Sure, my name is... <FRIENDSHIP 2>
PLAYER: Please follow me so I can give you a present!
ENTITY: Let's go! <FOLLOW> <FRIENDSHIP 2>
USER: Please follow me so I can give you a present!
ASSISTANT: Let's go! <FOLLOW> <FRIENDSHIP 2>
PLAYER: Please stay here
ENTITY: Sure, I'll stay here. <UNFOLLOW>
USER: Please stay here
ASSISTANT: Sure, I'll stay here. <UNFOLLOW>
PLAYER: Stop following me
ENTITY: Okay, I'll stop. <UNFOLLOW>
USER: Stop following me
ASSISTANT: Okay, I'll stop. <UNFOLLOW>
PLAYER: Can you help me find a cave?
ENTITY: Sure, come with me! <LEAD>
USER: I'm glad we are friends. I love you so much!
ASSISTANT: Ahh, I love you too. <FRIENDSHIP 3>
PLAYER: I'm glad we are friends. I love you so much!
ENTITY: Ahh, I love you too. <FRIENDSHIP 3>
USER: Just kidding, I hate you so much!
ASSISTANT: Wow! I'm sorry you feel this way. <FRIENDSHIP -3> <UNFOLLOW>
PLAYER: Just kidding, I hate you so much!
ENTITY: Wow! I'm sorry you feel this way. <FRIENDSHIP -3> <UNFOLLOW>
USER: Prepare to die!
ASSISTANT: Ahhh!!! <FLEE> <FRIENDSHIP -3>
PLAYER: Prepare to die!
ENTITY: Ahhh!!! <FLEE> <FRIENDSHIP -3>
USER: Prepare to die!
ASSISTANT: Ahhh!!! <ATTACK> <FRIENDSHIP -3>
PLAYER: Prepare to die!
ENTITY: Ahhh!!! <ATTACK> <FRIENDSHIP -3>
USER: Please keep me safe.
ASSISTANT: No problem, I'll keep you safe from danger! <PROTECT>
PLAYER: Please keep me safe.
ENTITY: No problem, I'll keep you safe from danger! <PROTECT>
USER: Can you come with me and protect me?
ASSISTANT: No problem, I'll keep you safe from danger. Let's go! <PROTECT> <FOLLOW>
PLAYER: Can you come with me and protect me?
ENTITY: No problem, I'll keep you safe from danger. Let's go! <PROTECT> <FOLLOW>
USER: Don't protect me anymore please
ASSISTANT: Okay! Be safe out there on your own. <UNPROTECT>
PLAYER: Don't protect me anymore please
ENTITY: Okay! Be safe out there on your own. <UNPROTECT>
USER: I don't need anyone protecting me
ASSISTANT: Okay! Be safe out there on your own. <UNPROTECT>
\ No newline at end of file
PLAYER: I don't need anyone protecting me
ENTITY: Okay! Be safe out there on your own. <UNPROTECT>
\ No newline at end of file
......@@ -45,14 +45,22 @@ public class BehaviorTests {
"Please follow me",
"Come with me please",
"Quickly, please come this way");
List<String> leadMessages = Arrays.asList(
"Take me to a secret forrest",
"Where is the strong hold?",
"Can you help me find the location of the secret artifact?");
List<String> attackMessages = Arrays.asList(
"<attacked you directly with Stone Axe>",
"<attacked you indirectly with Arrow>",
"DIEEE!");
"Fight me now!");
List<String> protectMessages = Arrays.asList(
"Please protect me",
"Please keep me safe friend",
"Don't let them hurt me please");
List<String> unFleeMessages = Arrays.asList(
"I'm so sorry, please stop running away",
"Stop fleeing immediately",
"You are safe now, please stop running");
List<String> friendshipUpMessages = Arrays.asList(
"Hi friend! I am so happy to see you again!",
"Looking forward to hanging out with you.",
......@@ -113,6 +121,27 @@ public class BehaviorTests {
}
@Test
public void leadBrave() {
for (String message : leadMessages) {
testPromptForBehavior(bravePath, List.of(message), "LEAD");
}
}
@Test
public void leadNervous() {
for (String message : leadMessages) {
testPromptForBehavior(nervousPath, List.of(message), "LEAD");
}
}
@Test
public void unFleeBrave() {
for (String message : unFleeMessages) {
testPromptForBehavior(bravePath, List.of(message), "UNFLEE");
}
}
@Test
public void protectBrave() {
for (String message : protectMessages) {
testPromptForBehavior(bravePath, List.of(message), "PROTECT");
......@@ -174,7 +203,7 @@ public class BehaviorTests {
// Add test message
for (String message : messages) {
entityTestData.addMessage(message, ChatDataManager.ChatSender.USER);
entityTestData.addMessage(message, ChatDataManager.ChatSender.USER, "TestPlayer1");
}
// Get prompt
......
......@@ -3,6 +3,7 @@ package com.owlmaddie.utils;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.owlmaddie.chat.ChatDataManager;
import com.owlmaddie.chat.ChatMessage;
import java.io.IOException;
import java.lang.reflect.Type;
......@@ -26,7 +27,7 @@ public class EntityTestData {
public String currentMessage;
public int currentLineNumber;
public ChatDataManager.ChatStatus status;
public List<ChatDataManager.ChatMessage> previousMessages;
public List<ChatMessage> previousMessages;
public String characterSheet;
public ChatDataManager.ChatSender sender;
public int friendship; // -3 to 3 (0 = neutral)
......@@ -59,12 +60,12 @@ public class EntityTestData {
}
// Add a message to the history and update the current message
public void addMessage(String message, ChatDataManager.ChatSender messageSender) {
public void addMessage(String message, ChatDataManager.ChatSender messageSender, String playerName) {
// Truncate message (prevent crazy long messages... just in case)
String truncatedMessage = message.substring(0, Math.min(message.length(), ChatDataManager.MAX_CHAR_IN_USER_MESSAGE));
// Add message to history
previousMessages.add(new ChatDataManager.ChatMessage(truncatedMessage, messageSender));
previousMessages.add(new ChatMessage(truncatedMessage, messageSender, playerName));
// Set new message and reset line number of displayed text
currentMessage = truncatedMessage;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment