Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
CI / headless-tests (push) Canceled after 49s
CI / headless-tests (push) Canceled after 49s
- Godot 4.3 project scaffold with first-person guard, procedural world, interaction HUD, access inspection, radio forms, debug overlay, shift review - Simulation core: ShiftCoordinator, VenueScenario, EventScheduler, NPCController/FSM, AccessVerifier, EscalationManager, RadioSystem, IncidentRecorder, ReputationEvaluator, CrowdSystem, EventBus - Three authored scenarios (Hollywood Blvd building, The Joke Shop, TV studio) - Custom headless test runner (25 tests) replacing editor-dependent GUT, plus GitHub Actions CI - Documentation workflow live: README, PLAN (tech stack), TODO/DONE, MEMORY
This commit is contained in:
1 parent
e23f284ad4
commit
439e0a4281
45 files changed
+4368
-7
No files matched your search
@@ -0,0 +1,115 @@
|
||||
class_name NPCController
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const FSM := preload("res://src/core/fsm.gd")
|
||||
## Simulation-side NPC (PLAN.md: NPC Behavior Types). Owns intent, an FSM, and
|
||||
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
||||
## RefCounted on purpose: headless tests and CI do not need a scene tree.
|
||||
|
||||
signal interacted(level: int, tone: int)
|
||||
|
||||
var npc_id: String = ""
|
||||
var profile: NpcProfile = null
|
||||
var intent: String = "roam" # enter | photo | sit | roam | deliver | film | trespass
|
||||
var fsm: FSM = null
|
||||
var rng := RandomNumberGenerator.new()
|
||||
|
||||
var highest_escalation_received: int = Rules.Level.OBSERVATION
|
||||
var leave_requested := false
|
||||
var reaction: int = Rules.Reaction.COMPLY
|
||||
var reaction_set := false
|
||||
|
||||
func _init() -> void:
|
||||
fsm = FSM.new(self)
|
||||
|
||||
## initial_state: APPROACH, AT_ENTRANCE, LOITERING, QUEUE, ARGUING
|
||||
func setup(p_id: String, p_profile: NpcProfile, p_intent: String = "roam", seed_value: int = 0, initial_state: String = "APPROACH") -> void:
|
||||
npc_id = p_id
|
||||
profile = p_profile
|
||||
intent = p_intent
|
||||
rng.seed = seed_value if seed_value != 0 else hash(npc_id)
|
||||
_build_fsm(initial_state)
|
||||
|
||||
func _build_fsm(initial_state: String) -> void:
|
||||
fsm.add_transition("APPROACH", "AT_ENTRANCE")
|
||||
fsm.add_transition("AT_ENTRANCE", "ARGUING")
|
||||
fsm.add_transition("AT_ENTRANCE", "LEAVING")
|
||||
fsm.add_transition("AT_ENTRANCE", "THREATENING")
|
||||
fsm.add_transition("AT_ENTRANCE", "FORCING_ENTRY")
|
||||
fsm.add_transition("APPROACH", "LEAVING")
|
||||
fsm.add_transition("ARGUING", "LEAVING")
|
||||
fsm.add_transition("ARGUING", "THREATENING")
|
||||
fsm.add_transition("ARGUING", "FORCING_ENTRY")
|
||||
fsm.add_transition("LEAVING", "LOITERING_DISTANT") # potential return later
|
||||
fsm.add_transition("LOITERING_DISTANT", "APPROACH")
|
||||
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
||||
fsm.start(initial_state)
|
||||
|
||||
func roll_reaction(level: int, tone: int) -> int:
|
||||
## Weighted pick from profile's reaction table for `level`, adjusted for tone.
|
||||
highest_escalation_received = maxi(highest_escalation_received, level)
|
||||
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
|
||||
var tone_mod: float = profile.tone_modifiers.get(tone, 0.0)
|
||||
if tone_mod > 0.0 and weights.has(Rules.Reaction.COMPLY):
|
||||
# Shift weight mass toward compliance proportionally.
|
||||
var transferable: float = float(weights[Rules.Reaction.COMPLY]) * tone_mod
|
||||
weights[Rules.Reaction.COMPLY] += transferable * 0.5
|
||||
var roll_value := rng.randf()
|
||||
var total := 0.0
|
||||
for key in weights.keys():
|
||||
total += weights[key]
|
||||
if total <= 0.0:
|
||||
return Rules.Reaction.ARGUE
|
||||
var cursor := 0.0
|
||||
for key in weights.keys():
|
||||
cursor += float(weights[key])
|
||||
if roll_value < cursor / total:
|
||||
return key
|
||||
return Rules.Reaction.LEAVE
|
||||
|
||||
func apply_player_action(level: int, tone: int) -> Dictionary:
|
||||
## Returns reaction to the player's action and updates FSM when needed.
|
||||
var performed_reaction := roll_reaction(level, tone)
|
||||
reaction = performed_reaction
|
||||
reaction_set = true
|
||||
_update_state_for_reaction(performed_reaction)
|
||||
return {
|
||||
"npc_id": npc_id,
|
||||
"level_used": level,
|
||||
"tone": tone,
|
||||
"reaction": performed_reaction,
|
||||
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
||||
"new_state": fsm.current_state,
|
||||
}
|
||||
|
||||
func _update_state_for_reaction(r: int) -> void:
|
||||
match r:
|
||||
Rules.Reaction.COMPLY, Rules.Reaction.LEAVE:
|
||||
leave_requested = true
|
||||
fsm.transition_to("LEAVING")
|
||||
Rules.Reaction.ARGUE, Rules.Reaction.RECORD:
|
||||
fsm.transition_to("ARGUING")
|
||||
Rules.Reaction.THREATEN:
|
||||
fsm.transition_to("THREATENING")
|
||||
Rules.Reaction.FORCE_ENTRY:
|
||||
fsm.transition_to("FORCING_ENTRY")
|
||||
Rules.Reaction.RETURN_LATER:
|
||||
fsm.transition_to("LOITERING_DISTANT")
|
||||
_:
|
||||
pass
|
||||
|
||||
func decide_leave() -> bool:
|
||||
return leave_requested
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"id": npc_id,
|
||||
"name": profile.display_name if profile else "",
|
||||
"type": profile.npc_type if profile else -1,
|
||||
"intent": intent,
|
||||
"state": fsm.current_state,
|
||||
"highest_escalation": highest_escalation_received,
|
||||
"reaction": reaction,
|
||||
}
|
||||
Reference in new issue
Block a user