Clean repo for open source release

Remove build artifacts, dev tool configs, unused dependencies,
and third-party source dumps. Add proper README, update .gitignore,
clean up Makefile.
This commit is contained in:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -0,0 +1,465 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.dialogue.EntityDialogueManager.DialogueCategory;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.entities.EntityKidnapperMerchant;
import com.tiedup.remake.entities.KidnapperTheme;
import com.tiedup.remake.personality.PersonalityState;
import com.tiedup.remake.personality.PersonalityType;
import com.tiedup.remake.util.MessageDispatcher;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
/**
* Bridge between the legacy EntityDialogueManager and the new DialogueManager.
*
* Provides methods to:
* - Build DialogueContext from EntityDamsel state
* - Build DialogueContext from IDialogueSpeaker (universal)
* - Map DialogueCategory to dialogue IDs
* - Try data-driven dialogues first, fallback to hardcoded
*
* Personality System: Data-driven dialogue
* Universal NPC Support: Extended for all speaker types
*/
public class DialogueBridge {
/**
* Build a DialogueContext from an EntityDamsel.
*
* @param entity The entity to build context from
* @param player The player interacting (can be null)
* @return DialogueContext with all available state
*/
public static DialogueContext buildContext(
EntityDamsel entity,
@Nullable Player player
) {
DialogueContext.Builder builder = DialogueContext.builder()
.npcName(entity.getNpcName())
.bound(entity.isTiedUp())
.gagged(entity.isGagged())
.blindfold(entity.isBlindfolded())
.hasCollar(entity.hasCollar());
// Add player info if available
if (player != null) {
builder.playerName(player.getName().getString());
}
// Add personality state if available
PersonalityState state = entity.getPersonalityState();
if (state != null) {
builder
.personality(state.getPersonality())
.mood((int) state.getMood())
.hunger(state.getNeeds().getHunger());
// Get master name if collared (commanding player is the master)
if (
entity.hasCollar() &&
state.getCommandingPlayer() != null &&
player != null
) {
// If the interacting player is the commanding player (master)
if (player.getUUID().equals(state.getCommandingPlayer())) {
builder.masterName(player.getName().getString());
}
}
} else {
// Default values if no personality state
builder.personality(PersonalityType.CALM).mood(50);
}
return builder.build();
}
/**
* Map a DialogueCategory to a dialogue ID.
*
* @param category The legacy category
* @return Dialogue ID for the new system
*/
public static String categoryToDialogueId(DialogueCategory category) {
return switch (category) {
// Capture sequence
case CAPTURE_START -> "capture.start";
case CAPTURE_APPROACHING -> "capture.approaching";
case CAPTURE_CHASE -> "capture.chase";
case CAPTURE_TYING -> "capture.tying";
case CAPTURE_TIED -> "capture.tied";
case CAPTURE_GAGGING -> "capture.gagging";
case CAPTURE_GAGGED -> "capture.gagged";
case CAPTURE_ENSLAVED -> "capture.enslaved";
case CAPTURE_ESCAPE -> "capture.escape";
// Damsel specific
case DAMSEL_PANIC -> "capture.panic";
case DAMSEL_FLEE -> "capture.flee";
case DAMSEL_CAPTURED -> "capture.captured";
case DAMSEL_FREED -> "capture.freed";
case DAMSEL_GREETING -> "idle.greeting";
case DAMSEL_IDLE -> "idle.free";
case DAMSEL_CALL_FOR_HELP -> "capture.call_for_help";
// Slave management
case SLAVE_TALK_RESPONSE -> "idle.slave_talk";
case SLAVE_STRUGGLE -> "struggle.warned";
case SLAVE_TRANSPORT -> "idle.transport";
case SLAVE_ARRIVE_PRISON -> "idle.arrive_prison";
case SLAVE_TIED_TO_POLE -> "idle.tied_to_pole";
case PUNISH -> "idle.punish";
// Sale system
case SALE_WAITING -> "idle.sale_waiting";
case SALE_ANNOUNCE -> "idle.sale_announce";
case SALE_OFFER -> "idle.sale_offer";
case SALE_COMPLETE -> "idle.sale_complete";
case SALE_ABANDONED -> "idle.sale_abandoned";
case SALE_KEPT -> "idle.sale_kept";
// Job system
case JOB_ASSIGNED -> "jobs.assigned";
case JOB_HURRY -> "jobs.hurry";
case JOB_COMPLETE -> "jobs.complete";
case JOB_FAILED -> "jobs.failed";
case JOB_LAST_CHANCE -> "jobs.last_chance";
case JOB_KILL -> "jobs.kill";
// Combat
case ATTACKED_RESPONSE -> "combat.attacked_response";
case ATTACK_SLAVE -> "combat.attack_slave";
case GENERIC_THREAT -> "combat.threat";
case GENERIC_TAUNT -> "combat.taunt";
// General
case FREED -> "idle.freed_captor";
case GOODBYE -> "idle.goodbye";
case GET_OUT -> "idle.get_out";
// Personality system commands
case COMMAND_ACCEPT -> "command.generic.accept";
case COMMAND_REFUSE -> "command.generic.refuse";
case COMMAND_HESITATE -> "command.generic.hesitate";
// Needs
case NEED_HUNGRY -> "needs.hungry";
case NEED_TIRED -> "needs.tired";
case NEED_UNCOMFORTABLE -> "needs.uncomfortable";
case NEED_DIGNITY_LOW -> "needs.dignity_low";
// Personality hints
case PERSONALITY_HINT_TIMID -> "personality.hint";
case PERSONALITY_HINT_GENTLE -> "personality.hint";
case PERSONALITY_HINT_SUBMISSIVE -> "personality.hint";
case PERSONALITY_HINT_CALM -> "personality.hint";
case PERSONALITY_HINT_CURIOUS -> "personality.hint";
case PERSONALITY_HINT_PROUD -> "personality.hint";
case PERSONALITY_HINT_FIERCE -> "personality.hint";
case PERSONALITY_HINT_DEFIANT -> "personality.hint";
case PERSONALITY_HINT_PLAYFUL -> "personality.hint";
case PERSONALITY_HINT_MASOCHIST -> "personality.hint";
case PERSONALITY_HINT_SADIST -> "personality.hint";
// Discipline responses (Training V2)
case PRAISE_RESPONSE -> "discipline.praise";
case SCOLD_RESPONSE -> "discipline.scold";
case THREATEN_RESPONSE -> "discipline.threaten";
};
}
/**
* Get dialogue from the data-driven system.
*
* @param entity The entity speaking
* @param player The player (can be null)
* @param category The dialogue category
* @return Dialogue text, or null if not found
*/
@Nullable
public static String getDataDrivenDialogue(
EntityDamsel entity,
@Nullable Player player,
DialogueCategory category
) {
if (!DialogueManager.isInitialized()) {
return null;
}
DialogueContext context = buildContext(entity, player);
String dialogueId = categoryToDialogueId(category);
return DialogueManager.getDialogue(dialogueId, context);
}
/**
* Get command dialogue from the data-driven system.
*
* @param entity The entity
* @param player The player
* @param commandName The command name (e.g., "follow", "stay")
* @param accepted Whether the command was accepted
* @param hesitated Whether the NPC hesitated
* @return Dialogue text, or null if not found
*/
@Nullable
public static String getCommandDialogue(
EntityDamsel entity,
@Nullable Player player,
String commandName,
boolean accepted,
boolean hesitated
) {
if (!DialogueManager.isInitialized()) {
return null;
}
DialogueContext context = buildContext(entity, player);
String suffix;
if (!accepted) {
suffix = ".refuse";
} else if (hesitated) {
suffix = ".hesitate";
} else {
suffix = ".accept";
}
String dialogueId = "command." + commandName.toLowerCase() + suffix;
return DialogueManager.getDialogue(dialogueId, context);
}
// ===========================================
// UNIVERSAL SPEAKER METHODS (IDialogueSpeaker)
// ===========================================
/**
* Build a DialogueContext from any IDialogueSpeaker.
* Automatically detects speaker type and builds appropriate context.
*
* @param speaker The dialogue speaker
* @param player The player interacting (can be null)
* @return DialogueContext with all available state
*/
public static DialogueContext buildContext(
IDialogueSpeaker speaker,
@Nullable Player player
) {
DialogueContext.Builder builder = DialogueContext.builder().fromSpeaker(
speaker,
player
);
// Add speaker-type specific context
LivingEntity entity = speaker.asEntity();
// Kidnapper-specific: add theme
if (entity instanceof EntityKidnapper kidnapper) {
KidnapperTheme theme = kidnapper.getTheme();
if (theme != null) {
builder.kidnapperTheme(theme.name());
}
}
// Merchant-specific: add mode
if (entity instanceof EntityKidnapperMerchant merchant) {
builder.merchantMode(merchant.isHostile() ? "HOSTILE" : "MERCHANT");
}
// Damsel-specific: add bondage state and personality state
if (entity instanceof EntityDamsel damsel) {
builder
.bound(damsel.isTiedUp())
.gagged(damsel.isGagged())
.blindfold(damsel.isBlindfolded())
.hasCollar(damsel.hasCollar());
// Add needs from personality state
PersonalityState state = damsel.getPersonalityState();
if (state != null) {
builder.hunger(state.getNeeds().getHunger());
// Get master name if collared
if (
damsel.hasCollar() &&
state.getCommandingPlayer() != null &&
player != null &&
player.getUUID().equals(state.getCommandingPlayer())
) {
builder.masterName(player.getName().getString());
}
}
}
return builder.build();
}
/**
* Get dialogue for a speaker and dialogue ID.
*
* @param speaker The dialogue speaker
* @param player The player (can be null)
* @param dialogueId The dialogue ID (e.g., "guard.watching")
* @return Dialogue text, or null if not found
*/
@Nullable
public static String getDialogue(
IDialogueSpeaker speaker,
@Nullable Player player,
String dialogueId
) {
if (!DialogueManager.isInitialized()) {
return null;
}
DialogueContext context = buildContext(speaker, player);
return DialogueManager.getDialogue(dialogueId, context);
}
/**
* Send dialogue from a speaker to a player.
* Handles dialogue lookup, variable substitution, and message dispatch.
*
* @param speaker The dialogue speaker
* @param player The player to send to
* @param dialogueId The dialogue ID
* @return true if dialogue was sent, false if not found or on cooldown
*/
public static boolean talkTo(
IDialogueSpeaker speaker,
Player player,
String dialogueId
) {
// Check cooldown
if (speaker.getDialogueCooldown() > 0) {
return false;
}
String dialogue = getDialogue(speaker, player, dialogueId);
if (dialogue == null || dialogue.isEmpty()) {
return false;
}
// Format and send message
broadcastDialogue(speaker.asEntity(), player, dialogue, false);
// Set default cooldown (600 ticks = 30 seconds)
speaker.setDialogueCooldown(600);
return true;
}
/**
* Send an action from a speaker to a player.
* Actions are formatted differently: "* Name action *"
*
* @param speaker The dialogue speaker
* @param player The player to send to
* @param dialogueId The dialogue ID
* @return true if action was sent, false if not found or on cooldown
*/
public static boolean actionTo(
IDialogueSpeaker speaker,
Player player,
String dialogueId
) {
// Check cooldown
if (speaker.getDialogueCooldown() > 0) {
return false;
}
String dialogue = getDialogue(speaker, player, dialogueId);
if (dialogue == null || dialogue.isEmpty()) {
return false;
}
// Format and send as action
broadcastDialogue(speaker.asEntity(), player, dialogue, true);
// Set default cooldown
speaker.setDialogueCooldown(600);
return true;
}
/**
* Broadcast dialogue from an entity to a player.
*
* @param entity The speaking entity
* @param player The target player
* @param text The dialogue text
* @param isAction Whether to format as action
*/
private static void broadcastDialogue(
LivingEntity entity,
Player player,
String text,
boolean isAction
) {
String name = entity.getName().getString();
if (isAction) {
MessageDispatcher.actionTo(entity, player, text);
} else {
MessageDispatcher.talkTo(entity, player, text);
}
}
/**
* Send dialogue to all players within a radius.
*
* @param speaker The dialogue speaker
* @param dialogueId The dialogue ID
* @param radius The broadcast radius in blocks
* @return true if dialogue was sent to at least one player
*/
public static boolean talkToNearby(
IDialogueSpeaker speaker,
String dialogueId,
double radius
) {
if (speaker.getDialogueCooldown() > 0) {
return false;
}
LivingEntity entity = speaker.asEntity();
var nearbyPlayers = entity
.level()
.getEntitiesOfClass(
Player.class,
entity.getBoundingBox().inflate(radius)
);
if (nearbyPlayers.isEmpty()) {
return false;
}
boolean sentAny = false;
for (Player player : nearbyPlayers) {
String dialogue = getDialogue(speaker, player, dialogueId);
if (dialogue != null && !dialogue.isEmpty()) {
broadcastDialogue(entity, player, dialogue, false);
sentAny = true;
}
}
if (sentAny) {
speaker.setDialogueCooldown(600);
}
return sentAny;
}
/**
* Map theme to a personality-like folder for kidnapper dialogues.
* This allows theme-based dialogue variation.
*
* @param theme The kidnapper theme
* @return Personality folder name to use
*/
public static String mapThemeToPersonalityFolder(KidnapperTheme theme) {
if (theme == null) {
return "default";
}
return switch (theme) {
case ROPE, SHIBARI -> "traditional";
case TAPE, LEATHER, CHAIN -> "rough";
case MEDICAL, ASYLUM -> "clinical";
case LATEX, RIBBON -> "playful";
case BEAM, WRAP -> "default";
};
}
}

View File

@@ -0,0 +1,155 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.personality.PersonalityType;
import java.util.EnumSet;
import java.util.Set;
import org.jetbrains.annotations.Nullable;
/**
* Conditions for when a dialogue entry can be used.
*
* Personality System: Data-driven dialogue
*/
public class DialogueCondition {
/** Personality types this dialogue applies to (null = all) */
@Nullable
private final Set<PersonalityType> personalities;
/** Minimum mood value (-100 to 100) */
private final int moodMin;
/** Maximum mood value (-100 to 100) */
private final int moodMax;
private DialogueCondition(Builder builder) {
this.personalities = builder.personalities;
this.moodMin = builder.moodMin;
this.moodMax = builder.moodMax;
}
/**
* Check if this condition matches the given context.
*/
public boolean matches(DialogueContext context) {
// Check personality
if (personalities != null && !personalities.isEmpty()) {
if (!personalities.contains(context.getPersonality())) {
return false;
}
}
// Check mood
if (context.getMood() < moodMin || context.getMood() > moodMax) {
return false;
}
return true;
}
/**
* Create a condition that matches all contexts (no restrictions).
*/
public static DialogueCondition any() {
return new Builder().build();
}
/**
* Create a condition for specific personalities.
*/
public static DialogueCondition forPersonalities(PersonalityType... types) {
return new Builder().personalities(types).build();
}
public static Builder builder() {
return new Builder();
}
// Getters
@Nullable
public Set<PersonalityType> getPersonalities() {
return personalities;
}
public int getMoodMin() {
return moodMin;
}
public int getMoodMax() {
return moodMax;
}
/**
* Builder for DialogueCondition.
*/
public static class Builder {
private Set<PersonalityType> personalities = null;
private int moodMin = -100;
private int moodMax = 100;
public Builder personalities(PersonalityType... types) {
this.personalities = EnumSet.noneOf(PersonalityType.class);
for (PersonalityType type : types) {
this.personalities.add(type);
}
return this;
}
public Builder personalities(Set<PersonalityType> types) {
this.personalities = types;
return this;
}
/**
* No-op: training conditions are ignored (training system removed).
* Kept for backward compatibility with JSON files that still reference training_min.
*/
public Builder trainingMin(Object level) {
return this;
}
/**
* No-op: training conditions are ignored (training system removed).
* Kept for backward compatibility with JSON files that still reference training_max.
*/
public Builder trainingMax(Object level) {
return this;
}
public Builder moodMin(int min) {
this.moodMin = min;
return this;
}
public Builder moodMax(int max) {
this.moodMax = max;
return this;
}
public Builder moodRange(int min, int max) {
this.moodMin = min;
this.moodMax = max;
return this;
}
/**
* No-op: resentment conditions are ignored (training system removed).
*/
public Builder resentmentMin(int min) {
return this;
}
public Builder resentmentMax(int max) {
return this;
}
public Builder resentmentRange(int min, int max) {
return this;
}
public DialogueCondition build() {
return new DialogueCondition(this);
}
}
}

View File

@@ -0,0 +1,339 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.personality.PersonalityType;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
/**
* Context information for dialogue selection.
* Holds all relevant state needed to match dialogue conditions.
*
* Personality System: Data-driven dialogue
* Universal NPC Support: Extended for all speaker types
*/
public class DialogueContext {
// Speaker type for dialogue routing
private final SpeakerType speakerType;
private final PersonalityType personality;
private final int mood;
// Kidnapper-specific context
@Nullable
private final String kidnapperTheme;
// Merchant-specific context
@Nullable
private final String merchantMode;
// Variable substitution data
@Nullable
private final String npcName;
@Nullable
private final String playerName;
@Nullable
private final String masterName;
@Nullable
private final String targetName;
// Additional state
private final boolean isBound;
private final boolean isGagged;
private final boolean isBlindfold;
private final boolean hasCollar;
private final float hunger;
private DialogueContext(Builder builder) {
this.speakerType = builder.speakerType;
this.personality = builder.personality;
this.mood = builder.mood;
this.kidnapperTheme = builder.kidnapperTheme;
this.merchantMode = builder.merchantMode;
this.npcName = builder.npcName;
this.playerName = builder.playerName;
this.masterName = builder.masterName;
this.targetName = builder.targetName;
this.isBound = builder.isBound;
this.isGagged = builder.isGagged;
this.isBlindfold = builder.isBlindfold;
this.hasCollar = builder.hasCollar;
this.hunger = builder.hunger;
}
// --- Getters for condition matching ---
/**
* Get the speaker type for dialogue routing.
*/
public SpeakerType getSpeakerType() {
return speakerType;
}
public PersonalityType getPersonality() {
return personality;
}
/**
* Get the kidnapper theme (for theme-based personalities).
* Only relevant for kidnapper-type speakers.
*/
@Nullable
public String getKidnapperTheme() {
return kidnapperTheme;
}
/**
* Get the merchant mode (MERCHANT or HOSTILE).
* Only relevant for merchant speakers.
*/
@Nullable
public String getMerchantMode() {
return merchantMode;
}
public int getMood() {
return mood;
}
// --- Getters for variable substitution ---
@Nullable
public String getNpcName() {
return npcName;
}
@Nullable
public String getPlayerName() {
return playerName;
}
@Nullable
public String getMasterName() {
return masterName;
}
@Nullable
public String getTargetName() {
return targetName;
}
public boolean isBound() {
return isBound;
}
public boolean isGagged() {
return isGagged;
}
public boolean isBlindfold() {
return isBlindfold;
}
public boolean hasCollar() {
return hasCollar;
}
public float getHunger() {
return hunger;
}
/**
* Get mood as a string descriptor.
*/
public String getMoodString() {
if (mood >= 70) return "happy";
if (mood >= 40) return "neutral";
if (mood >= 10) return "sad";
return "miserable";
}
/**
* Get hunger as a string descriptor.
*/
public String getHungerString() {
if (hunger >= 70) return "full";
if (hunger >= 30) return "hungry";
return "starving";
}
/**
* Substitute variables in dialogue text.
*
* @param text Text with placeholders like {player}, {npc}, etc.
* @return Text with placeholders replaced
*/
public String substituteVariables(String text) {
String result = text;
// Entity names
if (npcName != null) {
result = result.replace("{npc}", npcName);
}
if (playerName != null) {
result = result.replace("{player}", playerName);
}
if (masterName != null) {
result = result.replace("{master}", masterName);
}
if (targetName != null) {
result = result.replace("{target}", targetName);
}
// State variables
result = result.replace("{mood}", getMoodString());
result = result.replace("{hunger}", getHungerString());
result = result.replace(
"{personality}",
personality.name().toLowerCase()
);
return result;
}
public static Builder builder() {
return new Builder();
}
/**
* Builder for DialogueContext.
*/
public static class Builder {
private SpeakerType speakerType = SpeakerType.DAMSEL;
private PersonalityType personality = PersonalityType.CALM;
private int mood = 50;
private String kidnapperTheme = null;
private String merchantMode = null;
private String npcName = null;
private String playerName = null;
private String masterName = null;
private String targetName = null;
private boolean isBound = false;
private boolean isGagged = false;
private boolean isBlindfold = false;
private boolean hasCollar = false;
private float hunger = 100f;
public Builder speakerType(SpeakerType speakerType) {
this.speakerType = speakerType;
return this;
}
public Builder personality(PersonalityType personality) {
this.personality = personality;
return this;
}
public Builder kidnapperTheme(String theme) {
this.kidnapperTheme = theme;
return this;
}
public Builder merchantMode(String mode) {
this.merchantMode = mode;
return this;
}
public Builder mood(int mood) {
this.mood = mood;
return this;
}
public Builder npcName(String name) {
this.npcName = name;
return this;
}
public Builder playerName(String name) {
this.playerName = name;
return this;
}
public Builder masterName(String name) {
this.masterName = name;
return this;
}
public Builder targetName(String name) {
this.targetName = name;
return this;
}
public Builder bound(boolean bound) {
this.isBound = bound;
return this;
}
public Builder gagged(boolean gagged) {
this.isGagged = gagged;
return this;
}
public Builder blindfold(boolean blindfold) {
this.isBlindfold = blindfold;
return this;
}
public Builder hasCollar(boolean collar) {
this.hasCollar = collar;
return this;
}
public Builder hunger(float hunger) {
this.hunger = hunger;
return this;
}
/**
* Set player info from a Player entity.
*/
public Builder fromPlayer(Player player) {
this.playerName = player.getName().getString();
return this;
}
/**
* Set target info from a LivingEntity.
*/
public Builder fromTarget(LivingEntity target) {
this.targetName = target.getName().getString();
return this;
}
public DialogueContext build() {
return new DialogueContext(this);
}
/**
* Build from an IDialogueSpeaker.
* Automatically sets speakerType, personality, mood, and npcName.
*
* @param speaker The dialogue speaker
* @param player The player interacting (can be null)
* @return Builder with speaker data applied
*/
public Builder fromSpeaker(
IDialogueSpeaker speaker,
@Nullable Player player
) {
this.speakerType = speaker.getSpeakerType();
this.npcName = speaker.getDialogueName();
this.mood = speaker.getSpeakerMood();
if (speaker.getSpeakerPersonality() != null) {
this.personality = speaker.getSpeakerPersonality();
}
if (player != null) {
this.playerName = player.getName().getString();
}
return this;
}
}
}

View File

@@ -0,0 +1,181 @@
package com.tiedup.remake.dialogue;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* A dialogue entry containing an ID, conditions, and weighted variants.
* Represents a single dialogue "slot" that can be filled with different text
* based on personality and context.
*
* Personality System: Data-driven dialogue
*/
public class DialogueEntry {
private static final Random RANDOM = new Random();
private final String id;
private final DialogueCondition condition;
private final List<DialogueVariant> variants;
public DialogueEntry(
String id,
DialogueCondition condition,
List<DialogueVariant> variants
) {
this.id = id;
this.condition = condition;
this.variants = new ArrayList<>(variants);
}
public DialogueEntry(String id, DialogueCondition condition) {
this(id, condition, new ArrayList<>());
}
public String getId() {
return id;
}
public DialogueCondition getCondition() {
return condition;
}
public List<DialogueVariant> getVariants() {
return variants;
}
/**
* Add a variant to this entry.
*/
public DialogueEntry addVariant(DialogueVariant variant) {
this.variants.add(variant);
return this;
}
/**
* Add a simple text variant with default weight.
*/
public DialogueEntry addVariant(String text) {
this.variants.add(new DialogueVariant(text));
return this;
}
/**
* Add a text variant with specified weight.
*/
public DialogueEntry addVariant(String text, int weight) {
this.variants.add(new DialogueVariant(text, weight));
return this;
}
/**
* Check if this entry matches the given context.
*/
public boolean matches(DialogueContext context) {
return condition.matches(context);
}
/**
* Select a random variant based on weights.
*
* @return Selected variant, or null if no variants available
*/
public DialogueVariant selectVariant() {
if (variants.isEmpty()) {
return null;
}
// Calculate total weight
int totalWeight = 0;
for (DialogueVariant variant : variants) {
totalWeight += variant.getWeight();
}
if (totalWeight <= 0) {
return variants.get(RANDOM.nextInt(variants.size()));
}
// Weighted random selection
int roll = RANDOM.nextInt(totalWeight);
int cumulative = 0;
for (DialogueVariant variant : variants) {
cumulative += variant.getWeight();
if (roll < cumulative) {
return variant;
}
}
return variants.get(0); // Fallback
}
/**
* Select a random variant and format with context variables.
*
* @param context Context for variable substitution
* @return Formatted dialogue text, or null if no variants
*/
public String getFormattedDialogue(DialogueContext context) {
DialogueVariant variant = selectVariant();
if (variant == null) {
return null;
}
String text = context.substituteVariables(variant.getText());
// Format action text with asterisks
if (variant.isAction()) {
text = "*" + text + "*";
}
// Apply gag filter if speaker is gagged
if (context.isGagged()) {
// Don't muffle action text (already in asterisks)
if (!text.startsWith("*") || !text.endsWith("*")) {
text = GagTalkManager.transformToGaggedSpeech(text);
}
}
return text;
}
/**
* Check if this entry has any variants.
*/
public boolean hasVariants() {
return !variants.isEmpty();
}
// --- Static builder methods ---
/**
* Create an entry that matches any context.
*/
public static DialogueEntry any(String id) {
return new DialogueEntry(id, DialogueCondition.any());
}
/**
* Create an entry with a condition builder.
*/
public static DialogueEntry withCondition(
String id,
DialogueCondition condition
) {
return new DialogueEntry(id, condition);
}
@Override
public String toString() {
return (
"DialogueEntry{" +
"id='" +
id +
'\'' +
", variants=" +
variants.size() +
'}'
);
}
}

View File

@@ -0,0 +1,471 @@
package com.tiedup.remake.dialogue;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.personality.PersonalityType;
import java.io.Reader;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
/**
* Loads dialogue entries from JSON files.
*
* New structure (per-personality dialogues):
* dialogue/{lang}/default/ - Base dialogues (fallback)
* dialogue/{lang}/{personality}/ - Personality-specific dialogues
*
* Each personality folder contains the same categories:
* - idle.json, struggle.json, capture.json, commands.json,
* needs.json, mood.json, jobs.json, combat.json
*
* Personality System: Data-driven dialogue
*/
public class DialogueLoader {
private static final Gson GSON = new GsonBuilder()
.setPrettyPrinting()
.create();
/**
* Dialogue file categories to load.
*/
private static final String[] DIALOGUE_CATEGORIES = {
"commands",
"capture",
"struggle",
"jobs",
"mood",
"combat",
"needs",
"idle",
"actions",
"fear",
"reaction",
"environment",
// Phase 12: New dialogue categories
"discipline",
"home",
"leash",
"resentment",
// Phase 14: Personality hints and conversations
"personality",
"conversation",
// Master NPC dialogues
"punishment",
"purchase",
"inspection",
"petplay",
// Camp NPC dialogues (guard, maid)
"guard_labor",
"maid_labor",
// Kidnapper-specific dialogues
"guard",
"dogwalk",
"patrol",
"punish",
};
/**
* Speaker-type specific dialogue folders to load.
* These are loaded as additional dialogues for all personalities.
*/
private static final String[] SPEAKER_TYPE_FOLDERS = {
"master",
"kidnapper",
"maid",
"trader",
"guard",
"kidnapper_archer",
"kidnapper_elite",
"merchant",
};
/**
* Load all dialogues organized by personality.
*
* @param resourceManager Resource manager
* @param lang Language code (e.g., "en_us")
* @return Map of personality -> dialogue ID -> entries
*/
public static Map<
PersonalityType,
Map<String, List<DialogueEntry>>
> loadDialogues(ResourceManager resourceManager, String lang) {
Map<PersonalityType, Map<String, List<DialogueEntry>>> allDialogues =
new EnumMap<>(PersonalityType.class);
TiedUpMod.LOGGER.info(
"[DialogueLoader] Loading dialogues for lang: {}",
lang
);
// Load default dialogues (stored with null key in a temporary holder)
Map<String, List<DialogueEntry>> defaultDialogues = new HashMap<>();
loadDialoguesForPersonality(
resourceManager,
lang,
"default",
null,
defaultDialogues
);
int totalEntries = defaultDialogues
.values()
.stream()
.mapToInt(List::size)
.sum();
TiedUpMod.LOGGER.info(
"[DialogueLoader] Loaded {} default dialogue IDs ({} entries)",
defaultDialogues.size(),
totalEntries
);
// Load personality-specific dialogues
for (PersonalityType personality : PersonalityType.values()) {
// Start with a copy of default dialogues
Map<String, List<DialogueEntry>> personalityDialogues =
new HashMap<>();
// Copy default entries (deep copy the lists)
for (Map.Entry<
String,
List<DialogueEntry>
> entry : defaultDialogues.entrySet()) {
personalityDialogues.put(
entry.getKey(),
new ArrayList<>(entry.getValue())
);
}
// Load and merge personality-specific dialogues (these take priority)
String folderName = personality.name().toLowerCase();
loadDialoguesForPersonality(
resourceManager,
lang,
folderName,
personality,
personalityDialogues
);
allDialogues.put(personality, personalityDialogues);
}
// Load speaker-type specific dialogues (e.g., master/)
// These are added to ALL personality maps so they're always available
for (String speakerFolder : SPEAKER_TYPE_FOLDERS) {
loadSpeakerTypeDialogues(
resourceManager,
lang,
speakerFolder,
allDialogues
);
}
TiedUpMod.LOGGER.info(
"[DialogueLoader] Loaded dialogues for {} personalities",
allDialogues.size()
);
return allDialogues;
}
/**
* Load speaker-type specific dialogues and add them to all personality maps.
* This allows speaker-specific dialogues (e.g., master/) to be available
* regardless of personality context.
*
* @param resourceManager Resource manager
* @param lang Language code
* @param speakerFolder Speaker type folder name (e.g., "master")
* @param allDialogues Map to populate
*/
private static void loadSpeakerTypeDialogues(
ResourceManager resourceManager,
String lang,
String speakerFolder,
Map<PersonalityType, Map<String, List<DialogueEntry>>> allDialogues
) {
// Load from speaker/default folder
Map<String, List<DialogueEntry>> speakerDialogues = new HashMap<>();
String folderPath = speakerFolder + "/default";
loadDialoguesForPersonality(
resourceManager,
lang,
folderPath,
null, // No personality condition - available to all
speakerDialogues
);
if (speakerDialogues.isEmpty()) {
return;
}
int entriesAdded = speakerDialogues
.values()
.stream()
.mapToInt(List::size)
.sum();
TiedUpMod.LOGGER.info(
"[DialogueLoader] Loaded {} speaker-type dialogues from '{}'",
entriesAdded,
folderPath
);
// Add to all personality maps
for (PersonalityType personality : PersonalityType.values()) {
Map<String, List<DialogueEntry>> personalityDialogues =
allDialogues.get(personality);
if (personalityDialogues != null) {
for (Map.Entry<
String,
List<DialogueEntry>
> entry : speakerDialogues.entrySet()) {
personalityDialogues
.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
.addAll(entry.getValue());
}
}
}
}
/**
* Load dialogues for a specific personality folder.
*
* @param resourceManager Resource manager
* @param lang Language code
* @param folderName Folder name (e.g., "default", "timid", "fierce")
* @param personality Personality type (null for default)
* @param dialogues Map to populate (personality-specific entries are added at front for priority)
*/
private static void loadDialoguesForPersonality(
ResourceManager resourceManager,
String lang,
String folderName,
@Nullable PersonalityType personality,
Map<String, List<DialogueEntry>> dialogues
) {
int loadedFiles = 0;
int loadedEntries = 0;
for (String category : DIALOGUE_CATEGORIES) {
String path =
"dialogue/" +
lang +
"/" +
folderName +
"/" +
category +
".json";
ResourceLocation location = ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
path
);
try {
Resource resource = resourceManager
.getResource(location)
.orElse(null);
if (resource == null) continue;
try (Reader reader = resource.openAsReader()) {
JsonObject json = GSON.fromJson(reader, JsonObject.class);
int count = parseDialogueFile(json, personality, dialogues);
loadedFiles++;
loadedEntries += count;
}
} catch (Exception e) {
TiedUpMod.LOGGER.warn(
"[DialogueLoader] Failed to load {}: {}",
location,
e.getMessage()
);
}
}
if (loadedFiles > 0) {
TiedUpMod.LOGGER.debug(
"[DialogueLoader] Loaded {} files ({} entries) for '{}'",
loadedFiles,
loadedEntries,
folderName
);
}
}
/**
* Parse a dialogue JSON file.
*
* @param json JSON object
* @param personality Personality context (null for default, used to set conditions)
* @param dialogues Map to populate
* @return Number of entries loaded
*/
private static int parseDialogueFile(
JsonObject json,
@Nullable PersonalityType personality,
Map<String, List<DialogueEntry>> dialogues
) {
if (!json.has("entries")) return 0;
JsonArray entries = json.getAsJsonArray("entries");
int count = 0;
for (JsonElement element : entries) {
DialogueEntry entry = parseEntry(
element.getAsJsonObject(),
personality
);
if (entry != null && entry.hasVariants()) {
// For personality-specific dialogues, add at front (higher priority)
List<DialogueEntry> list = dialogues.computeIfAbsent(
entry.getId(),
k -> new ArrayList<>()
);
if (personality != null) {
// Personality-specific: add at front for priority
list.add(0, entry);
} else {
// Default: add at end
list.add(entry);
}
count++;
}
}
return count;
}
/**
* Parse a single dialogue entry from JSON.
*
* @param json JSON object for entry
* @param personality Personality context (for automatic condition)
* @return Parsed entry or null
*/
@Nullable
private static DialogueEntry parseEntry(
JsonObject json,
@Nullable PersonalityType personality
) {
if (!json.has("id")) return null;
String id = json.get("id").getAsString();
// Build condition
DialogueCondition.Builder conditionBuilder =
DialogueCondition.builder();
// If loading for a specific personality, add that as a condition
if (personality != null) {
conditionBuilder.personalities(personality);
}
// Apply additional conditions from JSON
if (json.has("conditions")) {
applyConditions(
conditionBuilder,
json.getAsJsonObject("conditions"),
personality
);
}
List<DialogueVariant> variants = parseVariants(
json.getAsJsonArray("variants")
);
if (variants.isEmpty()) {
return null;
}
return new DialogueEntry(id, conditionBuilder.build(), variants);
}
/**
* Apply conditions from JSON to builder.
*/
private static void applyConditions(
DialogueCondition.Builder builder,
JsonObject json,
@Nullable PersonalityType forcedPersonality
) {
// Personality filter (only if not already forced)
if (forcedPersonality == null && json.has("personality")) {
Set<PersonalityType> personalities = EnumSet.noneOf(
PersonalityType.class
);
JsonArray arr = json.getAsJsonArray("personality");
for (JsonElement e : arr) {
try {
personalities.add(
PersonalityType.valueOf(e.getAsString().toUpperCase())
);
} catch (IllegalArgumentException ex) {
TiedUpMod.LOGGER.warn(
"[DialogueLoader] Unknown personality: {}",
e.getAsString()
);
}
}
if (!personalities.isEmpty()) {
builder.personalities(personalities);
}
}
// Training level range - gracefully ignored (training system removed)
// JSON files may still contain training_min/training_max but they have no effect
// Mood range
if (json.has("mood_min")) {
builder.moodMin(json.get("mood_min").getAsInt());
}
if (json.has("mood_max")) {
builder.moodMax(json.get("mood_max").getAsInt());
}
// Relationship type - gracefully ignored (relationship system removed)
// JSON files may still contain "relationship" but it has no effect
}
/**
* Parse variants array from JSON.
*/
private static List<DialogueVariant> parseVariants(
@Nullable JsonArray json
) {
List<DialogueVariant> variants = new ArrayList<>();
if (json == null) return variants;
for (JsonElement element : json) {
JsonObject variantJson = element.getAsJsonObject();
if (!variantJson.has("text")) continue;
String text = variantJson.get("text").getAsString();
int weight = variantJson.has("weight")
? variantJson.get("weight").getAsInt()
: 10;
boolean isAction =
variantJson.has("is_action") &&
variantJson.get("is_action").getAsBoolean();
variants.add(new DialogueVariant(text, weight, isAction));
}
return variants;
}
}

View File

@@ -0,0 +1,428 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.personality.PersonalityType;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import org.jetbrains.annotations.Nullable;
import net.minecraft.server.packs.resources.ResourceManager;
/**
* Central manager for the data-driven dialogue system.
* Loads dialogues from JSON and provides selection based on context.
*
* New structure: dialogues are organized by personality type.
* Each personality has its own complete set of dialogues,
* with default dialogues as fallback.
*
* Usage:
* 1. Call reload() on server/resource reload to load JSON files
* 2. Call getDialogue(dialogueId, context) to get appropriate dialogue text
*
* Personality System: Data-driven dialogue
*/
public class DialogueManager {
private static final Random RANDOM = new Random();
/**
* Loaded dialogues organized by personality.
* Map: PersonalityType -> DialogueId -> List of matching entries
*/
private static Map<
PersonalityType,
Map<String, List<DialogueEntry>>
> dialogues = new EnumMap<>(PersonalityType.class);
/** Current language code */
private static String currentLang = "en_us";
/** Whether dialogues have been loaded */
private static boolean initialized = false;
/**
* Reload dialogues from resource manager.
* Should be called on server start and on /reload.
*
* @param resourceManager Minecraft's resource manager
*/
public static void reload(ResourceManager resourceManager) {
reload(resourceManager, "en_us");
}
/**
* Reload dialogues with specified language.
*
* @param resourceManager Minecraft's resource manager
* @param lang Language code
*/
public static void reload(ResourceManager resourceManager, String lang) {
TiedUpMod.LOGGER.debug(
"[DialogueManager] Reloading dialogues for lang: {}",
lang
);
currentLang = lang;
dialogues = DialogueLoader.loadDialogues(resourceManager, lang);
initialized = true;
int totalEntries = dialogues
.values()
.stream()
.flatMap(m -> m.values().stream())
.mapToInt(List::size)
.sum();
TiedUpMod.LOGGER.debug(
"[DialogueManager] Loaded {} personalities, {} total entries",
dialogues.size(),
totalEntries
);
}
/**
* Get a dialogue string for the given ID and context.
* Uses the personality from context to select the appropriate dialogue set.
*
* @param dialogueId The dialogue identifier (e.g., "command.follow.accept")
* @param context Current dialogue context (contains personality)
* @return Formatted dialogue text, or null if no match
*/
@Nullable
public static String getDialogue(
String dialogueId,
DialogueContext context
) {
if (!initialized) {
TiedUpMod.LOGGER.warn(
"[DialogueManager] Attempted to get dialogue before initialization"
);
return null;
}
PersonalityType personality = context.getPersonality();
// Get dialogues for this personality
Map<String, List<DialogueEntry>> personalityDialogues = dialogues.get(
personality
);
if (personalityDialogues == null) {
TiedUpMod.LOGGER.warn(
"[DialogueManager] No dialogues loaded for personality: {}",
personality
);
return null;
}
List<DialogueEntry> entries = personalityDialogues.get(dialogueId);
if (entries == null || entries.isEmpty()) {
TiedUpMod.LOGGER.warn(
"[DialogueManager] No entries for dialogue ID: '{}' (personality: {}). Available IDs: {}",
dialogueId,
personality,
personalityDialogues
.keySet()
.stream()
.filter(k -> k.startsWith("action."))
.toList()
);
return null;
}
// Find all matching entries
List<DialogueEntry> matching = new ArrayList<>();
for (DialogueEntry entry : entries) {
if (entry.matches(context)) {
matching.add(entry);
}
}
if (matching.isEmpty()) {
TiedUpMod.LOGGER.debug(
"[DialogueManager] No matching entries for: {} (conditions didn't match)",
dialogueId
);
return null;
}
// Select from matching entries (first match = highest priority)
DialogueEntry selected = matching.get(0);
return selected.getFormattedDialogue(context);
}
/**
* Get a dialogue with fallback to a default message.
*
* @param dialogueId The dialogue identifier
* @param context Current dialogue context
* @param fallback Fallback message if no match found
* @return Dialogue text or fallback
*/
public static String getDialogueOrDefault(
String dialogueId,
DialogueContext context,
String fallback
) {
String result = getDialogue(dialogueId, context);
return result != null ? result : context.substituteVariables(fallback);
}
/**
* Get a random dialogue from a category pattern.
* Useful for getting any dialogue matching "command.*.accept" pattern.
*
* @param pattern Dialogue ID pattern (use * as wildcard)
* @param context Current dialogue context
* @return Matching dialogue or null
*/
@Nullable
public static String getRandomDialogue(
String pattern,
DialogueContext context
) {
if (!initialized) return null;
PersonalityType personality = context.getPersonality();
Map<String, List<DialogueEntry>> personalityDialogues = dialogues.get(
personality
);
if (personalityDialogues == null) return null;
String regexPattern = pattern.replace(".", "\\.").replace("*", ".*");
List<DialogueEntry> allMatching = new ArrayList<>();
for (Map.Entry<
String,
List<DialogueEntry>
> entry : personalityDialogues.entrySet()) {
if (entry.getKey().matches(regexPattern)) {
for (DialogueEntry dialogueEntry : entry.getValue()) {
if (dialogueEntry.matches(context)) {
allMatching.add(dialogueEntry);
}
}
}
}
if (allMatching.isEmpty()) return null;
DialogueEntry selected = allMatching.get(
RANDOM.nextInt(allMatching.size())
);
return selected.getFormattedDialogue(context);
}
/**
* Check if a dialogue ID exists for the given personality.
*
* @param dialogueId The dialogue identifier
* @param personality Personality type
* @return true if at least one entry exists for this ID
*/
public static boolean hasDialogue(
String dialogueId,
PersonalityType personality
) {
Map<String, List<DialogueEntry>> personalityDialogues = dialogues.get(
personality
);
return (
personalityDialogues != null &&
personalityDialogues.containsKey(dialogueId) &&
!personalityDialogues.get(dialogueId).isEmpty()
);
}
/**
* Check if a dialogue ID exists (for any personality).
*/
public static boolean hasDialogue(String dialogueId) {
for (Map<
String,
List<DialogueEntry>
> personalityDialogues : dialogues.values()) {
if (
personalityDialogues.containsKey(dialogueId) &&
!personalityDialogues.get(dialogueId).isEmpty()
) {
return true;
}
}
return false;
}
/**
* Get all dialogue IDs matching a prefix for a personality.
*
* @param prefix Prefix to match (e.g., "command.")
* @param personality Personality type
* @return List of matching dialogue IDs
*/
public static List<String> getDialogueIds(
String prefix,
PersonalityType personality
) {
List<String> result = new ArrayList<>();
Map<String, List<DialogueEntry>> personalityDialogues = dialogues.get(
personality
);
if (personalityDialogues != null) {
for (String id : personalityDialogues.keySet()) {
if (id.startsWith(prefix)) {
result.add(id);
}
}
}
return result;
}
/**
* Get count of loaded personalities.
*/
public static int getPersonalityCount() {
return dialogues.size();
}
/**
* Get total count of dialogue entries across all personalities.
*/
public static int getEntryCount() {
return dialogues
.values()
.stream()
.flatMap(m -> m.values().stream())
.mapToInt(List::size)
.sum();
}
/**
* Get count of unique dialogue IDs (across all personalities).
*/
public static int getDialogueCount() {
return (int) dialogues
.values()
.stream()
.flatMap(m -> m.keySet().stream())
.distinct()
.count();
}
/**
* Check if dialogues have been loaded.
*/
public static boolean isInitialized() {
return initialized;
}
/**
* Clear all loaded dialogues.
*/
public static void clear() {
dialogues.clear();
initialized = false;
}
/**
* Get current language code.
*/
public static String getCurrentLang() {
return currentLang;
}
// --- Convenience methods for common dialogue categories ---
/**
* Get command accept dialogue.
*/
@Nullable
public static String getCommandAccept(
String commandName,
DialogueContext context
) {
return getDialogue(
"command." + commandName.toLowerCase() + ".accept",
context
);
}
/**
* Get command refuse dialogue.
*/
@Nullable
public static String getCommandRefuse(
String commandName,
DialogueContext context
) {
return getDialogue(
"command." + commandName.toLowerCase() + ".refuse",
context
);
}
/**
* Get command hesitate dialogue.
*/
@Nullable
public static String getCommandHesitate(
String commandName,
DialogueContext context
) {
return getDialogue(
"command." + commandName.toLowerCase() + ".hesitate",
context
);
}
/**
* Get capture dialogue.
*/
@Nullable
public static String getCaptureDialogue(
String phase,
DialogueContext context
) {
return getDialogue("capture." + phase, context);
}
/**
* Get struggle dialogue.
*/
@Nullable
public static String getStruggleDialogue(
String type,
DialogueContext context
) {
return getDialogue("struggle." + type, context);
}
/**
* Get mood expression dialogue.
*/
@Nullable
public static String getMoodDialogue(String mood, DialogueContext context) {
return getDialogue("mood." + mood, context);
}
/**
* Get needs dialogue (hungry, tired, etc.).
*/
@Nullable
public static String getNeedsDialogue(
String need,
DialogueContext context
) {
return getDialogue("needs." + need, context);
}
/**
* Get idle/random dialogue.
*/
@Nullable
public static String getIdleDialogue(DialogueContext context) {
return getRandomDialogue("idle.*", context);
}
}

View File

@@ -0,0 +1,63 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.core.TiedUpMod;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import net.minecraft.server.packs.resources.PreparableReloadListener;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.util.profiling.ProfilerFiller;
/**
* Forge reload listener for dialogue JSON files.
*
* Integrates with Minecraft's resource reload system to:
* 1. Load dialogue definitions from assets on server start
* 2. Reload dialogues when /reload command is executed
*
* Registered via AddReloadListenerEvent in TiedUpMod.
*
* Personality System: Data-driven dialogue
*/
public class DialogueReloadListener implements PreparableReloadListener {
@Override
public CompletableFuture<Void> reload(
PreparationBarrier barrier,
ResourceManager resourceManager,
ProfilerFiller preparationsProfiler,
ProfilerFiller reloadProfiler,
Executor backgroundExecutor,
Executor gameExecutor
) {
// Load dialogues in background thread (non-blocking)
return CompletableFuture.runAsync(
() -> {
preparationsProfiler.startTick();
preparationsProfiler.push("tiedup_dialogue_loading");
try {
TiedUpMod.LOGGER.info(
"[DialogueReloadListener] Starting dialogue reload..."
);
// Load dialogues from assets
DialogueManager.reload(resourceManager, "en_us");
TiedUpMod.LOGGER.info(
"[DialogueReloadListener] Dialogue reload complete - {} IDs loaded",
DialogueManager.getDialogueCount()
);
} catch (Exception e) {
TiedUpMod.LOGGER.error(
"[DialogueReloadListener] Failed to reload dialogues",
e
);
}
preparationsProfiler.pop();
preparationsProfiler.endTick();
},
backgroundExecutor
).thenCompose(barrier::wait);
}
}

View File

@@ -0,0 +1,154 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.personality.NpcCommand;
import com.tiedup.remake.personality.NpcNeeds;
import com.tiedup.remake.personality.PersonalityState;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.entity.player.Player;
/**
* System for selecting proactive dialogues based on NPC state.
* Makes NPCs feel alive by having them speak about their needs, mood, and environment.
*
* Personality System Phase 4: Living NPCs
*/
public class DialogueTriggerSystem {
/**
* Select a proactive dialogue ID based on NPC's current state.
* Returns null if no dialogue should be triggered.
*
* @param npc The damsel entity
* @return Dialogue ID or null
*/
@Nullable
public static String selectProactiveDialogue(EntityDamsel npc) {
PersonalityState state = npc.getPersonalityState();
if (state == null) {
return null;
}
NpcNeeds needs = state.getNeeds();
// Priority 1: Critical needs (starving)
if (needs.isStarving()) {
return "needs.starving";
}
// Priority 2: Very low mood
if (state.getMood() < 20) {
return "mood.miserable";
}
// Priority 4: Non-critical needs
if (needs.isHungry()) {
return "needs.hungry";
}
if (needs.isTired()) {
return "needs.dignity_low";
}
// Priority 5: Low mood
if (state.getMood() < 40) {
return "mood.sad";
}
// Priority 6: Job-specific idle (if doing a job)
if (state.getActiveCommand().type == NpcCommand.CommandType.JOB) {
return selectJobIdleDialogue(npc, state);
}
// Priority 7: Generic idle
return selectIdleDialogue(npc, state);
}
/**
* Select dialogue for approaching player.
* No fear/relationship system — returns generic approach dialogue.
*
* @param npc The damsel entity
* @param player The approaching player
* @return Dialogue ID
*/
public static String selectApproachDialogue(
EntityDamsel npc,
Player player
) {
return "reaction.approach.stranger";
}
/**
* Select job-specific idle dialogue.
*/
@Nullable
private static String selectJobIdleDialogue(
EntityDamsel npc,
PersonalityState state
) {
NpcCommand job = state.getActiveCommand();
if (job.type != NpcCommand.CommandType.JOB) {
return null;
}
// Check mood first
if (state.getMood() < 30) {
return "mood.working_unhappy";
}
// Job-specific idle
return "jobs.idle." + job.name().toLowerCase();
}
/**
* Select generic idle dialogue.
*/
@Nullable
private static String selectIdleDialogue(
EntityDamsel npc,
PersonalityState state
) {
// High mood = positive idle
if (state.getMood() > 70) {
return "idle.content";
}
// Normal idle
return "idle.neutral";
}
/**
* Select environmental dialogue based on weather/time.
*
* @param npc The damsel entity
* @return Dialogue ID or null
*/
@Nullable
public static String selectEnvironmentDialogue(EntityDamsel npc) {
// Check if outdoors (can see sky)
if (!npc.level().canSeeSky(npc.blockPosition())) {
return null;
}
// Thunder takes priority
if (npc.level().isThundering()) {
return "environment.thunder";
}
// Rain
if (
npc.level().isRaining() &&
npc.level().isRainingAt(npc.blockPosition())
) {
return "environment.rain";
}
// Night (only if dark enough)
long dayTime = npc.level().getDayTime() % 24000;
if (dayTime >= 13000 && dayTime <= 23000) {
return "environment.night";
}
return null;
}
}

View File

@@ -0,0 +1,42 @@
package com.tiedup.remake.dialogue;
/**
* A single dialogue variant with text, weight, and type.
*
* Personality System: Data-driven dialogue
*/
public class DialogueVariant {
private final String text;
private final int weight;
private final boolean isAction;
public DialogueVariant(String text, int weight, boolean isAction) {
this.text = text;
this.weight = weight;
this.isAction = isAction;
}
public DialogueVariant(String text, int weight) {
this(text, weight, false);
}
public DialogueVariant(String text) {
this(text, 10, false);
}
public String getText() {
return text;
}
public int getWeight() {
return weight;
}
/**
* Whether this is an action (displayed as *action*) or speech.
*/
public boolean isAction() {
return isAction;
}
}

View File

@@ -0,0 +1,270 @@
package com.tiedup.remake.dialogue;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Detects and applies emotional context to gagged speech.
*/
public class EmotionalContext {
/**
* Types of emotional expression that affect speech transformation.
*/
public enum EmotionType {
NORMAL,
SHOUTING,
WHISPERING,
QUESTIONING,
PLEADING,
DISTRESSED,
LAUGHING,
CRYING,
}
// Pattern for extended vowels (nooooo, heeelp, pleeease)
private static final Pattern EXTENDED_VOWELS = Pattern.compile(
"([aeiou])\\1{2,}",
Pattern.CASE_INSENSITIVE
);
// Pattern for laughing (haha, hehe, lol)
private static final Pattern LAUGHING_PATTERN = Pattern.compile(
"(ha){2,}|(he){2,}|(hi){2,}|lol|lmao|xd",
Pattern.CASE_INSENSITIVE
);
// Pattern for crying indicators
private static final Pattern CRYING_PATTERN = Pattern.compile(
"\\*(?:sob|cry|sniff|whimper)s?\\*|;-?;|T[-_]T|:'+\\(",
Pattern.CASE_INSENSITIVE
);
// Pleading keywords
private static final Set<String> PLEADING_WORDS = Set.of(
"please",
"help",
"stop",
"no",
"don't",
"dont",
"wait",
"s'il vous plait",
"svp",
"aide",
"aidez",
"arrete",
"arreter",
"non",
"mercy",
"spare",
"let me go",
"release"
);
// Whispering indicators
private static final Set<String> WHISPERING_INDICATORS = Set.of(
"*whisper*",
"*whispers*",
"*quietly*",
"*softly*",
"(whisper)",
"(quietly)"
);
/**
* Detect the emotional context of a message or word.
*
* @param text The text to analyze
* @return The detected emotion type
*/
public static EmotionType detectEmotion(String text) {
if (text == null || text.isEmpty()) {
return EmotionType.NORMAL;
}
String lower = text.toLowerCase();
// Check for explicit emotional markers first
if (CRYING_PATTERN.matcher(text).find()) {
return EmotionType.CRYING;
}
if (LAUGHING_PATTERN.matcher(text).find()) {
return EmotionType.LAUGHING;
}
// Check for whispering indicators
for (String indicator : WHISPERING_INDICATORS) {
if (lower.contains(indicator)) {
return EmotionType.WHISPERING;
}
}
// Check for shouting (ALL CAPS with 3+ letters, or multiple !)
String letters = text.replaceAll("[^a-zA-Z]", "");
if (letters.length() >= 3 && letters.equals(letters.toUpperCase())) {
return EmotionType.SHOUTING;
}
// Require at least 2 exclamation marks for shouting
if (text.contains("!!") || text.contains("!?") || text.contains("?!")) {
return EmotionType.SHOUTING;
}
// Check for questioning
if (text.endsWith("?") || text.endsWith("??")) {
return EmotionType.QUESTIONING;
}
// Check for distress (extended vowels: nooooo, heeelp)
if (EXTENDED_VOWELS.matcher(text).find()) {
return EmotionType.DISTRESSED;
}
// Check for pleading keywords
for (String word : PLEADING_WORDS) {
if (lower.contains(word)) {
return EmotionType.PLEADING;
}
}
return EmotionType.NORMAL;
}
/**
* Apply emotional modifiers to a muffled message.
*
* @param muffled The muffled text
* @param emotion The detected emotion type
* @return Modified text with emotional expression
*/
public static String applyEmotionalModifiers(
String muffled,
EmotionType emotion
) {
if (muffled == null || muffled.isEmpty()) {
return muffled;
}
switch (emotion) {
case SHOUTING:
// Convert to uppercase and emphasize
return (
muffled.toUpperCase() + (muffled.endsWith("!") ? "!" : "!!")
);
case WHISPERING:
// Softer, shorter sounds
return "*" + muffled.toLowerCase() + "*";
case QUESTIONING:
// Rising intonation
String question = muffled.endsWith("?")
? muffled
: muffled + "?";
return question.replace("mm", "mn").replace("nn", "nh");
case PLEADING:
// Add whimpering
return muffled + "-mm..";
case DISTRESSED:
// Extend the dominant sound
return extendDominantSound(muffled);
case LAUGHING:
// Muffled laughter
return muffled.replace("mm", "hm").replace("nn", "hn") + "-hm";
case CRYING:
// Add sobbing sounds
return "*hic* " + muffled + " *mm*";
case NORMAL:
default:
return muffled;
}
}
/**
* Extend the dominant/repeated sound in a muffled word for distress effect.
*/
private static String extendDominantSound(String text) {
if (text == null || text.length() < 2) {
return text;
}
StringBuilder result = new StringBuilder();
char prev = 0;
int repeatCount = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c == prev && Character.isLetter(c)) {
repeatCount++;
// Extend repeated sounds
if (repeatCount <= 3) {
result.append(c);
}
} else {
result.append(c);
repeatCount = 1;
}
prev = c;
}
// If no extension happened, extend the last vowel-like sound
String str = result.toString();
if (str.equals(text)) {
// Find last 'm' or 'n' or vowel and extend it
int lastExtendable = -1;
for (int i = str.length() - 1; i >= 0; i--) {
char c = Character.toLowerCase(str.charAt(i));
if (c == 'm' || c == 'n' || c == 'a' || c == 'o' || c == 'u') {
lastExtendable = i;
break;
}
}
if (lastExtendable >= 0) {
char ext = str.charAt(lastExtendable);
return (
str.substring(0, lastExtendable + 1) +
ext +
ext +
str.substring(lastExtendable + 1)
);
}
}
return result.toString();
}
/**
* Get the intensity multiplier for an emotion.
* Higher intensity = more muffling effect.
*
* @param emotion The emotion type
* @return Intensity multiplier (1.0 = normal)
*/
public static float getIntensityMultiplier(EmotionType emotion) {
switch (emotion) {
case SHOUTING:
return 1.3f; // Harder to understand when shouting
case WHISPERING:
return 0.7f; // Easier (softer, clearer)
case DISTRESSED:
return 1.2f;
case CRYING:
return 1.4f;
case LAUGHING:
return 1.1f;
case PLEADING:
case QUESTIONING:
case NORMAL:
default:
return 1.0f;
}
}
/**
* Check if the emotion should preserve more of the original text.
* Some emotions (like whispering) are clearer.
*/
public static boolean shouldPreserveMore(EmotionType emotion) {
return emotion == EmotionType.WHISPERING;
}
}

View File

@@ -0,0 +1,635 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.util.MessageDispatcher;
import java.util.List;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
/**
* Complete dialogue system for EntityDamsel and EntityKidnapper.
*
* Phase 14.3: Centralized NPC dialogue management
*
* Features:
* - Multiple dialogue variants per action category
* - Integration with GagTalkManager for gagged NPCs
* - Formatted messages with entity name
* - Action messages vs speech messages
* - Radius-based broadcasting
*/
public class EntityDialogueManager {
// ========================================
// DIALOGUE CATEGORIES
// ========================================
/**
* Dialogue categories for different NPC actions.
*/
public enum DialogueCategory {
// === KIDNAPPER CAPTURE SEQUENCE ===
CAPTURE_START, // When kidnapper starts chasing
CAPTURE_APPROACHING, // While approaching target
CAPTURE_CHASE, // When pursuing an escaped captive
CAPTURE_TYING, // While tying up target
CAPTURE_TIED, // After target is tied
CAPTURE_GAGGING, // While gagging target
CAPTURE_GAGGED, // After target is gagged
CAPTURE_ENSLAVED, // After enslaving target
CAPTURE_ESCAPE, // When captive escapes
// === SLAVE MANAGEMENT ===
SLAVE_TALK_RESPONSE, // When slave tries to talk
SLAVE_STRUGGLE, // When slave struggles
SLAVE_TRANSPORT, // When transporting slave
SLAVE_ARRIVE_PRISON, // When arriving at prison
SLAVE_TIED_TO_POLE, // When tying slave to pole
PUNISH, // When punishing a recaptured or misbehaving captive
// === SALE SYSTEM ===
SALE_WAITING, // Waiting for buyer
SALE_ANNOUNCE, // Announcing sale
SALE_OFFER, // Offering to sell to a passing player (needs %s for player name and %s for price)
SALE_COMPLETE, // Sale completed
SALE_ABANDONED, // When kidnapper abandons captive (solo mode)
SALE_KEPT, // When kidnapper decides to keep captive (solo mode)
// === JOB SYSTEM ===
JOB_ASSIGNED, // Giving job to slave
JOB_HURRY, // Urging slave to hurry
JOB_COMPLETE, // Job completed successfully
JOB_FAILED, // Job failed
JOB_LAST_CHANCE, // Warning before killing
JOB_KILL, // Killing the slave for failure
// === COMBAT/ATTACK ===
ATTACKED_RESPONSE, // When attacked by someone
ATTACK_SLAVE, // When slave attacks
// === DAMSEL SPECIFIC ===
DAMSEL_PANIC, // When damsel is scared
DAMSEL_FLEE, // When damsel flees
DAMSEL_CAPTURED, // When damsel is captured
DAMSEL_FREED, // When damsel is freed
DAMSEL_GREETING, // Greeting nearby players
DAMSEL_IDLE, // Random idle chatter
DAMSEL_CALL_FOR_HELP, // When tied damsel sees a player nearby (needs %s for player name)
// === GENERAL ===
FREED, // When freeing someone
GOODBYE, // Saying goodbye
GET_OUT, // Telling someone to leave
GENERIC_THREAT, // Generic threatening line
GENERIC_TAUNT, // Generic taunting line
// === PERSONALITY SYSTEM (Phase E) ===
COMMAND_ACCEPT, // When NPC accepts a command
COMMAND_REFUSE, // When NPC refuses a command
COMMAND_HESITATE, // When NPC hesitates before command
// === DISCIPLINE SYSTEM (Training V2) ===
PRAISE_RESPONSE, // When NPC is praised
SCOLD_RESPONSE, // When NPC is scolded
THREATEN_RESPONSE, // When NPC is threatened
NEED_HUNGRY, // When NPC is hungry
NEED_TIRED, // When NPC is tired
NEED_UNCOMFORTABLE, // When NPC is uncomfortable
NEED_DIGNITY_LOW, // When NPC has low dignity
PERSONALITY_HINT_TIMID, // Hint for TIMID personality
PERSONALITY_HINT_GENTLE, // Hint for GENTLE personality
PERSONALITY_HINT_SUBMISSIVE, // Hint for SUBMISSIVE personality
PERSONALITY_HINT_CALM, // Hint for CALM personality
PERSONALITY_HINT_CURIOUS, // Hint for CURIOUS personality
PERSONALITY_HINT_PROUD, // Hint for PROUD personality
PERSONALITY_HINT_FIERCE, // Hint for FIERCE personality
PERSONALITY_HINT_DEFIANT, // Hint for DEFIANT personality
PERSONALITY_HINT_PLAYFUL, // Hint for PLAYFUL personality
PERSONALITY_HINT_MASOCHIST, // Hint for MASOCHIST personality
PERSONALITY_HINT_SADIST, // Hint for SADIST personality (kidnappers)
}
// ========================================
// DIALOGUE RETRIEVAL (Data-Driven Only)
// ========================================
/**
* Get random dialogue for a category using the data-driven system.
* All dialogues are now loaded from JSON files.
*
* @param category The dialogue category
* @return Dialogue text, or a fallback message if not found
*/
public static String getDialogue(DialogueCategory category) {
// Use data-driven system with default context
if (com.tiedup.remake.dialogue.DialogueManager.isInitialized()) {
String dialogueId =
com.tiedup.remake.dialogue.DialogueBridge.categoryToDialogueId(
category
);
com.tiedup.remake.dialogue.DialogueContext defaultContext =
com.tiedup.remake.dialogue.DialogueContext.builder()
.personality(
com.tiedup.remake.personality.PersonalityType.CALM
)
.mood(50)
.build();
String text =
com.tiedup.remake.dialogue.DialogueManager.getDialogue(
dialogueId,
defaultContext
);
if (text != null) {
return text;
}
}
// Fallback for uninitialized system
com.tiedup.remake.core.TiedUpMod.LOGGER.warn(
"[EntityDialogueManager] Data-driven dialogue not found for category: {}",
category.name()
);
return "[" + category.name() + "]";
}
/**
* Get dialogue for a category using the data-driven system.
* Uses entity context for personality-aware dialogue selection.
*
* @param entity The entity speaking
* @param player The player (can be null)
* @param category The dialogue category
* @return Dialogue text
*/
public static String getDialogue(
EntityDamsel entity,
Player player,
DialogueCategory category
) {
// Always use data-driven system
String dataDriven =
com.tiedup.remake.dialogue.DialogueBridge.getDataDrivenDialogue(
entity,
player,
category
);
if (dataDriven != null) {
return dataDriven;
}
// Fallback to category-only lookup (no entity context)
return getDialogue(category);
}
// ========================================
// MESSAGE SENDING METHODS
// ========================================
/**
* Send a dialogue message to a specific player.
* Format: *EntityName* : message
* Uses data-driven dialogue system if available.
*
* @param entity The entity speaking
* @param player The player receiving the message
* @param category The dialogue category
*/
public static void talkTo(
EntityDamsel entity,
Player player,
DialogueCategory category
) {
// Use data-driven dialogue when available
talkTo(entity, player, getDialogue(entity, player, category));
}
/**
* Send a custom message to a specific player.
* Delegates formatting, gag talk, and earplug handling to MessageDispatcher.
*
* @param entity The entity speaking
* @param player The player receiving the message
* @param message The message to send
*/
public static void talkTo(
EntityDamsel entity,
Player player,
String message
) {
if (entity == null || player == null || message == null) return;
if (entity.level().isClientSide()) return;
if (!(player instanceof ServerPlayer)) return;
MessageDispatcher.talkTo(entity, player, message);
}
/**
* Send a dialogue message using a dialogue ID.
* Resolves the ID to actual text via DialogueManager.
*
* @param entity The entity speaking
* @param player The player receiving the message
* @param dialogueId The dialogue ID (e.g., "action.whip")
*/
public static void talkByDialogueId(
EntityDamsel entity,
Player player,
String dialogueId
) {
if (entity == null || player == null || dialogueId == null) return;
if (entity.level().isClientSide()) return;
if (!com.tiedup.remake.dialogue.DialogueManager.isInitialized()) {
talkTo(entity, player, "[Dialogue system not ready]");
return;
}
// Build context and resolve dialogue text
com.tiedup.remake.dialogue.DialogueContext context =
com.tiedup.remake.dialogue.DialogueBridge.buildContext(
entity,
player
);
String text = com.tiedup.remake.dialogue.DialogueManager.getDialogue(
dialogueId,
context
);
if (text == null) {
text = "[Missing: " + dialogueId + "]";
com.tiedup.remake.core.TiedUpMod.LOGGER.warn(
"[EntityDialogueManager] Missing dialogue for ID: {} with personality: {}",
dialogueId,
context.getPersonality()
);
}
talkTo(entity, player, text);
}
/**
* Send an action message to a specific player.
* Format: EntityName action
* Uses data-driven dialogue system if available.
*
* @param entity The entity performing the action
* @param player The player receiving the message
* @param category The dialogue category
*/
public static void actionTo(
EntityDamsel entity,
Player player,
DialogueCategory category
) {
// Use data-driven dialogue when available
actionTo(entity, player, getDialogue(entity, player, category));
}
/**
* Send a custom action message to a specific player.
* Delegates formatting and earplug handling to MessageDispatcher.
*
* @param entity The entity performing the action
* @param player The player receiving the message
* @param action The action description
*/
public static void actionTo(
EntityDamsel entity,
Player player,
String action
) {
if (entity == null || player == null || action == null) return;
if (entity.level().isClientSide()) return;
if (!(player instanceof ServerPlayer)) return;
MessageDispatcher.actionTo(entity, player, action);
}
/**
* Talk to all players within a radius.
* Builds proper context per player for variable substitution.
*
* @param entity The entity speaking
* @param category The dialogue category
* @param radius The radius in blocks
*/
public static void talkToNearby(
EntityDamsel entity,
DialogueCategory category,
int radius
) {
if (entity == null) return;
List<Player> players = entity
.level()
.getEntitiesOfClass(
Player.class,
entity.getBoundingBox().inflate(radius)
);
for (Player player : players) {
// Use the context-aware dialogue lookup for proper {player} substitution
String dialogue = getDialogue(entity, player, category);
talkTo(entity, player, dialogue);
}
}
/**
* Talk to all players within a radius with a custom message.
*
* @param entity The entity speaking
* @param message The message to send
* @param radius The radius in blocks
*/
public static void talkToNearby(
EntityDamsel entity,
String message,
int radius
) {
if (entity == null || message == null) return;
List<Player> players = entity
.level()
.getEntitiesOfClass(
Player.class,
entity.getBoundingBox().inflate(radius)
);
for (Player player : players) {
talkTo(entity, player, message);
}
}
/**
* Send an action message to all players within a radius.
* Builds proper context per player for variable substitution.
*
* @param entity The entity performing the action
* @param category The dialogue category
* @param radius The radius in blocks
*/
public static void actionToNearby(
EntityDamsel entity,
DialogueCategory category,
int radius
) {
if (entity == null) return;
List<Player> players = entity
.level()
.getEntitiesOfClass(
Player.class,
entity.getBoundingBox().inflate(radius)
);
for (Player player : players) {
// Use the context-aware dialogue lookup for proper {player} substitution
String action = getDialogue(entity, player, category);
actionTo(entity, player, action);
}
}
/**
* Send a custom action message to all players within a radius.
*
* @param entity The entity performing the action
* @param action The action description
* @param radius The radius in blocks
*/
public static void actionToNearby(
EntityDamsel entity,
String action,
int radius
) {
if (entity == null || action == null) return;
List<Player> players = entity
.level()
.getEntitiesOfClass(
Player.class,
entity.getBoundingBox().inflate(radius)
);
for (Player player : players) {
actionTo(entity, player, action);
}
}
// ========================================
// CONVENIENCE METHODS
// ========================================
/**
* Make entity say something to their target (if player).
*/
public static void talkToTarget(
EntityDamsel entity,
LivingEntity target,
DialogueCategory category
) {
if (target instanceof Player player) {
talkTo(entity, player, category);
}
}
/**
* Make entity say a custom message to their target (if player).
*/
public static void talkToTarget(
EntityDamsel entity,
LivingEntity target,
String message
) {
if (target instanceof Player player) {
talkTo(entity, player, message);
}
}
/**
* Make entity perform action to their target (if player).
*/
public static void actionToTarget(
EntityDamsel entity,
LivingEntity target,
DialogueCategory category
) {
if (target instanceof Player player) {
actionTo(entity, player, category);
}
}
/**
* Make entity perform custom action to their target (if player).
*/
public static void actionToTarget(
EntityDamsel entity,
LivingEntity target,
String action
) {
if (target instanceof Player player) {
actionTo(entity, player, action);
}
}
// ========================================
// COMPOUND MESSAGES
// ========================================
/**
* Get a dialogue for job assignment with item name.
*/
public static String getJobAssignmentDialogue(String itemName) {
return getDialogue(DialogueCategory.JOB_ASSIGNED) + itemName;
}
/**
* Get a call for help dialogue with player name.
*
* @deprecated Use {@link #callForHelp(EntityDamsel, Player)} which builds proper context.
*/
@Deprecated
public static String getCallForHelpDialogue(String playerName) {
// Legacy fallback - manually substitute {player}
String text = getDialogue(DialogueCategory.DAMSEL_CALL_FOR_HELP);
return text.replace("{player}", playerName);
}
/**
* Get a sale offer dialogue with player name and price.
* The {player} placeholder is the buyer, {target} is the price.
*
* @param playerName The buyer's name
* @param price The price as a display string (e.g., "50 gold")
* @return Formatted dialogue text
*/
public static String getSaleOfferDialogue(String playerName, String price) {
// Substitute placeholders manually since we don't have full context
String text = getDialogue(DialogueCategory.SALE_OFFER);
text = text.replace("{player}", playerName);
text = text.replace("{target}", price); // {target} is used for price in sale dialogues
return text;
}
/**
* Make a tied damsel call for help to a nearby player.
* Pre-conditions (tied, slave, not gagged) should be checked by caller.
*
* @param damsel The damsel calling for help
* @param targetPlayer The player to call for help
*/
public static void callForHelp(EntityDamsel damsel, Player targetPlayer) {
if (damsel == null || targetPlayer == null) return;
// Use context-aware dialogue for proper {player} substitution
String message = getDialogue(
damsel,
targetPlayer,
DialogueCategory.DAMSEL_CALL_FOR_HELP
);
talkTo(damsel, targetPlayer, message);
}
/**
* Get a sale announcement with coordinates.
*/
public static String getSaleAnnouncement(
EntityDamsel entity,
String slaveName
) {
return String.format(
"%s is selling %s at coordinates: %d, %d, %d",
entity.getNpcName(),
slaveName,
(int) entity.getX(),
(int) entity.getY(),
(int) entity.getZ()
);
}
// ========================================
// PERSONALITY SYSTEM HELPERS
// ========================================
/**
* Get personality hint dialogue based on personality type name.
*
* @param personalityTypeName The name of the personality type (from PersonalityType enum)
* @return The hint dialogue category, or null if unknown
*/
public static DialogueCategory getPersonalityHintCategory(
String personalityTypeName
) {
return switch (personalityTypeName.toUpperCase()) {
case "TIMID" -> DialogueCategory.PERSONALITY_HINT_TIMID;
case "GENTLE" -> DialogueCategory.PERSONALITY_HINT_GENTLE;
case "SUBMISSIVE" -> DialogueCategory.PERSONALITY_HINT_SUBMISSIVE;
case "CALM" -> DialogueCategory.PERSONALITY_HINT_CALM;
case "CURIOUS" -> DialogueCategory.PERSONALITY_HINT_CURIOUS;
case "PROUD" -> DialogueCategory.PERSONALITY_HINT_PROUD;
case "FIERCE" -> DialogueCategory.PERSONALITY_HINT_FIERCE;
case "DEFIANT" -> DialogueCategory.PERSONALITY_HINT_DEFIANT;
case "PLAYFUL" -> DialogueCategory.PERSONALITY_HINT_PLAYFUL;
case "MASOCHIST" -> DialogueCategory.PERSONALITY_HINT_MASOCHIST;
case "SADIST" -> DialogueCategory.PERSONALITY_HINT_SADIST;
default -> null;
};
}
/**
* Show a personality hint action to a player.
* Used when discovery level is GLIMPSE to give behavioral cues.
*
* @param entity The entity with the personality
* @param player The player to show the hint to
* @param personalityTypeName The personality type name
*/
public static void showPersonalityHint(
EntityDamsel entity,
Player player,
String personalityTypeName
) {
DialogueCategory hintCategory = getPersonalityHintCategory(
personalityTypeName
);
if (hintCategory != null) {
actionTo(entity, player, hintCategory);
}
}
/**
* Get dialogue for command response based on acceptance.
*
* @param accepted true if command was accepted, false if refused
* @param hesitated true if the NPC hesitated before responding
* @return The appropriate dialogue
*/
public static String getCommandResponseDialogue(
boolean accepted,
boolean hesitated
) {
if (!accepted) {
return getDialogue(DialogueCategory.COMMAND_REFUSE);
}
if (hesitated) {
return getDialogue(DialogueCategory.COMMAND_HESITATE);
}
return getDialogue(DialogueCategory.COMMAND_ACCEPT);
}
/**
* Get dialogue for a specific need when it's critically low.
*
* @param needType The type of need ("hunger", "comfort", "rest", "dignity")
* @return The appropriate dialogue category, or null if unknown
*/
public static DialogueCategory getNeedDialogueCategory(String needType) {
return switch (needType.toLowerCase()) {
case "hunger" -> DialogueCategory.NEED_HUNGRY;
case "rest" -> DialogueCategory.NEED_TIRED;
case "comfort" -> DialogueCategory.NEED_UNCOMFORTABLE;
case "dignity" -> DialogueCategory.NEED_DIGNITY_LOW;
default -> null;
};
}
}

View File

@@ -0,0 +1,562 @@
package com.tiedup.remake.dialogue;
import static com.tiedup.remake.util.GameConstants.*;
import com.tiedup.remake.dialogue.EmotionalContext.EmotionType;
import com.tiedup.remake.items.base.ItemGag;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.GagMaterial;
import com.tiedup.remake.util.PhoneticMapper;
import com.tiedup.remake.util.SyllableAnalyzer;
import java.util.List;
import java.util.Random;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
/**
* Phase 16: GagTalk System V4 - Realistic phonetic transformation
*
* <p>Features:
* <ul>
* <li>Phonetic-based transformation preserving word structure</li>
* <li>Syllable-aware processing for natural rhythm</li>
* <li>Emotional context detection and expression</li>
* <li>Material-specific bleed-through rates</li>
* <li>Progressive comprehension (partial understanding)</li>
* </ul>
*/
public class GagTalkManager {
private static final Random RANDOM = new Random();
private static final String[] CRIT_FAIL_SOUNDS = {
"Mmph!!",
"Mmmph...",
"Hmpf!",
"Mmm...",
"Mph?!",
"Nnnnh!",
"Hffff!",
"P-pph!",
};
/**
* Process a gagged message for any IBondageState entity.
*
* @param kidnapped The kidnapped entity (Player, EntityDamsel, etc.)
* @param gagStack The gag item stack
* @param originalMessage The original message before gagging
* @return The muffled message component
*/
public static Component processGagMessage(
IBondageState kidnapped,
ItemStack gagStack,
String originalMessage
) {
LivingEntity entity = kidnapped.asLivingEntity();
GagMaterial material = GagMaterial.CLOTH;
if (gagStack.getItem() instanceof ItemGag gag) {
material = gag.getGagMaterial();
}
// 1. EFFET DE SUFFOCATION (Si message trop long)
applySuffocationEffects(entity, originalMessage.length(), material);
// 2. CHANCE D'ECHEC CRITIQUE
Component critFailResult = checkCriticalFailure(
kidnapped,
gagStack,
originalMessage.length(),
material
);
if (critFailResult != null) {
return critFailResult;
}
// 3. DETECT OVERALL MESSAGE EMOTION
EmotionType messageEmotion = EmotionalContext.detectEmotion(
originalMessage
);
// 4. CONSTRUCTION DU MESSAGE V4
StringBuilder muffled = new StringBuilder();
String[] words = originalMessage.split("\\s+");
for (int i = 0; i < words.length; i++) {
String word = words[i];
// Progressive comprehension: longer messages get harder to understand
float positionPenalty = (i > 5) ? 0.05f * (i - 5) : 0.0f;
float baseComp = material.getComprehension();
float effectiveComprehension = Math.max(
0,
baseComp - positionPenalty
);
// Apply emotional intensity modifier
effectiveComprehension /= EmotionalContext.getIntensityMultiplier(
messageEmotion
);
// Whispering is clearer
if (EmotionalContext.shouldPreserveMore(messageEmotion)) {
effectiveComprehension *= 1.5f;
}
// Material-specific interjections (rare, natural placement)
if (RANDOM.nextFloat() < 0.03f && i > 0 && i < words.length - 1) {
muffled.append(getMaterialInterjection(material)).append(" ");
}
// Movement affects clarity
if (entity.isSprinting() || !entity.onGround()) {
effectiveComprehension *= 0.7f;
}
// Three-tier comprehension:
// - Full pass: word understood completely
// - Partial: first letter(s) + muffled rest
// - None: fully muffled
float roll = RANDOM.nextFloat();
if (roll < effectiveComprehension * 0.4f) {
// Full word passes through
muffled.append(word);
} else if (roll < effectiveComprehension) {
// Partial: first letter(s) visible + muffled rest
muffled.append(generatePartiallyMuffledWord(word, material));
} else {
// Fully muffled
muffled.append(generateMuffledWord(word, material));
}
// Word separator
if (i < words.length - 1) {
if (RANDOM.nextFloat() < 0.1f) {
muffled.append("-");
} else {
muffled.append(" ");
}
}
}
// 5. PENSEE INTERNE (Visible seulement pour l'entite)
kidnapped.sendMessage(
Component.literal("(")
.append(
Component.literal(originalMessage).withStyle(
ChatFormatting.ITALIC
)
)
.append(")")
.withStyle(ChatFormatting.GRAY),
false
);
return Component.literal(muffled.toString());
}
/**
* Generate a partially muffled word - first part recognizable, rest muffled.
*/
private static String generatePartiallyMuffledWord(
String original,
GagMaterial material
) {
if (original.isEmpty()) return "";
if (original.length() <= 2) return original;
// Find a good split point (after first consonant cluster + vowel)
int splitPoint = findNaturalSplitPoint(original);
String visible = original.substring(0, splitPoint);
String toMuffle = original.substring(splitPoint);
if (toMuffle.isEmpty()) {
return visible;
}
return visible + generateMuffledWord(toMuffle, material);
}
/**
* Find a natural split point in a word for partial muffling.
*/
private static int findNaturalSplitPoint(String word) {
if (word.length() <= 2) return word.length();
// Try to split after first syllable-ish structure
boolean foundVowel = false;
for (int i = 0; i < word.length() && i < 4; i++) {
char c = Character.toLowerCase(word.charAt(i));
boolean isVowel = "aeiouy".indexOf(c) >= 0;
if (isVowel) {
foundVowel = true;
} else if (foundVowel) {
// Found consonant after vowel - good split point
return i;
}
}
// Default: first 1-2 characters
return Math.min(2, word.length());
}
/**
* Generate a fully muffled word using phonetic transformation.
*/
private static String generateMuffledWord(
String original,
GagMaterial material
) {
if (original == null || original.isEmpty()) return "";
// Extract and preserve punctuation
String punctuation = extractTrailingPunctuation(original);
String cleanWord = original.replaceAll("[^a-zA-Z]", "");
if (cleanWord.isEmpty()) {
return original; // Pure punctuation, keep as-is
}
// Preserve original case pattern
boolean wasAllCaps =
cleanWord.length() >= 2 &&
cleanWord.equals(cleanWord.toUpperCase());
// Split into syllables for rhythm preservation
List<String> syllables = SyllableAnalyzer.splitIntoSyllables(cleanWord);
StringBuilder result = new StringBuilder();
float baseBleed = material.getComprehension();
for (
int syllableIdx = 0;
syllableIdx < syllables.size();
syllableIdx++
) {
String syllable = syllables.get(syllableIdx);
// First syllable gets bonus bleed (more recognizable)
float syllableBleed = (syllableIdx == 0)
? baseBleed * 1.3f
: baseBleed;
// Stressed syllables are clearer
if (
SyllableAnalyzer.isStressedSyllable(
syllable,
syllableIdx,
syllables.size()
)
) {
syllableBleed *= 1.2f;
}
StringBuilder muffledSyllable = new StringBuilder();
char prevOutput = 0;
for (int i = 0; i < syllable.length(); i++) {
char c = syllable.charAt(i);
// Calculate bleed for this specific phoneme
float phonemeBleed =
syllableBleed * material.getBleedRateFor(c);
// Apply phonetic mapping
String mapped = PhoneticMapper.mapPhoneme(
c,
material,
phonemeBleed
);
// Avoid excessive repetition (mmmmm -> mm)
if (shouldSkipRepetition(mapped, prevOutput, muffledSyllable)) {
continue;
}
muffledSyllable.append(mapped);
if (!mapped.isEmpty()) {
prevOutput = mapped.charAt(mapped.length() - 1);
}
}
result.append(muffledSyllable);
// Occasional syllable separator for multi-syllable words
if (
syllableIdx < syllables.size() - 1 && RANDOM.nextFloat() < 0.2f
) {
result.append("-");
}
}
String muffled = result.toString();
// Collapse excessive repetitions (max 2 of same letter)
muffled = collapseRepetitions(muffled, 2);
// Ensure we have something
if (muffled.isEmpty()) {
muffled =
material.getDominantConsonant() + material.getDominantVowel();
}
// Preserve ALL CAPS if original was all caps
if (wasAllCaps) {
muffled = muffled.toUpperCase();
}
return muffled + punctuation;
}
/**
* Check if we should skip adding a mapped sound to avoid excessive repetition.
*/
private static boolean shouldSkipRepetition(
String mapped,
char prevOutput,
StringBuilder current
) {
if (mapped.isEmpty()) return true;
char firstMapped = mapped.charAt(0);
// Skip if same character repeated more than twice
if (firstMapped == prevOutput) {
int repeatCount = 0;
for (
int i = current.length() - 1;
i >= 0 && i >= current.length() - 3;
i--
) {
if (current.charAt(i) == firstMapped) {
repeatCount++;
} else {
break;
}
}
if (repeatCount >= 2) {
return true;
}
}
return false;
}
/**
* Collapse runs of repeated characters to a maximum count.
* "uuuuu" with maxRepeat=2 becomes "uu"
*/
private static String collapseRepetitions(String text, int maxRepeat) {
if (text == null || text.length() <= maxRepeat) {
return text;
}
StringBuilder result = new StringBuilder();
char prev = 0;
int count = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
char lower = Character.toLowerCase(c);
char prevLower = Character.toLowerCase(prev);
if (lower == prevLower && Character.isLetter(c)) {
count++;
if (count <= maxRepeat) {
result.append(c);
}
} else {
result.append(c);
count = 1;
}
prev = c;
}
return result.toString();
}
/**
* Extract trailing punctuation from a word.
*/
private static String extractTrailingPunctuation(String word) {
StringBuilder punct = new StringBuilder();
for (int i = word.length() - 1; i >= 0; i--) {
char c = word.charAt(i);
if (!Character.isLetter(c)) {
punct.insert(0, c);
} else {
break;
}
}
return punct.toString();
}
/**
* Get a material-specific interjection sound.
*/
private static String getMaterialInterjection(GagMaterial material) {
return switch (material) {
case BALL -> "*hhuup*";
case TAPE -> "*mmn*";
case STUFFED, SPONGE -> "*mm*";
case RING -> "*aah*";
case BITE -> "*ngh*";
case LATEX -> "*uuh*";
case BAGUETTE -> "*nom*";
case PANEL -> "*mmph*";
default -> "*mph*";
};
}
// ========================================
// SUFFOCATION & CRITICAL FAILURE HELPERS
// ========================================
/**
* Apply suffocation effects if message is too long.
* Long messages through restrictive gags cause slowness and potential blindness.
*
* @param entity The entity speaking
* @param messageLength The length of the message
* @param material The gag material type
*/
private static void applySuffocationEffects(
LivingEntity entity,
int messageLength,
GagMaterial material
) {
if (
messageLength > GAG_MAX_MESSAGE_LENGTH_BEFORE_SUFFOCATION &&
material != GagMaterial.CLOTH
) {
entity.addEffect(
new MobEffectInstance(
MobEffects.MOVEMENT_SLOWDOWN,
GAG_SUFFOCATION_SLOWNESS_DURATION,
1
)
);
if (RANDOM.nextFloat() < GAG_SUFFOCATION_BLINDNESS_CHANCE) {
entity.addEffect(
new MobEffectInstance(
MobEffects.BLINDNESS,
GAG_SUFFOCATION_BLINDNESS_DURATION,
0
)
);
}
}
}
/**
* Check for critical failure when speaking through gag.
* Longer messages have higher chance of complete muffling.
*
* @param kidnapped The kidnapped entity
* @param gagStack The gag item stack
* @param messageLength The length of the message
* @param material The gag material type
* @return Critical fail Component if failed, null otherwise
*/
private static Component checkCriticalFailure(
IBondageState kidnapped,
ItemStack gagStack,
int messageLength,
GagMaterial material
) {
float critChance =
GAG_BASE_CRITICAL_FAIL_CHANCE +
(messageLength * GAG_LENGTH_CRITICAL_FACTOR);
if (RANDOM.nextFloat() < critChance && material != GagMaterial.CLOTH) {
kidnapped.sendMessage(
Component.translatable(
"chat.tiedup.gag.crit_fail",
gagStack.getHoverName()
).withStyle(ChatFormatting.RED),
true
);
return Component.literal(
CRIT_FAIL_SOUNDS[RANDOM.nextInt(CRIT_FAIL_SOUNDS.length)]
);
}
return null;
}
// ========================================
// MCA INTEGRATION METHODS
// ========================================
/**
* Transform a message to gagged speech without side effects.
* For use with MCA villagers and AI chat.
*
* <p>Unlike {@link #processGagMessage}, this method:
* <ul>
* <li>Does not apply suffocation effects</li>
* <li>Does not show internal thoughts</li>
* <li>Does not have critical fail chance</li>
* <li>Simply returns the muffled text</li>
* </ul>
*
* @param originalMessage The original message to transform
* @param gagStack The gag item stack (determines material)
* @return The muffled message string
*/
public static String transformToGaggedSpeech(
String originalMessage,
ItemStack gagStack
) {
if (originalMessage == null || originalMessage.isEmpty()) {
return "";
}
GagMaterial material = GagMaterial.CLOTH;
if (gagStack != null && gagStack.getItem() instanceof ItemGag gag) {
material = gag.getGagMaterial();
}
StringBuilder muffled = new StringBuilder();
String[] words = originalMessage.split("\\s+");
for (int i = 0; i < words.length; i++) {
String word = words[i];
// Three-tier comprehension for MCA as well
float comp = material.getComprehension();
float roll = RANDOM.nextFloat();
if (roll < comp * 0.4f) {
muffled.append(word);
} else if (roll < comp) {
muffled.append(generatePartiallyMuffledWord(word, material));
} else {
muffled.append(generateMuffledWord(word, material));
}
if (i < words.length - 1) {
muffled.append(RANDOM.nextFloat() < 0.1f ? "-" : " ");
}
}
return muffled.toString();
}
/**
* Transform a message to gagged speech using default cloth gag.
* Convenience method for when gag item is not available.
*
* @param originalMessage The original message to transform
* @return The muffled message string
*/
public static String transformToGaggedSpeech(String originalMessage) {
return transformToGaggedSpeech(originalMessage, ItemStack.EMPTY);
}
}

View File

@@ -0,0 +1,100 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.personality.PersonalityType;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
/**
* Interface for entities that can speak dialogue.
* Implemented by all NPCs that use the data-driven dialogue system.
*
* Dialogue System: Universal NPC dialogue support
*/
public interface IDialogueSpeaker {
/**
* Get the name displayed in dialogue chat messages.
*
* @return The speaker's display name
*/
String getDialogueName();
/**
* Get the speaker type for dialogue routing.
* Determines which dialogue folder is used.
*
* @return The speaker type
*/
SpeakerType getSpeakerType();
/**
* Get the speaker's personality for dialogue selection.
* May return null for NPCs without a personality system.
*
* @return PersonalityType or null
*/
@Nullable
PersonalityType getSpeakerPersonality();
/**
* Get the speaker's current mood (0-100).
* 50 = neutral, higher = happier, lower = angrier.
*
* @return Mood value between 0 and 100
*/
int getSpeakerMood();
/**
* Get the relation type between this speaker and a player.
* Used for dialogue condition matching.
*
* Examples:
* - "master" (player owns this NPC)
* - "captor" (kidnapper holding player)
* - "prisoner" (player is imprisoned by this NPC's camp)
* - "customer" (player is trading with this NPC)
* - null (no special relation)
*
* @param player The player to check relation with
* @return Relation type string or null
*/
@Nullable
String getTargetRelation(Player player);
/**
* Get this speaker as a LivingEntity.
* Used for getting position, level, and other entity data.
*
* @return The entity implementing this interface
*/
LivingEntity asEntity();
/**
* Get a cooldown timer to prevent dialogue spam.
* Returns 0 if dialogue is allowed now.
*
* @return Remaining cooldown ticks (0 = can speak)
*/
default int getDialogueCooldown() {
return 0;
}
/**
* Set the dialogue cooldown timer.
*
* @param ticks Cooldown duration in ticks
*/
default void setDialogueCooldown(int ticks) {
// Default: no-op, override in implementations that track cooldown
}
/**
* Check if dialogue should be processed through gag filter.
* For gagged entities, dialogue text is muffled.
*
* @return true if speaker is gagged
*/
default boolean isDialogueGagged() {
return false;
}
}

View File

@@ -0,0 +1,121 @@
package com.tiedup.remake.dialogue;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.entities.ai.kidnapper.KidnapperState;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.entity.player.Player;
/**
* Proactive dialogue trigger system for Kidnappers.
*
* This system enables kidnappers to speak unprompted based on their current state.
* Should be called periodically from the kidnapper's tick method.
*
* Universal NPC Dialogue Support
*/
public class KidnapperDialogueTriggerSystem {
/** Minimum ticks between proactive dialogues (60 seconds) */
private static final int PROACTIVE_COOLDOWN = 1200;
/** Chance to trigger proactive dialogue (1/400 per check = ~0.25% per check) */
private static final int TRIGGER_CHANCE = 400;
/** Check interval (every 20 ticks = 1 second) */
private static final int CHECK_INTERVAL = 20;
/**
* Tick the dialogue trigger system for a kidnapper.
* Should be called every tick from the kidnapper.
*
* @param kidnapper The kidnapper to check
* @param tickCount Current tick counter
*/
public static void tick(EntityKidnapper kidnapper, int tickCount) {
// Only check periodically
if (tickCount % CHECK_INTERVAL != 0) {
return;
}
// Check cooldown
if (kidnapper.getDialogueCooldown() > 0) {
return;
}
// Random chance check
if (kidnapper.getRandom().nextInt(TRIGGER_CHANCE) != 0) {
return;
}
// Get dialogue ID based on current state
String dialogueId = selectProactiveDialogue(kidnapper);
if (dialogueId == null) {
return;
}
// Find nearest player to speak to
Player nearestPlayer = findNearestPlayer(kidnapper, 10.0);
if (nearestPlayer == null) {
return;
}
// Speak
DialogueBridge.talkTo(kidnapper, nearestPlayer, dialogueId);
}
/**
* Select a proactive dialogue ID based on the kidnapper's current state.
*
* @param kidnapper The kidnapper
* @return Dialogue ID or null if no dialogue for this state
*/
@Nullable
private static String selectProactiveDialogue(EntityKidnapper kidnapper) {
KidnapperState state = kidnapper.getCurrentState();
if (state == null) {
return null;
}
return switch (state) {
case GUARD -> "guard.idle";
case PATROL -> "patrol.idle";
case ALERT -> "patrol.alert";
case SELLING -> "idle.sale_waiting";
case IDLE -> null; // No proactive dialogue when idle
default -> null;
};
}
/**
* Find the nearest player within a radius.
*
* @param kidnapper The kidnapper
* @param radius Search radius
* @return Nearest player or null
*/
@Nullable
private static Player findNearestPlayer(
EntityKidnapper kidnapper,
double radius
) {
var nearbyPlayers = kidnapper
.level()
.getEntitiesOfClass(
Player.class,
kidnapper.getBoundingBox().inflate(radius)
);
Player nearest = null;
double nearestDistSq = Double.MAX_VALUE;
for (Player player : nearbyPlayers) {
double distSq = kidnapper.distanceToSqr(player);
if (distSq < nearestDistSq) {
nearestDistSq = distSq;
nearest = player;
}
}
return nearest;
}
}

View File

@@ -0,0 +1,105 @@
package com.tiedup.remake.dialogue;
/**
* Defines the type of NPC speaker for dialogue routing.
* Each speaker type maps to a dialogue folder structure.
*
* Dialogue System: Universal NPC dialogue support
*/
public enum SpeakerType {
/**
* EntityDamsel and EntityDamselShiny.
* Uses personality-based dialogue folders (timid/, fierce/, etc.)
*/
DAMSEL("damsel"),
/**
* EntityKidnapper (base kidnapper).
* Uses theme-based personality mapping.
*/
KIDNAPPER("kidnapper"),
/**
* EntityKidnapperElite.
* More arrogant/confident dialogue tone.
*/
KIDNAPPER_ELITE("kidnapper_elite"),
/**
* EntityKidnapperArcher.
* Ranged combat specific dialogue.
*/
KIDNAPPER_ARCHER("kidnapper_archer"),
/**
* EntityKidnapperMerchant.
* Dual-mode: MERCHANT (greedy) vs HOSTILE (vengeful).
*/
MERCHANT("merchant"),
/**
* EntityMaid.
* Obedient servant dialogue.
*/
MAID("maid"),
/**
* EntityLaborGuard.
* Prison guard monitoring prisoners during labor.
*/
GUARD("guard"),
/**
* EntitySlaveTrader.
* Camp boss, business-focused dialogue.
*/
TRADER("trader"),
/**
* EntityMaster.
* Pet play master - dominant, commanding dialogue.
* Buys solo players from Kidnappers.
*/
MASTER("master");
private final String folderName;
SpeakerType(String folderName) {
this.folderName = folderName;
}
/**
* Get the dialogue folder name for this speaker type.
* Used for loading dialogue JSON files.
*
* @return Folder name (e.g., "kidnapper", "maid")
*/
public String getFolderName() {
return folderName;
}
/**
* Check if this is a kidnapper-type speaker.
* Kidnapper types use theme-based personality mapping.
*
* @return true if kidnapper, elite, archer, or merchant
*/
public boolean isKidnapperType() {
return (
this == KIDNAPPER ||
this == KIDNAPPER_ELITE ||
this == KIDNAPPER_ARCHER ||
this == MERCHANT
);
}
/**
* Check if this is a camp management speaker.
* Camp speakers have special dialogue for prisoner management.
*
* @return true if maid or trader
*/
public boolean isCampManagement() {
return this == MAID || this == TRADER || this == GUARD;
}
}

View File

@@ -0,0 +1,562 @@
package com.tiedup.remake.dialogue.conversation;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.dialogue.DialogueContext;
import com.tiedup.remake.dialogue.DialogueManager;
import com.tiedup.remake.dialogue.EntityDialogueManager;
import com.tiedup.remake.dialogue.IDialogueSpeaker;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.NpcTypeHelper;
import com.tiedup.remake.personality.PersonalityState;
import com.tiedup.remake.personality.PersonalityType;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
/**
* Manages interactive conversations between players and NPCs.
* Handles topic availability, dialogue retrieval, conversation state,
* cooldowns, refusals, and topic effects.
*
* Phase 5: Enhanced Conversation System
*/
public class ConversationManager {
/**
* Active conversations: player UUID -> conversation state
*/
private static final Map<UUID, ConversationState> activeConversations =
new HashMap<>();
/**
* Maximum distance for conversation
*/
private static final double MAX_CONVERSATION_DISTANCE = 5.0;
/**
* Conversation state holder.
*/
public static class ConversationState {
private final UUID speakerEntityId;
private final ResourceKey<Level> speakerDimension;
private final long startTime;
private ConversationTopic lastTopic;
private int messageCount;
public ConversationState(
UUID speakerEntityId,
ResourceKey<Level> speakerDimension
) {
this.speakerEntityId = speakerEntityId;
this.speakerDimension = speakerDimension;
this.startTime = System.currentTimeMillis();
this.messageCount = 0;
}
public UUID getSpeakerEntityId() {
return speakerEntityId;
}
public ResourceKey<Level> getSpeakerDimension() {
return speakerDimension;
}
public long getStartTime() {
return startTime;
}
public ConversationTopic getLastTopic() {
return lastTopic;
}
public void setLastTopic(ConversationTopic topic) {
this.lastTopic = topic;
this.messageCount++;
}
public int getMessageCount() {
return messageCount;
}
}
/**
* Check if a player can start a conversation with a speaker.
* Does not check refusal reasons - use checkRefusal for that.
*
* @param speaker The NPC to converse with
* @param player The player
* @return true if conversation can start (basic checks only)
*/
public static boolean canConverse(IDialogueSpeaker speaker, Player player) {
if (speaker == null || player == null) {
return false;
}
// Check distance
if (speaker.asEntity().distanceTo(player) > MAX_CONVERSATION_DISTANCE) {
return false;
}
// Check if speaker is alive
if (!speaker.asEntity().isAlive()) {
return false;
}
// Check if player is already in a conversation
ConversationState current = activeConversations.get(player.getUUID());
if (current != null) {
// Allow if it's with the same speaker
if (
!current
.getSpeakerEntityId()
.equals(speaker.asEntity().getUUID())
) {
return false;
}
}
return true;
}
/**
* Check if the NPC will refuse to talk and why.
*
* @param speaker The NPC
* @param player The player
* @return Refusal reason (NONE if willing to talk)
*/
public static ConversationRefusalReason checkRefusal(
IDialogueSpeaker speaker,
Player player
) {
// No refusal system — NPCs always talk
return ConversationRefusalReason.NONE;
}
/**
* Start a conversation between a player and a speaker.
*
* @param speaker The NPC to converse with
* @param player The player
* @return true if conversation started successfully
*/
public static boolean startConversation(
IDialogueSpeaker speaker,
Player player
) {
if (!canConverse(speaker, player)) {
return false;
}
UUID playerId = player.getUUID();
UUID speakerId = speaker.asEntity().getUUID();
ResourceKey<Level> dimension = speaker.asEntity().level().dimension();
// Create new conversation state
ConversationState state = new ConversationState(speakerId, dimension);
activeConversations.put(playerId, state);
TiedUpMod.LOGGER.info(
"[ConversationManager] Started conversation: player={}, speaker={}",
player.getName().getString(),
speaker.getDialogueName()
);
return true;
}
/**
* End the current conversation for a player.
* Starts cooldown on the NPC's conversation memory.
*
* @param player The player
* @param damsel The damsel (can be null)
*/
public static void endConversation(
Player player,
@Nullable EntityDamsel damsel
) {
ConversationState state = activeConversations.remove(player.getUUID());
if (state != null) {
TiedUpMod.LOGGER.info(
"[ConversationManager] Ended conversation: player={}, messages={}",
player.getName().getString(),
state.getMessageCount()
);
}
// No conversation memory/cooldown system
}
/**
* End the current conversation for a player (legacy overload).
*
* @param player The player
*/
public static void endConversation(Player player) {
endConversation(player, null);
}
/**
* Check if a player is in an active conversation.
*
* @param player The player
* @return true if in conversation
*/
public static boolean isInConversation(Player player) {
return activeConversations.containsKey(player.getUUID());
}
/**
* Get the current conversation state for a player.
*
* @param player The player
* @return Conversation state, or null if not in conversation
*/
@Nullable
public static ConversationState getConversationState(Player player) {
return activeConversations.get(player.getUUID());
}
/**
* Get all available conversation topics for a speaker.
*
* @param speaker The NPC
* @param player The player
* @return List of available topics
*/
public static List<ConversationTopic> getAvailableTopics(
IDialogueSpeaker speaker,
Player player
) {
DialogueContext context = DialogueBridge.buildContext(speaker, player);
Set<ConversationTopic> topics = ConversationTopic.getAvailableTopics(
context
);
// Sort by category, then by name within category
return topics
.stream()
.sorted((a, b) -> {
int catCompare = a.getCategory().compareTo(b.getCategory());
if (catCompare != 0) return catCompare;
return a.getDisplayText().compareTo(b.getDisplayText());
})
.collect(Collectors.toList());
}
/**
* Get available topics by category.
*
* @param speaker The NPC
* @param player The player
* @return Map of category -> topics
*/
public static Map<
ConversationTopic.Category,
List<ConversationTopic>
> getTopicsByCategory(IDialogueSpeaker speaker, Player player) {
DialogueContext context = DialogueBridge.buildContext(speaker, player);
return ConversationTopic.getAvailableTopics(context)
.stream()
.collect(Collectors.groupingBy(ConversationTopic::getCategory));
}
/**
* Get topic effectiveness for UI display.
*
* @param speaker The NPC
* @param player The player
* @param topic The topic
* @return Effectiveness multiplier (0.2 to 1.0)
*/
public static float getTopicEffectiveness(
IDialogueSpeaker speaker,
Player player,
ConversationTopic topic
) {
return 1.0f;
}
/**
* Send a conversation topic to a speaker and get the response.
*
* @param speaker The NPC
* @param player The player
* @param topic The selected topic
* @return The response text, or null if failed
*/
@Nullable
public static String sendTopic(
IDialogueSpeaker speaker,
Player player,
ConversationTopic topic
) {
if (!canConverse(speaker, player)) {
TiedUpMod.LOGGER.warn(
"[ConversationManager] Cannot send topic: conversation not valid"
);
return null;
}
DialogueContext context = DialogueBridge.buildContext(speaker, player);
// Check if topic is available
if (!topic.isAvailableFor(context)) {
TiedUpMod.LOGGER.warn(
"[ConversationManager] Topic {} not available for context",
topic.name()
);
return null;
}
// Get response from DialogueManager
String response = DialogueManager.getDialogue(
topic.getDialogueId(),
context
);
if (response == null) {
TiedUpMod.LOGGER.warn(
"[ConversationManager] No dialogue found for topic: {}",
topic.getDialogueId()
);
return null;
}
// Update conversation state
ConversationState state = activeConversations.get(player.getUUID());
if (state != null) {
state.setLastTopic(topic);
}
// Apply effects for action topics
if (speaker.asEntity() instanceof EntityDamsel damsel && NpcTypeHelper.isDamselOnly(speaker.asEntity())) {
applyTopicEffects(damsel, player, topic);
}
return response;
}
/**
* Apply comprehensive topic effects based on the topic selected.
* Uses TopicEffect system with personality modifiers and fatigue.
*
* @param damsel The damsel entity
* @param player The player
* @param topic The topic that was selected
*/
private static void applyTopicEffects(
EntityDamsel damsel,
Player player,
ConversationTopic topic
) {
PersonalityState state = damsel.getPersonalityState();
if (state == null) {
return;
}
// Get base effect for topic
TopicEffect baseEffect = TopicEffect.forTopic(topic);
// Apply personality modifier
PersonalityType personality = state.getPersonality();
TopicEffect finalEffect = baseEffect.withPersonalityModifier(
personality,
topic
);
// Apply mood effect only (no relationship/resentment/memory)
if (finalEffect.hasEffect() && finalEffect.moodChange() != 0) {
state.modifyMood(finalEffect.moodChange());
}
}
/**
* Send a topic and display the response to the player.
* This is the main method to use from network packets.
*
* @param speaker The NPC
* @param player The server player
* @param topic The selected topic
*/
public static void handleTopicSelection(
IDialogueSpeaker speaker,
ServerPlayer player,
ConversationTopic topic
) {
String response = sendTopic(speaker, player, topic);
if (
response != null &&
speaker.asEntity() instanceof EntityDamsel damsel &&
NpcTypeHelper.isDamselOnly(speaker.asEntity())
) {
// Send the response as a dialogue message
EntityDialogueManager.talkTo(damsel, player, response);
}
}
/**
* Clean up conversation state for a disconnecting player.
* Called from {@link com.tiedup.remake.events.lifecycle.PlayerDisconnectHandler}.
*
* @param playerId UUID of the disconnecting player
*/
public static void cleanupPlayer(java.util.UUID playerId) {
ConversationState removed = activeConversations.remove(playerId);
if (removed != null) {
TiedUpMod.LOGGER.debug(
"[ConversationManager] Cleaned up conversation for disconnecting player {}",
playerId
);
}
}
/**
* Clean up stale conversations (called periodically).
*/
public static void cleanupStaleConversations() {
long now = System.currentTimeMillis();
long maxAge = 5 * 60 * 1000; // 5 minutes
activeConversations
.entrySet()
.removeIf(entry -> {
if (now - entry.getValue().getStartTime() > maxAge) {
TiedUpMod.LOGGER.debug(
"[ConversationManager] Cleaned up stale conversation for player {}",
entry.getKey()
);
return true;
}
return false;
});
}
/**
* Open a conversation GUI for a player with an NPC.
* Called from server side - sends packet to open client GUI.
* Checks for refusal reasons before opening.
*
* @param speaker The NPC to converse with
* @param player The server player
* @return true if conversation was opened
*/
public static boolean openConversation(
IDialogueSpeaker speaker,
ServerPlayer player
) {
if (!canConverse(speaker, player)) {
TiedUpMod.LOGGER.warn(
"[ConversationManager] Cannot open conversation: canConverse returned false"
);
return false;
}
// Check for refusal
ConversationRefusalReason refusal = checkRefusal(speaker, player);
if (refusal != ConversationRefusalReason.NONE) {
// Send refusal dialogue to player
if (
refusal.hasDialogue() &&
speaker.asEntity() instanceof EntityDamsel damsel &&
NpcTypeHelper.isDamselOnly(speaker.asEntity())
) {
DialogueContext context = DialogueBridge.buildContext(
speaker,
player
);
String refusalMsg = DialogueManager.getDialogue(
refusal.getDialogueId(),
context
);
if (refusalMsg != null) {
EntityDialogueManager.talkTo(damsel, player, refusalMsg);
} else {
// Fallback message
EntityDialogueManager.talkTo(
damsel,
player,
getDefaultRefusalMessage(refusal)
);
}
}
TiedUpMod.LOGGER.info(
"[ConversationManager] Conversation refused: player={}, reason={}",
player.getName().getString(),
refusal.name()
);
return false;
}
// Start the conversation state
if (!startConversation(speaker, player)) {
return false;
}
// Get available topics
List<ConversationTopic> topics = getAvailableTopics(speaker, player);
if (topics.isEmpty()) {
TiedUpMod.LOGGER.warn(
"[ConversationManager] No available topics for conversation"
);
endConversation(player);
return false;
}
// Send packet to open conversation GUI
com.tiedup.remake.network.ModNetwork.sendToPlayer(
new com.tiedup.remake.network.conversation.PacketOpenConversation(
speaker.asEntity().getId(),
speaker.getDialogueName(),
topics
),
player
);
TiedUpMod.LOGGER.info(
"[ConversationManager] Opened conversation GUI for {} with {} ({} topics)",
player.getName().getString(),
speaker.getDialogueName(),
topics.size()
);
return true;
}
/**
* Get default refusal message when dialogue is not available.
*
* @param reason The refusal reason
* @return Default message
*/
private static String getDefaultRefusalMessage(
ConversationRefusalReason reason
) {
return switch (reason) {
case COOLDOWN -> "*looks away* We just talked...";
case LOW_MOOD -> "*stares at the ground* I don't feel like talking...";
case HIGH_RESENTMENT -> "*glares silently*";
case FEAR_OF_PLAYER -> "*looks away nervously*";
case EXHAUSTED -> "*yawns* Too tired...";
case TOPIC_LIMIT -> "*sighs* I'm tired of talking...";
default -> "...";
};
}
}

View File

@@ -0,0 +1,100 @@
package com.tiedup.remake.dialogue.conversation;
/**
* Reasons why an NPC might refuse to engage in conversation.
* Each reason has a corresponding dialogue ID for personality-specific responses.
*
* Phase 5: Enhanced Conversation System
*/
public enum ConversationRefusalReason {
/** No refusal - NPC will talk */
NONE("", false),
/** Cooldown active - recently finished talking */
COOLDOWN("conversation.refusal.cooldown", false),
/** Mood too low (< 20) - not feeling talkative */
LOW_MOOD("conversation.refusal.low_mood", true),
/** Resentment too high (> 70) - silent treatment */
HIGH_RESENTMENT("conversation.refusal.resentment", true),
/** Fear too high (> 60) - too scared to talk */
FEAR_OF_PLAYER("conversation.refusal.fear", true),
/** Rest too low (< 20) - too exhausted to talk */
EXHAUSTED("conversation.refusal.exhausted", true),
/** Topic limit reached - tired of talking this session */
TOPIC_LIMIT("conversation.refusal.tired", true);
/** Dialogue ID for the refusal message (personality-specific) */
private final String dialogueId;
/** Whether this refusal can be shown to the player as a message */
private final boolean hasDialogue;
ConversationRefusalReason(String dialogueId, boolean hasDialogue) {
this.dialogueId = dialogueId;
this.hasDialogue = hasDialogue;
}
/**
* Get the dialogue ID for this refusal reason.
* Used to fetch personality-specific refusal messages.
*
* @return Dialogue ID string
*/
public String getDialogueId() {
return dialogueId;
}
/**
* Whether this refusal has associated dialogue.
*
* @return true if there's a dialogue response
*/
public boolean hasDialogue() {
return hasDialogue;
}
/**
* Get the translation key for the reason (for UI display).
*
* @return Translation key
*/
public String getTranslationKey() {
return "conversation.refusal." + this.name().toLowerCase();
}
/**
* Get how long (in ticks) before the player can try again.
* Returns 0 for reasons that aren't time-based.
*
* @return Retry delay in ticks, or 0
*/
public int getRetryDelay() {
return switch (this) {
case COOLDOWN -> 1200; // 1 minute cooldown in ticks
case TOPIC_LIMIT -> 200; // 10 seconds
default -> 0; // State-based, no fixed delay
};
}
/**
* Get a suggestion for the player on how to resolve this refusal.
*
* @return Translation key for the suggestion
*/
public String getSuggestionKey() {
return switch (this) {
case COOLDOWN -> "conversation.suggestion.wait";
case LOW_MOOD -> "conversation.suggestion.improve_mood";
case HIGH_RESENTMENT -> "conversation.suggestion.reduce_resentment";
case FEAR_OF_PLAYER -> "conversation.suggestion.be_gentle";
case EXHAUSTED -> "conversation.suggestion.let_rest";
case TOPIC_LIMIT -> "conversation.suggestion.end_conversation";
default -> "";
};
}
}

View File

@@ -0,0 +1,292 @@
package com.tiedup.remake.dialogue.conversation;
import com.tiedup.remake.dialogue.DialogueContext;
import com.tiedup.remake.dialogue.SpeakerType;
import java.util.EnumSet;
import java.util.Set;
/**
* Simplified conversation topics for interactive dialogue.
* 8 core topics with significant effects, organized in 2 categories.
*
* Phase 5: Enhanced Conversation System
*/
public enum ConversationTopic {
// === ACTIONS (Always visible) ===
COMPLIMENT(
"conversation.compliment",
"Compliment",
Category.ACTION,
null,
null,
"You look nice today.",
"Give a compliment"
),
COMFORT(
"conversation.comfort",
"Comfort",
Category.ACTION,
null,
50,
"It's going to be okay.",
"Comfort them"
),
PRAISE(
"conversation.praise",
"Praise",
Category.ACTION,
null,
null,
"You did well.",
"Praise their behavior"
),
SCOLD(
"conversation.scold",
"Scold",
Category.ACTION,
null,
null,
"That was wrong.",
"Scold them"
),
THREATEN(
"conversation.threaten",
"Threaten",
Category.ACTION,
null,
null,
"Don't make me...",
"Make a threat"
),
TEASE(
"conversation.tease",
"Tease",
Category.ACTION,
null,
null,
"Having fun?",
"Tease them playfully"
),
// === QUESTIONS (Basic inquiries) ===
HOW_ARE_YOU(
"conversation.how_are_you",
"How are you?",
Category.QUESTION,
null,
null,
"How are you feeling?",
"Ask about their state"
),
WHATS_WRONG(
"conversation.whats_wrong",
"What's wrong?",
Category.QUESTION,
null,
40,
"Something seems off...",
"Ask what's bothering them"
);
/**
* Topic categories for UI organization.
*/
public enum Category {
ACTION("Actions"),
QUESTION("Questions");
private final String displayName;
Category(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
private final String dialogueId;
private final String displayText;
private final Category category;
private final Integer minMood;
private final Integer maxMood;
private final String playerText;
private final String description;
ConversationTopic(
String dialogueId,
String displayText,
Category category,
Integer minMood,
Integer maxMood,
String playerText,
String description
) {
this.dialogueId = dialogueId;
this.displayText = displayText;
this.category = category;
this.minMood = minMood;
this.maxMood = maxMood;
this.playerText = playerText;
this.description = description;
}
/**
* Get the dialogue ID for this topic (used to fetch NPC response).
*/
public String getDialogueId() {
return dialogueId;
}
/**
* Get the display text shown in the UI button.
*/
public String getDisplayText() {
return displayText;
}
/**
* Get the topic category.
*/
public Category getCategory() {
return category;
}
/**
* Get the text the player says when selecting this topic.
*/
public String getPlayerText() {
return playerText;
}
/**
* Get a description of what this topic does.
*/
public String getDescription() {
return description;
}
/**
* Check if this topic is available for the given context.
*
* @param context The dialogue context
* @return true if the topic can be used
*/
public boolean isAvailableFor(DialogueContext context) {
// Check speaker type
if (!isValidForSpeaker(context.getSpeakerType())) {
return false;
}
// Check mood bounds
int mood = context.getMood();
if (minMood != null && mood < minMood) {
return false;
}
if (maxMood != null && mood > maxMood) {
return false;
}
return true;
}
/**
* Check if this topic is valid for a speaker type.
*
* @param speakerType The speaker type
* @return true if valid
*/
public boolean isValidForSpeaker(SpeakerType speakerType) {
// All 8 topics work with DAMSEL
return speakerType == SpeakerType.DAMSEL;
}
/**
* Check if this is an action topic (as opposed to a question).
*
* @return true if this is an action
*/
public boolean isAction() {
return category == Category.ACTION;
}
/**
* Check if this is a positive action (compliment, comfort, praise).
*
* @return true if positive
*/
public boolean isPositive() {
return this == COMPLIMENT || this == COMFORT || this == PRAISE;
}
/**
* Check if this is a negative action (scold, threaten).
*
* @return true if negative
*/
public boolean isNegative() {
return this == SCOLD || this == THREATEN;
}
/**
* Get all topics available for a given context.
*
* @param context The dialogue context
* @return Set of available topics
*/
public static Set<ConversationTopic> getAvailableTopics(
DialogueContext context
) {
Set<ConversationTopic> available = EnumSet.noneOf(
ConversationTopic.class
);
for (ConversationTopic topic : values()) {
if (topic.isAvailableFor(context)) {
available.add(topic);
}
}
return available;
}
/**
* Get topics by category that are available for a context.
*
* @param context The dialogue context
* @param category The category to filter by
* @return Set of available topics in that category
*/
public static Set<ConversationTopic> getAvailableByCategory(
DialogueContext context,
Category category
) {
Set<ConversationTopic> available = EnumSet.noneOf(
ConversationTopic.class
);
for (ConversationTopic topic : values()) {
if (topic.category == category && topic.isAvailableFor(context)) {
available.add(topic);
}
}
return available;
}
/**
* Get the translation key for this topic's display text.
*
* @return Translation key
*/
public String getTranslationKey() {
return "conversation.topic." + this.name().toLowerCase();
}
}

View File

@@ -0,0 +1,144 @@
package com.tiedup.remake.dialogue.conversation;
/**
* Enum defining requests that a pet (player) can make to their Master NPC.
* This is the inverse of ConversationTopic - these are actions initiated by the player.
*
* Each request has:
* - An ID for network serialization
* - Display text shown in the menu
* - Player speech text (what the player "says" when selecting)
* - Response dialogue ID for the Master's response
*/
public enum PetRequest {
/**
* Ask Master for food. Triggers feeding sequence with bowl placement.
*/
REQUEST_FOOD(
"request.food",
"Ask for food",
"Please, may I have something to eat?",
"petplay.feeding"
),
/**
* Ask Master to rest. Triggers resting sequence with pet bed placement.
*/
REQUEST_SLEEP(
"request.sleep",
"Ask to rest",
"I'm tired... may I rest?",
"petplay.resting"
),
/**
* Request a walk where the pet leads (Master follows).
*/
REQUEST_WALK_PASSIVE(
"request.walk_passive",
"Request a walk (you lead)",
"Can we go for a walk? I'll lead the way.",
"petplay.walk_passive"
),
/**
* Request a walk where the Master leads (Master walks, pulls pet).
*/
REQUEST_WALK_ACTIVE(
"request.walk_active",
"Request a walk (Master leads)",
"Can we go for a walk? You lead.",
"petplay.walk_active"
),
/**
* Ask Master to tie you up.
*/
REQUEST_TIE(
"request.tie",
"Ask to be tied",
"Would you tie me up, please?",
"petplay.tie_request"
),
/**
* Ask Master to untie you.
*/
REQUEST_UNTIE(
"request.untie",
"Ask to be untied",
"May I be untied, please?",
"petplay.untie_request"
),
/**
* End the conversation gracefully.
*/
END_CONVERSATION(
"request.end",
"End conversation",
"Thank you, Master.",
"petplay.dismiss"
);
private final String id;
private final String displayText;
private final String playerText;
private final String responseDialogueId;
PetRequest(
String id,
String displayText,
String playerText,
String responseDialogueId
) {
this.id = id;
this.displayText = displayText;
this.playerText = playerText;
this.responseDialogueId = responseDialogueId;
}
/**
* Get the unique ID for this request (used in network packets).
*/
public String getId() {
return id;
}
/**
* Get the display text shown in the request menu button.
*/
public String getDisplayText() {
return displayText;
}
/**
* Get the text that represents what the player "says" when making this request.
* This is displayed as player speech before the Master responds.
*/
public String getPlayerText() {
return playerText;
}
/**
* Get the dialogue ID that the Master should respond with.
*/
public String getResponseDialogueId() {
return responseDialogueId;
}
/**
* Find a PetRequest by its ID.
*
* @param id The request ID
* @return The matching PetRequest, or null if not found
*/
public static PetRequest fromId(String id) {
for (PetRequest request : values()) {
if (request.id.equals(id)) {
return request;
}
}
return null;
}
}

View File

@@ -0,0 +1,290 @@
package com.tiedup.remake.dialogue.conversation;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.dialogue.DialogueBridge;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.entities.ai.master.MasterPlaceBlockGoal;
import com.tiedup.remake.entities.ai.master.MasterState;
import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.items.base.BindVariant;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.master.PacketOpenPetRequestMenu;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/**
* Manager for handling pet requests to their Master NPC.
*
* This is the server-side handler for player-initiated requests in pet play mode.
* Utilizes existing systems:
* - PlayerEquipment.equip(BodyRegionV2.ARMS, bind) / takeBindOff() for tie/untie
* - MasterState.DOGWALK for walk mode
* - MasterPlaceBlockGoal for feeding/resting
* - Leash physics via mixin for pulling the player
*/
public class PetRequestManager {
/** Maximum distance for pet request interaction */
private static final double MAX_DISTANCE = 6.0;
/**
* Open the pet request menu for a pet player.
* Sends a packet to the client to display the GUI.
*
* @param master The master entity
* @param pet The pet player
*/
public static void openRequestMenu(EntityMaster master, ServerPlayer pet) {
if (!master.isPetPlayer(pet)) {
TiedUpMod.LOGGER.warn(
"[PetRequestManager] {} is not a pet of {}",
pet.getName().getString(),
master.getNpcName()
);
return;
}
// Check distance
double dist = master.distanceTo(pet);
if (dist > MAX_DISTANCE) {
pet.sendSystemMessage(
Component.literal("You are too far from your Master to talk.")
);
return;
}
// Send packet to open GUI on client
ModNetwork.sendToPlayer(
new PacketOpenPetRequestMenu(
master.getId(),
master.getNpcName()
),
pet
);
TiedUpMod.LOGGER.debug(
"[PetRequestManager] Opened request menu for {} with {}",
pet.getName().getString(),
master.getNpcName()
);
}
/**
* Handle a request from the pet player.
* Called when the player selects an option from the pet request menu.
*
* @param master The master entity
* @param pet The pet player making the request
* @param request The request type
*/
public static void handleRequest(
EntityMaster master,
ServerPlayer pet,
PetRequest request
) {
if (!master.isPetPlayer(pet)) {
TiedUpMod.LOGGER.warn(
"[PetRequestManager] Rejected request from non-pet {} to {}",
pet.getName().getString(),
master.getNpcName()
);
return;
}
// Check distance
double dist = master.distanceTo(pet);
if (dist > MAX_DISTANCE) {
pet.sendSystemMessage(
Component.literal("You are too far from your Master.")
);
return;
}
TiedUpMod.LOGGER.info(
"[PetRequestManager] {} requested {} from {}",
pet.getName().getString(),
request.name(),
master.getNpcName()
);
// Display what the player "says"
pet.sendSystemMessage(
Component.literal("You: " + request.getPlayerText())
);
// Handle specific request
switch (request) {
case REQUEST_FOOD -> triggerFeeding(master, pet);
case REQUEST_SLEEP -> triggerResting(master, pet);
case REQUEST_WALK_PASSIVE -> triggerDogwalk(master, pet, false);
case REQUEST_WALK_ACTIVE -> triggerDogwalk(master, pet, true);
case REQUEST_TIE -> triggerTie(master, pet);
case REQUEST_UNTIE -> triggerUntie(master, pet);
case END_CONVERSATION -> endConversation(master, pet);
}
}
/**
* Trigger feeding action - Master places bowl for pet.
*/
private static void triggerFeeding(EntityMaster master, ServerPlayer pet) {
DialogueBridge.talkTo(master, pet, "petplay.feeding");
MasterPlaceBlockGoal goal = master.getPlaceBlockGoal();
if (goal != null) {
goal.triggerFeeding();
}
}
/**
* Trigger resting action - Master places pet bed for pet.
*/
private static void triggerResting(EntityMaster master, ServerPlayer pet) {
DialogueBridge.talkTo(master, pet, "petplay.resting");
MasterPlaceBlockGoal goal = master.getPlaceBlockGoal();
if (goal != null) {
goal.triggerResting();
}
}
/**
* Trigger dogwalk mode.
* Puts dogbind on player and attaches leash.
*
* @param masterLeads If true, Master walks and pulls pet. If false, Master follows pet.
*/
private static void triggerDogwalk(
EntityMaster master,
ServerPlayer pet,
boolean masterLeads
) {
// Get player bind state
PlayerBindState state = PlayerBindState.getInstance(pet);
if (state == null) {
TiedUpMod.LOGGER.warn(
"[PetRequestManager] Could not get PlayerBindState for {}",
pet.getName().getString()
);
return;
}
// Put dogbind on player (if not already tied)
if (!state.isTiedUp()) {
ItemStack dogbind = new ItemStack(
ModItems.getBind(BindVariant.DOGBINDER)
);
state.equip(BodyRegionV2.ARMS, dogbind);
TiedUpMod.LOGGER.debug(
"[PetRequestManager] Equipped dogbind on {} for walk",
pet.getName().getString()
);
}
// Attach leash
master.attachLeashToPet();
// Set dogwalk mode
master.setDogwalkMode(masterLeads);
master.setMasterState(MasterState.DOGWALK);
String dialogueId = masterLeads
? "petplay.walk_active"
: "petplay.walk_passive";
DialogueBridge.talkTo(master, pet, dialogueId);
TiedUpMod.LOGGER.info(
"[PetRequestManager] {} entered DOGWALK mode with {} (masterLeads={})",
master.getNpcName(),
pet.getName().getString(),
masterLeads
);
}
/**
* Trigger tie request - Master ties up the pet.
*/
private static void triggerTie(EntityMaster master, ServerPlayer pet) {
// Don't allow tie requests during dogwalk
if (master.getStateManager().getCurrentState() == MasterState.DOGWALK) {
DialogueBridge.talkTo(master, pet, "petplay.busy");
return;
}
// Get player bind state
PlayerBindState state = PlayerBindState.getInstance(pet);
if (state == null) {
TiedUpMod.LOGGER.warn(
"[PetRequestManager] Could not get PlayerBindState for {}",
pet.getName().getString()
);
return;
}
// Check if already tied
if (state.isTiedUp()) {
DialogueBridge.talkTo(master, pet, "petplay.already_tied");
return;
}
// Master equips armbinder on pet (classic pet play restraint)
ItemStack bind = new ItemStack(ModItems.getBind(BindVariant.ARMBINDER));
state.equip(BodyRegionV2.ARMS, bind);
DialogueBridge.talkTo(master, pet, "petplay.tie_accept");
TiedUpMod.LOGGER.info(
"[PetRequestManager] {} tied up {} with armbinder",
master.getNpcName(),
pet.getName().getString()
);
}
/**
* Trigger untie request - Master unties the pet.
*/
private static void triggerUntie(EntityMaster master, ServerPlayer pet) {
// Don't allow untie requests during dogwalk
if (master.getStateManager().getCurrentState() == MasterState.DOGWALK) {
DialogueBridge.talkTo(master, pet, "petplay.busy");
return;
}
// Get player bind state
PlayerBindState state = PlayerBindState.getInstance(pet);
if (state == null) {
TiedUpMod.LOGGER.warn(
"[PetRequestManager] Could not get PlayerBindState for {}",
pet.getName().getString()
);
return;
}
// Check if actually tied
if (!state.isTiedUp()) {
DialogueBridge.talkTo(master, pet, "petplay.not_tied");
return;
}
// Master removes bind from pet
state.unequip(BodyRegionV2.ARMS);
DialogueBridge.talkTo(master, pet, "petplay.untie_accept");
TiedUpMod.LOGGER.info(
"[PetRequestManager] {} untied {}",
master.getNpcName(),
pet.getName().getString()
);
}
/**
* End the conversation gracefully.
*/
private static void endConversation(EntityMaster master, ServerPlayer pet) {
DialogueBridge.talkTo(master, pet, "petplay.dismiss");
}
}

View File

@@ -0,0 +1,167 @@
package com.tiedup.remake.dialogue.conversation;
import com.tiedup.remake.personality.PersonalityType;
/**
* Represents the effects of a conversation topic on an NPC's state.
* Effects are applied with personality modifiers.
*
* Phase 5: Enhanced Conversation System
*/
public record TopicEffect(
/** Mood change (-20 to +20) */
float moodChange
) {
// --- Static Factory Methods for Standard Topics ---
public static TopicEffect compliment() {
return new TopicEffect(5f);
}
public static TopicEffect comfort() {
return new TopicEffect(10f);
}
public static TopicEffect praise() {
return new TopicEffect(3f);
}
public static TopicEffect scold() {
return new TopicEffect(-5f);
}
public static TopicEffect threaten() {
return new TopicEffect(-8f);
}
public static TopicEffect tease() {
return new TopicEffect(0f);
}
public static TopicEffect howAreYou() {
return new TopicEffect(1f);
}
public static TopicEffect whatsWrong() {
return new TopicEffect(2f);
}
// --- Effect Application Methods ---
/**
* Apply personality modifier to this effect.
* Different personalities react differently to topics.
*
* @param personality The NPC's personality
* @param topic The topic being used
* @return Modified TopicEffect
*/
public TopicEffect withPersonalityModifier(
PersonalityType personality,
ConversationTopic topic
) {
float moodMult = 1.0f;
switch (personality) {
case MASOCHIST -> {
if (
topic == ConversationTopic.SCOLD ||
topic == ConversationTopic.THREATEN
) {
moodMult = -0.5f;
}
}
case SUBMISSIVE -> {
if (topic == ConversationTopic.PRAISE) {
moodMult = 1.5f;
}
}
case PLAYFUL -> {
if (topic == ConversationTopic.TEASE) {
moodMult = 3.0f;
}
}
case TIMID -> {
if (topic == ConversationTopic.COMFORT) {
moodMult = 1.5f;
}
}
case GENTLE -> {
if (
topic == ConversationTopic.COMPLIMENT ||
topic == ConversationTopic.COMFORT
) {
moodMult = 1.3f;
}
}
case PROUD -> {
if (topic == ConversationTopic.COMPLIMENT) {
moodMult = 0.5f;
}
}
default -> {
// No special handling
}
}
return new TopicEffect(this.moodChange * moodMult);
}
/**
* Apply effectiveness multiplier (from topic fatigue).
*
* @param effectiveness Multiplier (0.2 to 1.0)
* @return Scaled TopicEffect
*/
public TopicEffect withEffectiveness(float effectiveness) {
return new TopicEffect(this.moodChange * effectiveness);
}
/**
* Get the base effect for a conversation topic.
*
* @param topic The topic
* @return Base TopicEffect
*/
public static TopicEffect forTopic(ConversationTopic topic) {
return switch (topic) {
case COMPLIMENT -> compliment();
case COMFORT -> comfort();
case PRAISE -> praise();
case SCOLD -> scold();
case THREATEN -> threaten();
case TEASE -> tease();
case HOW_ARE_YOU -> howAreYou();
case WHATS_WRONG -> whatsWrong();
default -> neutral();
};
}
/**
* Create a neutral effect (no changes).
*/
public static TopicEffect neutral() {
return new TopicEffect(0f);
}
/**
* Check if this effect has any significant changes.
*
* @return true if any value is non-zero
*/
public boolean hasEffect() {
return moodChange != 0;
}
/**
* Get a descriptive string of the effect for debugging.
*
* @return Human-readable effect description
*/
public String toDebugString() {
if (moodChange != 0) {
return "Mood:" + (moodChange > 0 ? "+" : "") + moodChange;
}
return "";
}
}