supervisor-simulator/scripts/Campus/CampusBehaviorAgent.cs
2026-01-11 19:51:49 +08:00

779 lines
22 KiB
C#

using System;
using System.Collections.Generic;
using Godot;
using Models;
/// <summary>
/// Runtime data for a campus agent. This keeps Godot nodes and pure data separate
/// so the behavior system can be tested without scene dependencies.
/// </summary>
public sealed class CampusAgentRuntime
{
public StudentModel Student { get; }
public CampusAgentNeeds Needs { get; }
public CampusTask AssignedTask { get; set; }
public CampusLocationId CurrentLocationId { get; set; } = CampusLocationId.None;
public UnitModel Unit => Student.Core;
public string Name
{
get => Student.Name;
set => Student.Name = value;
}
public CampusAgentRuntime(StudentModel student, CampusAgentNeeds needs)
{
Student = student ?? throw new ArgumentNullException(nameof(student));
Needs = needs ?? throw new ArgumentNullException(nameof(needs));
}
public bool HasTrait(string traitId) => Unit.Tags.TraitIds.Contains(traitId);
public bool HasRole(string roleId) => Unit.Tags.RoleIds.Contains(roleId);
public bool HasArchetype(string archetypeId) => Unit.Tags.ArchetypeIds.Contains(archetypeId);
}
/// <summary>
/// Intent produced by the planner. It captures both the action and the destination
/// so the state machine can move and execute without re-running the decision logic.
/// </summary>
public sealed class CampusBehaviorIntent
{
public CampusBehaviorPriority Priority { get; }
public CampusActionId ActionId { get; }
public CampusLocationId LocationId { get; }
public string Reason { get; }
public CampusBehaviorIntent(CampusBehaviorPriority priority, CampusActionId actionId, CampusLocationId locationId, string reason)
{
Priority = priority;
ActionId = actionId;
LocationId = locationId;
Reason = reason ?? string.Empty;
}
public bool Matches(CampusBehaviorIntent other)
{
if (other == null) return false;
return Priority == other.Priority && ActionId == other.ActionId && LocationId == other.LocationId;
}
}
/// <summary>
/// Shared context passed into providers/states so they can evaluate the same data
/// without hard-coding dependencies.
/// </summary>
public sealed class CampusBehaviorContext
{
public CampusAgentRuntime Agent { get; }
public CampusBehaviorConfig Config { get; }
public CampusLocationRegistry Locations { get; }
public CampusBehaviorWorld World { get; }
public Random Rng { get; }
public List<Vector2> WanderPoints { get; }
public CampusBehaviorContext(
CampusAgentRuntime agent,
CampusBehaviorConfig config,
CampusLocationRegistry locations,
CampusBehaviorWorld world,
Random rng,
List<Vector2> wanderPoints)
{
Agent = agent;
Config = config;
Locations = locations;
World = world;
Rng = rng;
WanderPoints = wanderPoints;
}
}
/// <summary>
/// Providers represent a single rule in the priority queue. Each provider returns
/// a behavior intent or null if it cannot apply to the current context.
/// </summary>
public interface ICampusBehaviorProvider
{
CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context);
}
/// <summary>
/// Critical state provider: handles sanity collapse, extreme stress, or exhaustion.
/// This is the highest priority in the decision queue.
/// </summary>
public sealed class CriticalBehaviorProvider : ICampusBehaviorProvider
{
private readonly string _grinderArchetypeId;
public CriticalBehaviorProvider(string grinderArchetypeId)
{
_grinderArchetypeId = grinderArchetypeId;
}
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
var agent = context.Agent;
var config = context.Config;
var stamina = agent.Student.Progress.Stamina.Current.Value;
var sanity = agent.Unit.Statuses.Sanity.Current.Value;
var stress = agent.Unit.Statuses.Stress.Current.Value;
if (sanity <= config.CriticalSanityThreshold || stress >= config.CriticalStressThreshold)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Critical,
CampusActionId.Staring,
CampusLocationId.ArtificialLake,
"critical_sanity_or_stress");
}
if (stamina <= config.CriticalStaminaThreshold)
{
if (agent.HasArchetype(_grinderArchetypeId) && context.Rng.NextDouble() < 0.5)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Critical,
CampusActionId.CoffeeBreak,
CampusLocationId.CoffeeShop,
"grinder_forced_coffee");
}
return new CampusBehaviorIntent(
CampusBehaviorPriority.Critical,
CampusActionId.Sleeping,
CampusLocationId.Dormitory,
"critical_stamina");
}
return null;
}
}
/// <summary>
/// Assigned task provider: if the agent has a task, it is executed before needs.
/// </summary>
public sealed class AssignedTaskBehaviorProvider : ICampusBehaviorProvider
{
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
var task = context.Agent.AssignedTask;
if (task == null || task.IsComplete) return null;
var action = MapTaskToAction(task.Type);
var location = context.Config.GetActionConfig(action)?.LocationId ?? CampusLocationId.Laboratory;
return new CampusBehaviorIntent(
CampusBehaviorPriority.AssignedTask,
action,
location,
$"assigned_task_{task.Type}");
}
public static CampusActionId MapTaskToAction(CampusTaskType taskType)
{
return taskType switch
{
CampusTaskType.Experiment => CampusActionId.Experimenting,
CampusTaskType.Writing => CampusActionId.Writing,
CampusTaskType.Administration => CampusActionId.Administration,
CampusTaskType.Exercise => CampusActionId.Running,
CampusTaskType.Coding => CampusActionId.Experimenting,
CampusTaskType.Social => CampusActionId.Socializing,
_ => CampusActionId.Wandering
};
}
}
/// <summary>
/// Needs provider: hunger, fatigue, mood, and social needs are handled here.
/// It sits below assigned tasks but above trait-driven idle behavior.
/// </summary>
public sealed class NeedsBehaviorProvider : ICampusBehaviorProvider
{
private readonly string _traitNotHumanId;
private readonly string _traitCaffeineId;
private readonly string _grinderArchetypeId;
public NeedsBehaviorProvider(string traitNotHumanId, string traitCaffeineId, string grinderArchetypeId)
{
_traitNotHumanId = traitNotHumanId;
_traitCaffeineId = traitCaffeineId;
_grinderArchetypeId = grinderArchetypeId;
}
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
var agent = context.Agent;
var config = context.Config;
var needs = agent.Needs;
var isNotHuman = agent.HasTrait(_traitNotHumanId);
if (!isNotHuman && needs.Hunger.Value <= config.HungerThreshold)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.Eating,
CampusLocationId.Canteen,
"need_hunger");
}
var stamina = agent.Student.Progress.Stamina.Current.Value;
if (!isNotHuman && (needs.Energy.Value <= config.EnergyThreshold || stamina <= config.CriticalStaminaThreshold))
{
if (agent.HasTrait(_traitCaffeineId))
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.CoffeeBreak,
CampusLocationId.CoffeeShop,
"need_caffeine");
}
if (agent.HasArchetype(_grinderArchetypeId) && context.Rng.NextDouble() < 0.5)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.CoffeeBreak,
CampusLocationId.CoffeeShop,
"grinder_refuse_sleep");
}
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.Sleeping,
CampusLocationId.Dormitory,
"need_sleep");
}
var mood = agent.Unit.Statuses.Mood.Value;
if (mood <= config.LowMoodThreshold)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.Chilling,
CampusLocationId.Dormitory,
"need_mood");
}
if (needs.Social.Value <= config.SocialThreshold)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Needs,
CampusActionId.Socializing,
CampusLocationId.CoffeeShop,
"need_social");
}
return null;
}
}
/// <summary>
/// Trait-driven provider: applies long-term personality or tag tendencies when
/// there is no urgent need.
/// </summary>
public sealed class TraitBehaviorProvider : ICampusBehaviorProvider
{
private readonly string _archetypeGrinderId;
private readonly string _archetypeSlackerId;
private readonly string _traitSocialPhobiaId;
private readonly string _traitSocialButterflyId;
private readonly string _roleLabRatId;
private readonly string _roleAlchemistId;
private readonly string _roleWriterId;
private readonly string _roleScribeId;
public TraitBehaviorProvider(
string archetypeGrinderId,
string archetypeSlackerId,
string traitSocialPhobiaId,
string traitSocialButterflyId,
string roleLabRatId,
string roleAlchemistId,
string roleWriterId,
string roleScribeId)
{
_archetypeGrinderId = archetypeGrinderId;
_archetypeSlackerId = archetypeSlackerId;
_traitSocialPhobiaId = traitSocialPhobiaId;
_traitSocialButterflyId = traitSocialButterflyId;
_roleLabRatId = roleLabRatId;
_roleAlchemistId = roleAlchemistId;
_roleWriterId = roleWriterId;
_roleScribeId = roleScribeId;
}
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
var agent = context.Agent;
if (agent.HasTrait(_traitSocialPhobiaId))
{
var quietSpot = PickLeastCrowded(context, CampusLocationId.ArtificialLake, CampusLocationId.Dormitory, CampusLocationId.Library);
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
CampusActionId.Staring,
quietSpot,
"trait_social_phobia");
}
if (agent.HasTrait(_traitSocialButterflyId))
{
var socialSpot = PickMostCrowded(context, CampusLocationId.Canteen, CampusLocationId.CoffeeShop);
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
CampusActionId.Socializing,
socialSpot,
"trait_social_butterfly");
}
if (agent.HasArchetype(_archetypeGrinderId) || agent.HasRole(_roleLabRatId) || agent.HasRole(_roleAlchemistId))
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
CampusActionId.Experimenting,
CampusLocationId.Laboratory,
"trait_prefers_lab");
}
if (agent.HasRole(_roleWriterId) || agent.HasRole(_roleScribeId))
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
CampusActionId.Writing,
CampusLocationId.Library,
"trait_prefers_library");
}
if (agent.HasArchetype(_archetypeSlackerId))
{
var action = context.Rng.NextDouble() < 0.3
? CampusActionId.Eating
: CampusActionId.Chilling;
var location = action == CampusActionId.Eating ? CampusLocationId.Canteen : CampusLocationId.Dormitory;
return new CampusBehaviorIntent(
CampusBehaviorPriority.Trait,
action,
location,
"trait_slacker");
}
return null;
}
private static CampusLocationId PickLeastCrowded(CampusBehaviorContext context, params CampusLocationId[] candidates)
{
if (candidates == null || candidates.Length == 0) return CampusLocationId.ArtificialLake;
var best = candidates[0];
var bestCount = context.World.GetOccupancy(best);
for (var i = 1; i < candidates.Length; i++)
{
var count = context.World.GetOccupancy(candidates[i]);
if (count < bestCount)
{
best = candidates[i];
bestCount = count;
}
}
return best;
}
private static CampusLocationId PickMostCrowded(CampusBehaviorContext context, params CampusLocationId[] candidates)
{
if (candidates == null || candidates.Length == 0) return CampusLocationId.Canteen;
var best = candidates[0];
var bestCount = context.World.GetOccupancy(best);
for (var i = 1; i < candidates.Length; i++)
{
var count = context.World.GetOccupancy(candidates[i]);
if (count > bestCount)
{
best = candidates[i];
bestCount = count;
}
}
return best;
}
}
/// <summary>
/// Idle provider: default fallback when nothing else applies.
/// </summary>
public sealed class IdleBehaviorProvider : ICampusBehaviorProvider
{
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Idle,
CampusActionId.Wandering,
CampusLocationId.RandomWander,
"idle_wander");
}
}
/// <summary>
/// Planner executes providers in priority order. This lets us add or remove
/// providers without editing the state machine.
/// </summary>
public sealed class CampusBehaviorPlanner
{
private readonly List<ICampusBehaviorProvider> _providers;
public CampusBehaviorPlanner(IEnumerable<ICampusBehaviorProvider> providers)
{
_providers = new List<ICampusBehaviorProvider>(providers ?? Array.Empty<ICampusBehaviorProvider>());
}
public CampusBehaviorIntent PickIntent(CampusBehaviorContext context)
{
foreach (var provider in _providers)
{
var intent = provider.TryCreateIntent(context);
if (intent != null)
{
return intent;
}
}
return new CampusBehaviorIntent(CampusBehaviorPriority.Idle, CampusActionId.Wandering, CampusLocationId.RandomWander, "fallback_idle");
}
}
/// <summary>
/// State interface for the AI FSM. Each state can transition by requesting
/// a change via the owning behavior agent.
/// </summary>
public interface ICampusBehaviorState
{
void Enter(CampusBehaviorAgent agent);
void Tick(CampusBehaviorAgent agent, float delta);
void Exit(CampusBehaviorAgent agent);
}
/// <summary>
/// State machine wrapper to enforce enter/exit semantics.
/// </summary>
public sealed class CampusBehaviorStateMachine
{
private ICampusBehaviorState _current;
public void ChangeState(CampusBehaviorAgent agent, ICampusBehaviorState next)
{
_current?.Exit(agent);
_current = next;
_current?.Enter(agent);
}
public void Tick(CampusBehaviorAgent agent, float delta)
{
_current?.Tick(agent, delta);
}
}
/// <summary>
/// Decision state: pick a new intent and immediately transition to movement.
/// This keeps the intent selection isolated and easy to extend.
/// </summary>
public sealed class CampusDecisionState : ICampusBehaviorState
{
public void Enter(CampusBehaviorAgent agent)
{
var intent = agent.PlanNextIntent();
agent.StartIntent(intent);
}
public void Tick(CampusBehaviorAgent agent, float delta)
{
}
public void Exit(CampusBehaviorAgent agent)
{
}
}
/// <summary>
/// Movement state: navigate to the intent's target location.
/// Once the agent arrives, it transitions into the action state.
/// </summary>
public sealed class CampusMoveState : ICampusBehaviorState
{
private CampusBehaviorIntent _intent;
public CampusMoveState(CampusBehaviorIntent intent)
{
_intent = intent;
}
public void Enter(CampusBehaviorAgent agent)
{
var target = agent.ResolveTargetPosition(_intent);
agent.Student.SetBehaviorTarget(target);
agent.Log($"moving_to {target} action={_intent.ActionId} reason={_intent.Reason}");
}
public void Tick(CampusBehaviorAgent agent, float delta)
{
if (agent.Student.HasReachedBehaviorTarget())
{
agent.Student.ClearBehaviorTarget();
agent.StateMachine.ChangeState(agent, new CampusActionState(_intent));
}
}
public void Exit(CampusBehaviorAgent agent)
{
}
}
/// <summary>
/// Action state: apply per-second deltas and update task progress.
/// When the action duration expires, transition back to decision.
/// </summary>
public sealed class CampusActionState : ICampusBehaviorState
{
private readonly CampusBehaviorIntent _intent;
private float _remaining;
private CampusActionConfig _actionConfig;
private float _logTimer;
public CampusActionState(CampusBehaviorIntent intent)
{
_intent = intent;
}
public void Enter(CampusBehaviorAgent agent)
{
_actionConfig = agent.Config.GetActionConfig(_intent.ActionId);
_remaining = _actionConfig?.DurationSeconds ?? 4.0f;
_logTimer = 0.0f;
agent.Runtime.CurrentLocationId = _intent.LocationId;
agent.Log($"action_start {_intent.ActionId} duration={_remaining:0.0}s reason={_intent.Reason}");
}
public void Tick(CampusBehaviorAgent agent, float delta)
{
if (_remaining <= 0.0f)
{
agent.StateMachine.ChangeState(agent, new CampusDecisionState());
return;
}
if (_actionConfig != null)
{
ApplyActionDelta(agent, delta, _actionConfig);
}
AdvanceTask(agent, delta);
_remaining -= delta;
_logTimer += delta;
if (_logTimer >= 1.5f)
{
_logTimer = 0.0f;
agent.Log($"action_tick {_intent.ActionId} remaining={Mathf.Max(0.0f, _remaining):0.0}s");
}
}
public void Exit(CampusBehaviorAgent agent)
{
agent.Runtime.CurrentLocationId = CampusLocationId.None;
agent.Log($"action_end {_intent.ActionId}");
}
private static void ApplyActionDelta(CampusBehaviorAgent agent, float delta, CampusActionConfig action)
{
var needs = agent.Runtime.Needs;
needs.Hunger.Add(action.HungerDelta * delta);
needs.Energy.Add(action.EnergyDelta * delta);
needs.Social.Add(action.SocialDelta * delta);
needs.Health.Add(action.HealthDelta * delta);
agent.Runtime.Student.Progress.Stamina.Add(action.StaminaDelta * delta);
agent.Runtime.Unit.Statuses.Stress.Add(action.StressDelta * delta);
agent.Runtime.Unit.Statuses.Sanity.Add(action.SanityDelta * delta);
agent.Runtime.Unit.Statuses.Mood.Add(action.MoodDelta * delta);
}
private void AdvanceTask(CampusBehaviorAgent agent, float delta)
{
var task = agent.Runtime.AssignedTask;
if (task == null) return;
var actionForTask = AssignedTaskBehaviorProvider.MapTaskToAction(task.Type);
if (actionForTask != _intent.ActionId) return;
task.Advance(delta);
if (task.IsComplete)
{
agent.Log($"task_complete {task.Type}");
agent.Runtime.AssignedTask = null;
}
}
}
/// <summary>
/// Main behavior agent that drives one campus character. It owns the planner,
/// state machine, and applies baseline stat decay on every tick.
/// </summary>
public sealed class CampusBehaviorAgent
{
public CampusStudent Student { get; }
public CampusAgentRuntime Runtime { get; }
public CampusBehaviorConfig Config { get; }
public CampusBehaviorStateMachine StateMachine { get; } = new();
private readonly CampusBehaviorPlanner _planner;
private readonly CampusBehaviorContext _context;
private CampusBehaviorIntent _currentIntent;
private float _decisionTimer;
public CampusBehaviorAgent(
CampusStudent student,
CampusAgentRuntime runtime,
CampusBehaviorConfig config,
CampusLocationRegistry locations,
CampusBehaviorWorld world,
Random rng,
List<Vector2> wanderPoints)
{
Student = student ?? throw new ArgumentNullException(nameof(student));
Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
Config = config ?? new CampusBehaviorConfig();
_context = new CampusBehaviorContext(runtime, Config, locations, world, rng ?? new Random(), wanderPoints ?? new List<Vector2>());
_planner = new CampusBehaviorPlanner(BuildProviders());
Student.EnableBehaviorControl();
StateMachine.ChangeState(this, new CampusDecisionState());
}
public void Tick(float delta)
{
ApplyBaselineDecay(delta);
TryInterrupt(delta);
StateMachine.Tick(this, delta);
}
public CampusBehaviorIntent PlanNextIntent()
{
var intent = _planner.PickIntent(_context);
Log($"intent {intent.Priority} {intent.ActionId} -> {intent.LocationId} reason={intent.Reason}");
return intent;
}
public void StartIntent(CampusBehaviorIntent intent)
{
_currentIntent = intent;
StateMachine.ChangeState(this, new CampusMoveState(intent));
}
public Vector2 ResolveTargetPosition(CampusBehaviorIntent intent)
{
if (intent.LocationId == CampusLocationId.RandomWander)
{
return PickRandomWanderPoint();
}
if (_context.Locations != null && _context.Locations.TryGetPosition(intent.LocationId, out var position))
{
return position;
}
return PickRandomWanderPoint();
}
public void Log(string message)
{
GD.Print($"[CampusAI:{Runtime.Name}] {message}");
}
private void ApplyBaselineDecay(float delta)
{
var needs = Runtime.Needs;
var isNotHuman = Runtime.HasTrait(CampusTraitIds.NotHuman);
var hungerDecay = Config.HungerDecayPerSecond;
if (Runtime.HasTrait(CampusTraitIds.BigEater))
{
hungerDecay *= 1.5f;
}
if (!isNotHuman)
{
needs.Hunger.Add(-hungerDecay * delta);
needs.Energy.Add(-Config.EnergyDecayPerSecond * delta);
Runtime.Student.Progress.Stamina.Add(-Config.StaminaDecayPerSecond * delta);
}
needs.Social.Add(-Config.SocialDecayPerSecond * delta);
Runtime.Unit.Statuses.Stress.Add(Config.StressGrowthPerSecond * delta);
}
private void TryInterrupt(float delta)
{
_decisionTimer += delta;
if (_decisionTimer < Config.DecisionIntervalSeconds)
{
return;
}
_decisionTimer = 0.0f;
var candidate = _planner.PickIntent(_context);
if (candidate == null) return;
if (_currentIntent == null || candidate.Priority < _currentIntent.Priority)
{
Log($"interrupt {candidate.Priority} {candidate.ActionId} reason={candidate.Reason}");
StartIntent(candidate);
}
}
private Vector2 PickRandomWanderPoint()
{
if (_context.WanderPoints == null || _context.WanderPoints.Count == 0)
{
return Student.GlobalPosition;
}
var index = _context.Rng.Next(0, _context.WanderPoints.Count);
return _context.WanderPoints[index];
}
private IEnumerable<ICampusBehaviorProvider> BuildProviders()
{
yield return new CriticalBehaviorProvider(CoreIds.ArchetypeGrinder);
yield return new AssignedTaskBehaviorProvider();
yield return new NeedsBehaviorProvider(CampusTraitIds.NotHuman, CampusTraitIds.CaffeineDependence, CoreIds.ArchetypeGrinder);
yield return new TraitBehaviorProvider(
CoreIds.ArchetypeGrinder,
CoreIds.ArchetypeSlacker,
CampusTraitIds.SocialPhobia,
CampusTraitIds.SocialButterfly,
CoreIds.RoleLabRat,
CoreIds.RoleAlchemist,
CoreIds.RoleWriter,
CoreIds.RoleScribe);
yield return new IdleBehaviorProvider();
}
}
/// <summary>
/// Centralized IDs for traits referenced by the behavior system.
/// Keeping them here avoids scattering magic strings.
/// </summary>
public static class CampusTraitIds
{
public const string CaffeineDependence = "core:trait_caffeine_dependence";
public const string SocialPhobia = "core:trait_social_phobia";
public const string SocialButterfly = "core:trait_social_butterfly";
public const string NotHuman = "core:trait_not_human";
public const string BigEater = "core:trait_big_eater";
}