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:
465
src/main/java/com/tiedup/remake/dialogue/DialogueBridge.java
Normal file
465
src/main/java/com/tiedup/remake/dialogue/DialogueBridge.java
Normal 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";
|
||||
};
|
||||
}
|
||||
}
|
||||
155
src/main/java/com/tiedup/remake/dialogue/DialogueCondition.java
Normal file
155
src/main/java/com/tiedup/remake/dialogue/DialogueCondition.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
339
src/main/java/com/tiedup/remake/dialogue/DialogueContext.java
Normal file
339
src/main/java/com/tiedup/remake/dialogue/DialogueContext.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
181
src/main/java/com/tiedup/remake/dialogue/DialogueEntry.java
Normal file
181
src/main/java/com/tiedup/remake/dialogue/DialogueEntry.java
Normal 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() +
|
||||
'}'
|
||||
);
|
||||
}
|
||||
}
|
||||
471
src/main/java/com/tiedup/remake/dialogue/DialogueLoader.java
Normal file
471
src/main/java/com/tiedup/remake/dialogue/DialogueLoader.java
Normal 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;
|
||||
}
|
||||
}
|
||||
428
src/main/java/com/tiedup/remake/dialogue/DialogueManager.java
Normal file
428
src/main/java/com/tiedup/remake/dialogue/DialogueManager.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
270
src/main/java/com/tiedup/remake/dialogue/EmotionalContext.java
Normal file
270
src/main/java/com/tiedup/remake/dialogue/EmotionalContext.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
562
src/main/java/com/tiedup/remake/dialogue/GagTalkManager.java
Normal file
562
src/main/java/com/tiedup/remake/dialogue/GagTalkManager.java
Normal 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);
|
||||
}
|
||||
}
|
||||
100
src/main/java/com/tiedup/remake/dialogue/IDialogueSpeaker.java
Normal file
100
src/main/java/com/tiedup/remake/dialogue/IDialogueSpeaker.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
105
src/main/java/com/tiedup/remake/dialogue/SpeakerType.java
Normal file
105
src/main/java/com/tiedup/remake/dialogue/SpeakerType.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 -> "...";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 -> "";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user