Strip all Phase references, TODO/FUTURE roadmap notes, and internal planning comments from the codebase. Run Prettier for consistent formatting across all Java files.
558 lines
17 KiB
Java
558 lines
17 KiB
Java
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;
|
|
|
|
/**
|
|
*
|
|
* <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);
|
|
}
|
|
}
|