Add lesson whispers, teacher catch chance, and after-bell teacher talk.

Whispering cuts lesson skill gain from BehaviorDef; a teacher in the room may interrupt into a discipline circle, and pedagogy scales the pupil's opinion of the teacher.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 10:35:31 +03:00
co-authored by Cursor
parent cb869e4977
commit f7d5e0c042
22 changed files with 1061 additions and 36 deletions
+39 -4
View File
@@ -261,6 +261,11 @@ internal static class PeopleDefValidator
throw new ContentLoadException($"TraitDef '{trait.DefName}' skill modifier references unknown SkillDef '{modifier.Skill}'.");
}
}
if (trait.WhisperCatchMultiplier < 0f)
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' whisperCatchMultiplier cannot be negative.");
}
}
private static void ValidateNeed(NeedDef need)
@@ -574,6 +579,39 @@ internal static class PeopleDefValidator
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' talk circle size must be at least 2 with max ≥ min.");
}
if (behavior.LessonWhisperSkillFactor is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' lessonWhisperSkillFactor must be 01.");
}
if (behavior.WhisperCatchChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperCatchChance must be 01.");
}
if (behavior.WhisperCatchMax is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperCatchMax must be 01.");
}
if (behavior.WhisperStartChance is < 0f or > 1f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' whisperStartChance must be 01.");
}
if (behavior.TalkPedagogyOpinionScale < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' talkPedagogyOpinionScale cannot be negative.");
}
if (behavior.TeacherAfterLessonWeight < 0f
|| behavior.TeacherQuestionWeight < 0f
|| behavior.TeacherPraiseWeight < 0f
|| behavior.TeacherReprimandWeight < 0f)
{
throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' teacher-talk weights cannot be negative.");
}
}
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
@@ -611,10 +649,7 @@ internal static class PeopleDefValidator
private static void RequireTalkTopics(DefCatalog catalog)
{
var hasTalkAction = catalog.Actions.Values.Any(action =>
!action.Abstract
&& (action.DefName.Equals(TalkActions.Chat, StringComparison.Ordinal)
|| action.DefName.Equals(TalkActions.StaffChat, StringComparison.Ordinal)
|| action.DefName.Equals(TalkActions.PhoneChat, StringComparison.Ordinal)));
!action.Abstract && TalkActions.IsTalk(action.DefName));
if (!hasTalkAction)
{
return;
+36
View File
@@ -210,6 +210,11 @@ public sealed class TraitDef : Def
/// <summary>Pull toward topics carrying these tags when this person picks the subject.</summary>
public IReadOnlyList<TopicTagWeight> TalkTagWeights { get; init; } = [];
/// <summary>
/// Multiplies the chance a whisper is caught. Quiet below 1, outgoing above. Missing is 1.
/// </summary>
public float WhisperCatchMultiplier { get; init; } = 1f;
}
/// <summary>Conversation subject. Tags gate appropriateness; language is optional skill help.</summary>
@@ -415,6 +420,37 @@ public sealed class BehaviorDef : Def
/// <summary>Opinion at or below — enemy; never invited, lunch nodes avoided.</summary>
public int TalkEnemyThreshold { get; init; } = -40;
/// <summary>
/// Lesson skill multiplier while the pupil is in a whisper circle. Missing keeps a cut, not
/// a skipped lesson — whispering is costly, not a free skip.
/// </summary>
public float LessonWhisperSkillFactor { get; init; } = 0.4f;
/// <summary>Base chance the teacher notices one whisper circle. Traits scale it; never 1.</summary>
public float WhisperCatchChance { get; init; } = 0.4f;
/// <summary>Ceiling after traits. A pack cannot make every whisper a sure catch.</summary>
public float WhisperCatchMax { get; init; } = 0.85f;
/// <summary>
/// How far pedagogy 0…100 bends a pupil's opinion of the teacher. 50 is identity; above it
/// praise grows and reprimand softens, below it the reverse.
/// </summary>
public float TalkPedagogyOpinionScale { get; init; } = 0.4f;
/// <summary>Leisure weight for a teacher taking pupils after the bell. Below lesson, above chat.</summary>
public float TeacherAfterLessonWeight { get; init; } = 4f;
/// <summary>Relative weights when the teacher picks question / praise / reprimand.</summary>
public float TeacherQuestionWeight { get; init; } = 2f;
public float TeacherPraiseWeight { get; init; } = 2f;
public float TeacherReprimandWeight { get; init; } = 1f;
/// <summary>Per-decision chance a pupil in class tries to start a whisper, before initiative.</summary>
public float WhisperStartChance { get; init; } = 0.12f;
public static IReadOnlyList<OpinionBand> DefaultOpinionBands { get; } =
[
new() { Min = 70, Id = "OpinionCloseFriend" },
+19 -1
View File
@@ -6,12 +6,22 @@ public static class TalkActions
public const string Chat = "Chat";
public const string StaffChat = "StaffChat";
public const string PhoneChat = "PhoneChat";
public const string Whisper = "Whisper";
public const string TeacherTalk = "TeacherTalk";
public static bool IsTalk(string? actionId) =>
actionId is not null
&& (actionId.Equals(Chat, StringComparison.Ordinal)
|| actionId.Equals(StaffChat, StringComparison.Ordinal)
|| actionId.Equals(PhoneChat, StringComparison.Ordinal));
|| actionId.Equals(PhoneChat, StringComparison.Ordinal)
|| actionId.Equals(Whisper, StringComparison.Ordinal)
|| actionId.Equals(TeacherTalk, StringComparison.Ordinal));
public static bool IsWhisper(string? actionId) =>
actionId is not null && actionId.Equals(Whisper, StringComparison.Ordinal);
public static bool IsTeacherTalk(string? actionId) =>
actionId is not null && actionId.Equals(TeacherTalk, StringComparison.Ordinal);
}
/// <summary>Vanilla topic tags from the social slice design doc.</summary>
@@ -26,3 +36,11 @@ public static class TopicTags
public const string Rude = "rude";
public const string Appearance = "appearance";
}
/// <summary>Teacher-after-lesson and reprimand topics in core. Names match TopicDef ids.</summary>
public static class TeacherTopics
{
public const string Question = "TopicQuestion";
public const string Praise = "TopicPraise";
public const string Discipline = "TopicDiscipline";
}