Add talk circles with TopicDef, staff/phone chat, and opinion outcomes.

This commit is contained in:
Leonid Pershin
2026-08-20 08:55:55 +03:00
parent 9d96108516
commit 47fc10da02
26 changed files with 1948 additions and 175 deletions
+5
View File
@@ -315,6 +315,7 @@ public sealed class CatalogLoader
var holidays = new Dictionary<string, HolidayDef>(StringComparer.Ordinal);
var behavior = new Dictionary<string, BehaviorDef>(StringComparer.Ordinal);
var colors = new Dictionary<string, ColorDef>(StringComparer.Ordinal);
var topics = new Dictionary<string, TopicDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -380,6 +381,9 @@ public sealed class CatalogLoader
case DefKind.Color:
colors[key.Name] = Jsonc.Deserialize<ColorDef>(json);
break;
case DefKind.Topic:
topics[key.Name] = Jsonc.Deserialize<TopicDef>(json);
break;
}
}
@@ -405,6 +409,7 @@ public sealed class CatalogLoader
holidays,
behavior,
colors,
topics,
ru,
en);
}
+6
View File
@@ -28,6 +28,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, HolidayDef> holidays,
IReadOnlyDictionary<string, BehaviorDef> behavior,
IReadOnlyDictionary<string, ColorDef> colors,
IReadOnlyDictionary<string, TopicDef> topics,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -52,6 +53,7 @@ public sealed class DefCatalog
Holidays = holidays;
Behavior = behavior;
Colors = colors;
Topics = topics;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -109,6 +111,8 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, ColorDef> Colors { get; }
public IReadOnlyDictionary<string, TopicDef> Topics { get; }
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
@@ -145,6 +149,7 @@ public sealed class DefCatalog
DefKind.Holiday => Holidays.GetValueOrDefault(defName),
DefKind.Behavior => Behavior.GetValueOrDefault(defName),
DefKind.Color => Colors.GetValueOrDefault(defName),
DefKind.Topic => Topics.GetValueOrDefault(defName),
_ => null,
};
@@ -225,6 +230,7 @@ public sealed class DefCatalog
HolidayDef => DefKind.Holiday,
BehaviorDef => DefKind.Behavior,
ColorDef => DefKind.Color,
TopicDef => DefKind.Topic,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+1
View File
@@ -22,6 +22,7 @@ public enum DefKind
Holiday,
Behavior,
Color,
Topic,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
+3
View File
@@ -129,6 +129,9 @@ internal static class PackPaths
case "colors":
kind = DefKind.Color;
return true;
case "topics":
kind = DefKind.Topic;
return true;
default:
kind = default;
return false;
+64
View File
@@ -65,6 +65,13 @@ internal static class PeopleDefValidator
ValidateBehavior(behavior, log);
}
foreach (var topic in catalog.Topics.Values)
{
ValidateTopic(topic, catalog);
}
RequireTalkTopics(catalog);
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
{
throw new ContentLoadException("A catalog may only have one concrete StaffingDef.");
@@ -550,6 +557,63 @@ internal static class PeopleDefValidator
log.Warning(
$"BehaviorDef '{behavior.DefName}' lunchWeight {behavior.LunchWeight} is above dutyLessonWeight {behavior.DutyLessonWeight}; pupils will leave class for lunch.");
}
if (behavior.TalkCircleMin < 2 || behavior.TalkCircleMax < behavior.TalkCircleMin)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' talk circle size must be at least 2 with max ≥ min.");
}
}
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
{
if (topic.Abstract)
{
return;
}
if (topic.Tags.Count == 0)
{
throw new ContentLoadException($"TopicDef '{topic.DefName}' needs at least one tag.");
}
if (topic.Age is { Min: var min, Max: var max } && min > max)
{
throw new ContentLoadException($"TopicDef '{topic.DefName}' age min cannot exceed max.");
}
foreach (var role in topic.Roles)
{
if (!PersonRoles.IsKnown(role))
{
throw new ContentLoadException($"TopicDef '{topic.DefName}' has unknown role '{role}'.");
}
}
if (topic.Language is not null && !catalog.Skills.ContainsKey(topic.Language))
{
throw new ContentLoadException($"TopicDef '{topic.DefName}' references unknown SkillDef '{topic.Language}'.");
}
}
/// <summary>A catalog with circle actions but no topics would silently solo-chat — refuse load.</summary>
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)));
if (!hasTalkAction)
{
return;
}
if (!catalog.Topics.Values.Any(topic => !topic.Abstract))
{
throw new ContentLoadException(
"A catalog with talk actions requires at least one concrete TopicDef.");
}
}
private static void ValidateConditionBands(BehaviorDef behavior)
+71
View File
@@ -162,6 +162,14 @@ public sealed class TraitSkillModifier
public int Offset { get; init; }
}
/// <summary>Biases topic pick toward a tag. Used on gossip and similar traits.</summary>
public sealed class TopicTagWeight
{
public required string Tag { get; init; }
public float Weight { get; init; } = 1f;
}
public sealed class TraitDef : Def
{
public int Weight { get; init; } = 1;
@@ -190,6 +198,39 @@ public sealed class TraitDef : Def
/// cold-loving is negative.
/// </summary>
public float ComfortTemperatureOffset { get; init; }
/// <summary>How often this person initiates talk. Quiet below 1, leader above.</summary>
public float TalkInitiative { get; init; } = 1f;
/// <summary>Extra seats preferred in a circle. Social trait raises it.</summary>
public int TalkCircleBonus { get; init; }
/// <summary>Multiplies opinion shift from talk. Gossip above 1.</summary>
public float TalkOpinionMultiplier { get; init; } = 1f;
/// <summary>Pull toward topics carrying these tags when this person picks the subject.</summary>
public IReadOnlyList<TopicTagWeight> TalkTagWeights { get; init; } = [];
}
/// <summary>Conversation subject. Tags gate appropriateness; language is optional skill help.</summary>
public sealed class TopicDef : Def
{
/// <summary>Vanilla tags: study, games, food, family, sport, gossip, rude, appearance.</summary>
public IReadOnlyList<string> Tags { get; init; } = [];
public IntRange? Age { get; init; }
/// <summary>Empty means every role.</summary>
public IReadOnlyList<string> Roles { get; init; } = [];
/// <summary>Allowed as whisper on lesson — phase 43 uses this; ordinary Chat does not.</summary>
public bool WhisperOnLesson { get; init; }
/// <summary>Skill that helps when shared above threshold. Null — any language works equally.</summary>
public string? Language { get; init; }
/// <summary>Base opinion shift toward each other participant when talk succeeds.</summary>
public int OpinionShift { get; init; } = 2;
}
public sealed class StaffingDef : Def
@@ -329,6 +370,36 @@ public sealed class BehaviorDef : Def
/// </summary>
public IReadOnlyList<OpinionBand> OpinionBands { get; init; } = DefaultOpinionBands;
/// <summary>Skill points Communication gains per game hour of talk — less than a lesson.</summary>
public float TalkSkillPerHour { get; init; } = 0.02f;
/// <summary>Language used in talk, when not already at skill max.</summary>
public float TalkLanguageSkillPerHour { get; init; } = 0.005f;
/// <summary>Minimum shared language skill for full opinion shift.</summary>
public int TalkLanguageThreshold { get; init; } = 25;
/// <summary>Opinion multiplier when participants share no language above threshold.</summary>
public float TalkNoLanguageOpinionMultiplier { get; init; } = 0.15f;
/// <summary>Opinion multiplier for phone chat vs live circle.</summary>
public float TalkPhoneOpinionMultiplier { get; init; } = 0.5f;
/// <summary>How much Communication scales opinion shift (per 100 points).</summary>
public float TalkCommunicationOpinionScale { get; init; } = 0.5f;
/// <summary>Minimum circle size. Below this Chat does not start.</summary>
public int TalkCircleMin { get; init; } = 2;
/// <summary>Maximum people in one live circle.</summary>
public int TalkCircleMax { get; init; } = 4;
/// <summary>Opinion at or above — friend for invites and node pull.</summary>
public int TalkFriendThreshold { get; init; } = 40;
/// <summary>Opinion at or below — enemy; never invited, lunch nodes avoided.</summary>
public int TalkEnemyThreshold { get; init; } = -40;
public static IReadOnlyList<OpinionBand> DefaultOpinionBands { get; } =
[
new() { Min = 70, Id = "OpinionCloseFriend" },
+28
View File
@@ -0,0 +1,28 @@
namespace HSchool.Content;
/// <summary>Leisure actions that form talk circles. Names match ActionDef ids in core.</summary>
public static class TalkActions
{
public const string Chat = "Chat";
public const string StaffChat = "StaffChat";
public const string PhoneChat = "PhoneChat";
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));
}
/// <summary>Vanilla topic tags from the social slice design doc.</summary>
public static class TopicTags
{
public const string Study = "study";
public const string Games = "games";
public const string Food = "food";
public const string Family = "family";
public const string Sport = "sport";
public const string Gossip = "gossip";
public const string Rude = "rude";
public const string Appearance = "appearance";
}