Add talk circles with TopicDef, staff/phone chat, and opinion outcomes.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user