using System; using System.Collections.Generic; using Godot; using Models; /// /// Runtime data for a campus agent. This keeps Godot nodes and pure data separate /// so the behavior system can be tested without scene dependencies. /// 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); } /// /// 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. /// 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; } } /// /// Shared context passed into providers/states so they can evaluate the same data /// without hard-coding dependencies. /// 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 WanderPoints { get; } public CampusBehaviorContext( CampusAgentRuntime agent, CampusBehaviorConfig config, CampusLocationRegistry locations, CampusBehaviorWorld world, Random rng, List wanderPoints) { Agent = agent; Config = config; Locations = locations; World = world; Rng = rng; WanderPoints = wanderPoints; } } /// /// 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. /// public interface ICampusBehaviorProvider { CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context); } /// /// Critical state provider: handles sanity collapse, extreme stress, or exhaustion. /// This is the highest priority in the decision queue. /// 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; } } /// /// Assigned task provider: if the agent has a task, it is executed before needs. /// 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 }; } } /// /// Needs provider: hunger, fatigue, mood, and social needs are handled here. /// It sits below assigned tasks but above trait-driven idle behavior. /// 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; } } /// /// Trait-driven provider: applies long-term personality or tag tendencies when /// there is no urgent need. /// 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; } } /// /// Idle provider: default fallback when nothing else applies. /// public sealed class IdleBehaviorProvider : ICampusBehaviorProvider { public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context) { return new CampusBehaviorIntent( CampusBehaviorPriority.Idle, CampusActionId.Wandering, CampusLocationId.RandomWander, "idle_wander"); } } /// /// Planner executes providers in priority order. This lets us add or remove /// providers without editing the state machine. /// public sealed class CampusBehaviorPlanner { private readonly List _providers; public CampusBehaviorPlanner(IEnumerable providers) { _providers = new List(providers ?? Array.Empty()); } 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"); } } /// /// State interface for the AI FSM. Each state can transition by requesting /// a change via the owning behavior agent. /// public interface ICampusBehaviorState { void Enter(CampusBehaviorAgent agent); void Tick(CampusBehaviorAgent agent, float delta); void Exit(CampusBehaviorAgent agent); } /// /// State machine wrapper to enforce enter/exit semantics. /// 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); } } /// /// Decision state: pick a new intent and immediately transition to movement. /// This keeps the intent selection isolated and easy to extend. /// 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) { } } /// /// Movement state: navigate to the intent's target location. /// Once the agent arrives, it transitions into the action state. /// 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) { } } /// /// Action state: apply per-second deltas and update task progress. /// When the action duration expires, transition back to decision. /// 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; } } } /// /// Main behavior agent that drives one campus character. It owns the planner, /// state machine, and applies baseline stat decay on every tick. /// 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 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()); _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 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(); } } /// /// Centralized IDs for traits referenced by the behavior system. /// Keeping them here avoids scattering magic strings. /// 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"; }