feat: add combat, wild wolves, factions, and tribe war motivation
This commit is contained in:
@@ -62,6 +62,14 @@ func _init(
|
||||
|
||||
|
||||
func die_from_starvation() -> void:
|
||||
_apply_death()
|
||||
|
||||
|
||||
func die_from_combat() -> void:
|
||||
_apply_death()
|
||||
|
||||
|
||||
func _apply_death() -> void:
|
||||
is_dead = true
|
||||
current_task = SimulationIds.ACTION_DEAD
|
||||
task_state = TASK_STATE_IDLE
|
||||
@@ -71,7 +79,7 @@ func die_from_starvation() -> void:
|
||||
has_travel_target = false
|
||||
|
||||
if debug_logs:
|
||||
print("[SimNPC] ", npc_name, " died from starvation.")
|
||||
print("[SimNPC] ", npc_name, " died.")
|
||||
|
||||
|
||||
func get_inventory_amount(item_id: StringName) -> float:
|
||||
|
||||
@@ -10,6 +10,7 @@ const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_sy
|
||||
const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd")
|
||||
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
|
||||
const ConflictSystemScript := preload("res://simulation/conflict/ConflictSystem.gd")
|
||||
|
||||
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
|
||||
signal village_changed(village: SimVillage)
|
||||
@@ -27,6 +28,11 @@ signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord)
|
||||
signal opportunity_opened(opportunity: OpportunityStateRecord)
|
||||
signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord)
|
||||
signal opportunity_invalidated(opportunity: OpportunityStateRecord)
|
||||
signal combatant_spawned(combatant_id: StringName)
|
||||
signal combatant_died(combatant_id: StringName)
|
||||
signal raid_started(raider_ids: Array[StringName])
|
||||
signal war_resolved(outcome: StringName)
|
||||
signal war_aborted
|
||||
|
||||
var village := SimVillage.new()
|
||||
var player_system := PlayerCitizenSystem.new()
|
||||
@@ -49,6 +55,7 @@ var animal_care := AnimalCareSystemScript.new()
|
||||
var relationship_system := RelationshipSystemScript.new()
|
||||
var event_knowledge_system := EventKnowledgeSystemScript.new()
|
||||
var opportunity_system := VillageOpportunitySystem.new()
|
||||
var conflict_system := ConflictSystemScript.new()
|
||||
var storage_states: Dictionary:
|
||||
get:
|
||||
return economy.storage_states
|
||||
@@ -85,6 +92,12 @@ func _ready() -> void:
|
||||
economy.narrative_event_requested.connect(record_narrative_event)
|
||||
animal_care.economic_event_requested.connect(_record_economic_event_at)
|
||||
animal_care.narrative_event_requested.connect(record_narrative_event)
|
||||
conflict_system.narrative_event_requested.connect(record_narrative_event)
|
||||
conflict_system.combatant_spawned.connect(_on_conflict_combatant_spawned)
|
||||
conflict_system.combatant_died.connect(_on_conflict_combatant_died)
|
||||
conflict_system.raid_started.connect(func(raider_ids): raid_started.emit(raider_ids))
|
||||
conflict_system.war_resolved.connect(func(outcome): war_resolved.emit(outcome))
|
||||
conflict_system.war_aborted.connect(func(): war_aborted.emit())
|
||||
action_selector.relationship_system = relationship_system
|
||||
var definition_errors := SimulationDefinitions.validate()
|
||||
if not definition_errors.is_empty():
|
||||
@@ -99,11 +112,14 @@ func _ready() -> void:
|
||||
economy.configure(village, debug_logs)
|
||||
animal_care.configure(economy, active_world_adapter)
|
||||
player_system.configure(economy, _record_player_event_at)
|
||||
conflict_system.configure(economy)
|
||||
economy.initialize_storage()
|
||||
village.update_modifiers()
|
||||
village.update_priorities()
|
||||
generate_npcs()
|
||||
relationship_system.initialize_households(npcs)
|
||||
conflict_system.initialize_factions()
|
||||
conflict_system.register_npc_combatants(npcs)
|
||||
call_deferred("register_loaded_resource_nodes")
|
||||
animal_care.call_deferred("register_loaded_nodes")
|
||||
call_deferred("register_loaded_storage_nodes")
|
||||
@@ -182,6 +198,7 @@ func simulate_tick() -> void:
|
||||
village_was_changed = _simulate_npc_tick(npc) or village_was_changed
|
||||
if village_was_changed:
|
||||
village_changed.emit(village)
|
||||
conflict_system.advance(tick_count)
|
||||
var invalidated: OpportunityStateRecord = opportunity_system.maintain_open_opportunity(
|
||||
tick_count, get_knowledge_review_interval(), npcs, get_pantry(), get_woodpile()
|
||||
)
|
||||
@@ -266,6 +283,55 @@ func _is_weak_villager_state(npc: SimNPC) -> bool:
|
||||
)
|
||||
|
||||
|
||||
func _on_conflict_combatant_spawned(combatant_id: StringName) -> void:
|
||||
combatant_spawned.emit(combatant_id)
|
||||
|
||||
|
||||
func _on_conflict_combatant_died(combatant_id: StringName) -> void:
|
||||
combatant_died.emit(combatant_id)
|
||||
var combatant := conflict_system.get_combatant(combatant_id)
|
||||
if combatant == null or combatant.get_npc_id() < 0:
|
||||
return
|
||||
for npc in npcs:
|
||||
if npc.id != combatant.get_npc_id() or npc.is_dead:
|
||||
continue
|
||||
_handle_npc_death(npc, npc.current_task, npc.target_id)
|
||||
return
|
||||
|
||||
|
||||
func player_attack(combatant_id: StringName) -> float:
|
||||
return conflict_system.player_attack(combatant_id)
|
||||
|
||||
|
||||
func spawn_wolf(position: Vector3) -> StringName:
|
||||
return conflict_system.spawn_wolf(position)
|
||||
|
||||
|
||||
func get_combatant(combatant_id: StringName) -> CombatantStateRecord:
|
||||
return conflict_system.get_combatant(combatant_id)
|
||||
|
||||
|
||||
func get_faction(faction_id: StringName) -> FactionStateRecord:
|
||||
return conflict_system.get_faction(faction_id)
|
||||
|
||||
|
||||
func get_living_hostile_combatants() -> Array[CombatantStateRecord]:
|
||||
return conflict_system.get_living_hostiles()
|
||||
|
||||
|
||||
func get_village_defense() -> float:
|
||||
return conflict_system.village_defense()
|
||||
|
||||
|
||||
func get_village_defender_count() -> int:
|
||||
return conflict_system.village_defender_count()
|
||||
|
||||
|
||||
func get_tribe_war_plan() -> StringName:
|
||||
var tribe := conflict_system.get_faction(SimulationIds.FACTION_TRIBE)
|
||||
return tribe.get_war_plan() if tribe != null else SimulationIds.WAR_PLAN_NONE
|
||||
|
||||
|
||||
func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void:
|
||||
if not previous_target.is_empty():
|
||||
release_npc_reservation(npc.id)
|
||||
@@ -1203,6 +1269,7 @@ func create_state_record() -> SimulationStateRecord:
|
||||
for opportunity in opportunity_system.get_all_sorted():
|
||||
record.opportunities.append(opportunity)
|
||||
record.player = player_system.player_state
|
||||
conflict_system.append_state_records(record)
|
||||
return record
|
||||
|
||||
|
||||
@@ -1255,6 +1322,8 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
||||
animal_care.restore_state_records(record.animals, tick_count)
|
||||
if record.player != null:
|
||||
player_system.player_state = record.player
|
||||
conflict_system.restore_state_records(record.combatants, record.factions, tick_count)
|
||||
conflict_system.register_npc_combatants(npcs)
|
||||
village_changed.emit(village)
|
||||
state_restored.emit()
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
class_name ConflictSystem
|
||||
extends RefCounted
|
||||
|
||||
signal narrative_event_requested(
|
||||
event_type: StringName,
|
||||
actor_id: int,
|
||||
source_id: StringName,
|
||||
action_display: String,
|
||||
action_id: StringName,
|
||||
item_id: StringName,
|
||||
required_amount: float
|
||||
)
|
||||
signal combatant_spawned(combatant_id: StringName)
|
||||
signal combatant_died(combatant_id: StringName)
|
||||
signal raid_started(raider_ids: Array[StringName])
|
||||
signal war_resolved(outcome: StringName)
|
||||
signal war_aborted
|
||||
|
||||
const NPC_MAX_HEALTH := 100.0
|
||||
const STRONG_STRENGTH := 5.0
|
||||
const DEFENSE_DECISION_INTERVAL := 200
|
||||
const TRIBE_FOOD_DECAY := 0.05
|
||||
const WOLF_HUNT_RADIUS := 18.0
|
||||
const RAID_SPAWN_OFFSET := Vector3(42.0, 0.0, 42.0)
|
||||
const RAID_SPEED := 1.2
|
||||
const RAID_SIZE := 4
|
||||
const RAIDER_HEALTH := 60.0
|
||||
const WOLF_HEALTH := 40.0
|
||||
const RAIDER_REACH_FACTOR := 1.6
|
||||
|
||||
var combatants: Dictionary = {}
|
||||
var factions: Dictionary = {}
|
||||
var economy: RefCounted
|
||||
var npcs: Array[SimNPC] = []
|
||||
var next_combatant_id := 0
|
||||
var next_wolf_id := 0
|
||||
var current_tick := 0
|
||||
var village_center := Vector3.ZERO
|
||||
var last_war_resolved_tick := -1
|
||||
|
||||
|
||||
func configure(economy_service: RefCounted) -> void:
|
||||
economy = economy_service
|
||||
|
||||
|
||||
func initialize_factions() -> void:
|
||||
if not factions.has(SimulationIds.FACTION_VILLAGE):
|
||||
factions[SimulationIds.FACTION_VILLAGE] = FactionStateRecord.create(
|
||||
SimulationIds.FACTION_VILLAGE, "Village", 20.0, 0, 0.2, 0.8
|
||||
)
|
||||
if not factions.has(SimulationIds.FACTION_TRIBE):
|
||||
factions[SimulationIds.FACTION_TRIBE] = FactionStateRecord.create(
|
||||
SimulationIds.FACTION_TRIBE, "Hill Tribe", 4.0, RAID_SIZE, 0.7, 0.7
|
||||
)
|
||||
|
||||
|
||||
func register_npc_combatants(npc_list: Array[SimNPC]) -> void:
|
||||
npcs = npc_list
|
||||
var existing_ids := {}
|
||||
for combatant in combatants.values():
|
||||
var candidate := combatant as CombatantStateRecord
|
||||
if candidate != null and candidate.get_kind() == SimulationIds.COMBATANT_KIND_NPC:
|
||||
existing_ids[candidate.get_npc_id()] = true
|
||||
for npc in npcs:
|
||||
if npc.is_dead:
|
||||
continue
|
||||
if existing_ids.has(npc.id):
|
||||
continue
|
||||
_create_npc_combatant(npc)
|
||||
|
||||
|
||||
func _create_npc_combatant(npc: SimNPC) -> CombatantStateRecord:
|
||||
var weapon := (
|
||||
SimulationIds.ITEM_SWORD
|
||||
if npc.profession == SimulationIds.PROFESSION_GUARD
|
||||
else SimulationIds.ITEM_CLAW
|
||||
)
|
||||
var combatant := CombatantStateRecord.create(
|
||||
SimulationIds.npc_combatant_id(npc.id),
|
||||
SimulationIds.COMBATANT_KIND_NPC,
|
||||
SimulationIds.FACTION_VILLAGE,
|
||||
npc.npc_name,
|
||||
npc.position,
|
||||
NPC_MAX_HEALTH,
|
||||
weapon,
|
||||
npc.id
|
||||
)
|
||||
combatants[combatant.get_combatant_id()] = combatant
|
||||
return combatant
|
||||
|
||||
|
||||
func advance(simulation_tick: int) -> void:
|
||||
current_tick = simulation_tick
|
||||
_sync_npc_positions()
|
||||
_tick_cooldowns()
|
||||
_advance_raider_positions()
|
||||
_advance_wolves()
|
||||
_advance_war(simulation_tick)
|
||||
_run_battle()
|
||||
|
||||
|
||||
func _advance_raider_positions() -> void:
|
||||
if not _is_raiding():
|
||||
return
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if (
|
||||
combatant == null
|
||||
or not combatant.is_alive()
|
||||
or combatant.get_kind() != SimulationIds.COMBATANT_KIND_RAIDER
|
||||
):
|
||||
continue
|
||||
var target := _find_nearest_defender(combatant.get_position(), 100000.0)
|
||||
var target_position := village_center
|
||||
if target != null:
|
||||
target_position = target.get_position()
|
||||
var to_target := target_position - combatant.get_position()
|
||||
to_target.y = 0.0
|
||||
if to_target.length() <= 0.5:
|
||||
continue
|
||||
var step := to_target.normalized() * minf(RAID_SPEED, to_target.length())
|
||||
combatant.set_position(combatant.get_position() + step)
|
||||
|
||||
|
||||
func player_attack(combatant_id: StringName) -> float:
|
||||
var combatant := get_combatant(combatant_id)
|
||||
if combatant == null or not combatant.is_alive():
|
||||
return 0.0
|
||||
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||
var damage := sword.damage if sword != null else 24.0
|
||||
return _apply_damage(combatant, damage, SimulationIds.PLAYER_ACTOR_ID)
|
||||
|
||||
|
||||
func spawn_wolf(position: Vector3) -> StringName:
|
||||
var wolf_id := StringName("wolf_%d" % next_wolf_id)
|
||||
next_wolf_id += 1
|
||||
var wolf := CombatantStateRecord.create(
|
||||
wolf_id,
|
||||
SimulationIds.COMBATANT_KIND_WOLF,
|
||||
SimulationIds.FACTION_TRIBE,
|
||||
"Wolf",
|
||||
position,
|
||||
WOLF_HEALTH,
|
||||
SimulationIds.ITEM_CLAW,
|
||||
CombatantStateRecord.NO_NPC_ID,
|
||||
true
|
||||
)
|
||||
combatants[wolf_id] = wolf
|
||||
combatant_spawned.emit(wolf_id)
|
||||
return wolf_id
|
||||
|
||||
|
||||
func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -> float:
|
||||
if target == null or not target.is_alive() or damage <= 0.0:
|
||||
return 0.0
|
||||
var applied := target.take_damage(damage)
|
||||
if applied <= 0.0:
|
||||
return 0.0
|
||||
_narrative_event_requested(
|
||||
SimulationIds.EVENT_COMBATANT_HURT,
|
||||
actor_id,
|
||||
target.get_combatant_id(),
|
||||
"",
|
||||
"",
|
||||
target.get_weapon_id(),
|
||||
applied
|
||||
)
|
||||
if not target.is_alive():
|
||||
_kill_combatant(target, actor_id)
|
||||
return applied
|
||||
|
||||
|
||||
func _kill_combatant(target: CombatantStateRecord, killer_id: int) -> void:
|
||||
var npc_id := target.get_npc_id()
|
||||
_narrative_event_requested(
|
||||
SimulationIds.EVENT_COMBATANT_KILLED,
|
||||
killer_id,
|
||||
target.get_combatant_id(),
|
||||
target.get_display_name(),
|
||||
"",
|
||||
target.get_weapon_id(),
|
||||
0.0
|
||||
)
|
||||
combatant_died.emit(target.get_combatant_id())
|
||||
if npc_id >= 0:
|
||||
var npc := _find_npc(npc_id)
|
||||
if npc != null and not npc.is_dead:
|
||||
npc.die_from_combat()
|
||||
|
||||
|
||||
func _advance_wolves() -> void:
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if combatant == null or not combatant.is_alive():
|
||||
continue
|
||||
if combatant.get_kind() != SimulationIds.COMBATANT_KIND_WOLF:
|
||||
continue
|
||||
var target := _find_nearest_defender(combatant.get_position(), WOLF_HUNT_RADIUS)
|
||||
if target == null:
|
||||
continue
|
||||
if not combatant.is_hostile():
|
||||
combatant.set_hostile(true)
|
||||
_attack_target(combatant, target)
|
||||
|
||||
|
||||
func _advance_war(simulation_tick: int) -> void:
|
||||
var tribe := _get_faction(SimulationIds.FACTION_TRIBE)
|
||||
var village := _get_faction(SimulationIds.FACTION_VILLAGE)
|
||||
if tribe == null:
|
||||
return
|
||||
if economy != null:
|
||||
var pantry: StorageStateRecord = economy.get_pantry()
|
||||
if pantry != null:
|
||||
if village != null:
|
||||
village.set_food(pantry.get_amount(SimulationIds.RESOURCE_FOOD))
|
||||
tribe.set_food(tribe.get_food() - TRIBE_FOOD_DECAY)
|
||||
if tribe.get_war_plan() == SimulationIds.WAR_PLAN_RAIDING:
|
||||
return
|
||||
if (
|
||||
last_war_resolved_tick >= 0
|
||||
and simulation_tick - last_war_resolved_tick < DEFENSE_DECISION_INTERVAL
|
||||
):
|
||||
return
|
||||
if simulation_tick % DEFENSE_DECISION_INTERVAL != 0:
|
||||
return
|
||||
_evaluate_war(simulation_tick)
|
||||
|
||||
|
||||
func _evaluate_war(simulation_tick: int) -> void:
|
||||
var tribe := _get_faction(SimulationIds.FACTION_TRIBE)
|
||||
var village := _get_faction(SimulationIds.FACTION_VILLAGE)
|
||||
if tribe == null or village == null:
|
||||
return
|
||||
var desire := _tribe_desire(tribe, village)
|
||||
var confidence := _tribe_confidence(tribe)
|
||||
if desire < 0.5:
|
||||
tribe.set_war_plan(SimulationIds.WAR_PLAN_NONE, simulation_tick, confidence)
|
||||
return
|
||||
if confidence >= 0.55:
|
||||
if tribe.get_war_plan() == SimulationIds.WAR_PLAN_PLANNED:
|
||||
_start_raid(tribe, simulation_tick)
|
||||
else:
|
||||
tribe.set_war_plan(SimulationIds.WAR_PLAN_PLANNED, simulation_tick, confidence)
|
||||
return
|
||||
if confidence < 0.35:
|
||||
var had_plan := (
|
||||
tribe.get_war_plan()
|
||||
in [
|
||||
SimulationIds.WAR_PLAN_PLANNED,
|
||||
SimulationIds.WAR_PLAN_RAIDING,
|
||||
]
|
||||
)
|
||||
tribe.set_war_plan(SimulationIds.WAR_PLAN_ABORTED, simulation_tick, confidence)
|
||||
tribe.set_stance(SimulationIds.STANCE_NEUTRAL)
|
||||
if had_plan:
|
||||
war_aborted.emit()
|
||||
_narrative_event_requested(
|
||||
SimulationIds.EVENT_WAR_ABORTED,
|
||||
SimulationIds.PLAYER_ACTOR_ID,
|
||||
SimulationIds.FACTION_TRIBE,
|
||||
"The hill tribe read the village's strength and turned away.",
|
||||
"",
|
||||
"",
|
||||
0.0
|
||||
)
|
||||
return
|
||||
if tribe.get_war_plan() != SimulationIds.WAR_PLAN_PLANNED:
|
||||
tribe.set_war_plan(SimulationIds.WAR_PLAN_PLANNED, simulation_tick, confidence)
|
||||
|
||||
|
||||
func _tribe_desire(tribe: FactionStateRecord, village: FactionStateRecord) -> float:
|
||||
var hunger_need := clampf((30.0 - tribe.get_food()) / 30.0, 0.0, 1.0)
|
||||
var surplus_lure := clampf(village.get_food() / 30.0, 0.0, 1.0)
|
||||
return clampf((hunger_need + surplus_lure * 0.5) / 1.5, 0.0, 1.0) * tribe.get_aggression()
|
||||
|
||||
|
||||
func _tribe_confidence(tribe: FactionStateRecord) -> float:
|
||||
var morale_factor := 0.6 + 0.4 * tribe.get_morale()
|
||||
var tribe_strength := float(tribe.get_warriors()) * morale_factor
|
||||
var defense := village_defense()
|
||||
if defense <= 0.0:
|
||||
return 1.0
|
||||
return clampf(tribe_strength / (defense * 1.1), 0.0, 1.0)
|
||||
|
||||
|
||||
func village_defense() -> float:
|
||||
var defense := 0.0
|
||||
var eligible := 0
|
||||
for npc in npcs:
|
||||
if npc.is_dead:
|
||||
continue
|
||||
var is_guard := npc.profession == SimulationIds.PROFESSION_GUARD
|
||||
if npc.strength < STRONG_STRENGTH and not is_guard:
|
||||
continue
|
||||
eligible += 1
|
||||
defense += (
|
||||
1.0 + (0.5 if is_guard else 0.0) + maxf(npc.strength - STRONG_STRENGTH, 0.0) * 0.1
|
||||
)
|
||||
var village := _get_faction(SimulationIds.FACTION_VILLAGE)
|
||||
if village != null:
|
||||
defense += village.get_food() / 100.0
|
||||
return defense
|
||||
|
||||
|
||||
func village_defender_count() -> int:
|
||||
var count := 0
|
||||
for npc in npcs:
|
||||
if npc.is_dead:
|
||||
continue
|
||||
if npc.strength >= STRONG_STRENGTH or npc.profession == SimulationIds.PROFESSION_GUARD:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
|
||||
tribe.set_war_plan(SimulationIds.WAR_PLAN_RAIDING, simulation_tick)
|
||||
tribe.set_stance(SimulationIds.STANCE_HOSTILE)
|
||||
var raider_ids: Array[StringName] = []
|
||||
for index in range(tribe.get_warriors()):
|
||||
var raider_id := StringName("raider_%d" % next_combatant_id)
|
||||
next_combatant_id += 1
|
||||
var offset := Vector3(float(index * 2 - tribe.get_warriors()), 0.0, 0.0)
|
||||
var raider := CombatantStateRecord.create(
|
||||
raider_id,
|
||||
SimulationIds.COMBATANT_KIND_RAIDER,
|
||||
SimulationIds.FACTION_TRIBE,
|
||||
"Raider",
|
||||
village_center + RAID_SPAWN_OFFSET + offset,
|
||||
RAIDER_HEALTH,
|
||||
SimulationIds.ITEM_SWORD,
|
||||
CombatantStateRecord.NO_NPC_ID,
|
||||
true
|
||||
)
|
||||
combatants[raider_id] = raider
|
||||
raider_ids.append(raider_id)
|
||||
combatant_spawned.emit(raider_id)
|
||||
raid_started.emit(raider_ids)
|
||||
_narrative_event_requested(
|
||||
SimulationIds.EVENT_RAID_STARTED,
|
||||
SimulationIds.PLAYER_ACTOR_ID,
|
||||
SimulationIds.FACTION_TRIBE,
|
||||
"The hill tribe, short of food, raids the village.",
|
||||
"",
|
||||
"",
|
||||
0.0
|
||||
)
|
||||
|
||||
|
||||
func _run_battle() -> void:
|
||||
var raid_active := _is_raiding()
|
||||
var living_hostiles: Array[CombatantStateRecord] = []
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if combatant == null or not combatant.is_alive() or not combatant.is_hostile():
|
||||
continue
|
||||
living_hostiles.append(combatant)
|
||||
if not raid_active and living_hostiles.is_empty():
|
||||
return
|
||||
for hostile in living_hostiles:
|
||||
var target := _find_nearest_defender(hostile.get_position(), _reach(hostile))
|
||||
if target != null and hostile.is_attack_ready():
|
||||
_attack_target(hostile, target)
|
||||
if raid_active:
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if (
|
||||
combatant == null
|
||||
or not combatant.is_alive()
|
||||
or combatant.get_kind() != SimulationIds.COMBATANT_KIND_NPC
|
||||
):
|
||||
continue
|
||||
var hostile := _find_nearest_hostile(combatant.get_position(), _reach(combatant))
|
||||
if hostile != null and combatant.is_attack_ready():
|
||||
_attack_target(combatant, hostile)
|
||||
_resolve_raid_outcome()
|
||||
|
||||
|
||||
func _resolve_raid_outcome() -> void:
|
||||
var tribe := _get_faction(SimulationIds.FACTION_TRIBE)
|
||||
if tribe == null or tribe.get_war_plan() != SimulationIds.WAR_PLAN_RAIDING:
|
||||
return
|
||||
var living_raiders := 0
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if (
|
||||
combatant != null
|
||||
and combatant.is_alive()
|
||||
and combatant.get_kind() == SimulationIds.COMBATANT_KIND_RAIDER
|
||||
):
|
||||
living_raiders += 1
|
||||
var living_defenders := _living_village_npc_count()
|
||||
if living_raiders == 0:
|
||||
_finish_war(SimulationIds.WAR_PLAN_NONE, &"village_won")
|
||||
elif living_defenders <= 0:
|
||||
_finish_war(SimulationIds.WAR_PLAN_ABORTED, &"village_lost")
|
||||
|
||||
|
||||
func _finish_war(next_plan: StringName, outcome: StringName) -> void:
|
||||
var tribe := _get_faction(SimulationIds.FACTION_TRIBE)
|
||||
var village := _get_faction(SimulationIds.FACTION_VILLAGE)
|
||||
if tribe != null:
|
||||
tribe.set_war_plan(next_plan, current_tick)
|
||||
tribe.set_stance(SimulationIds.STANCE_NEUTRAL)
|
||||
if village != null and outcome == &"village_lost":
|
||||
var stolen := minf(village.get_food() * 0.6, 15.0)
|
||||
village.set_food(maxf(village.get_food() - stolen, 0.0))
|
||||
if tribe != null:
|
||||
tribe.set_food(tribe.get_food() + stolen)
|
||||
elif tribe != null:
|
||||
tribe.set_food(tribe.get_food() + 2.0)
|
||||
tribe.set_morale(tribe.get_morale() * 0.6)
|
||||
last_war_resolved_tick = current_tick
|
||||
war_resolved.emit(outcome)
|
||||
_narrative_event_requested(
|
||||
SimulationIds.EVENT_WAR_RESOLVED,
|
||||
SimulationIds.PLAYER_ACTOR_ID,
|
||||
SimulationIds.FACTION_TRIBE,
|
||||
(
|
||||
"The village drove off the raiders."
|
||||
if outcome == &"village_won"
|
||||
else "The raiders plundered the village."
|
||||
),
|
||||
"",
|
||||
"",
|
||||
0.0
|
||||
)
|
||||
|
||||
|
||||
func _attack_target(attacker: CombatantStateRecord, target: CombatantStateRecord) -> void:
|
||||
if not attacker.is_attack_ready() or target == null:
|
||||
return
|
||||
var damage := _attack_damage(attacker)
|
||||
var applied := _apply_damage(target, damage, _actor_id_for(attacker))
|
||||
if applied > 0.0:
|
||||
attacker.mark_attacked()
|
||||
|
||||
|
||||
func _attack_damage(attacker: CombatantStateRecord) -> float:
|
||||
var weapon := SimulationItems.get_weapon(attacker.get_weapon_id())
|
||||
var base := weapon.damage if weapon != null else 5.0
|
||||
if attacker.get_kind() != SimulationIds.COMBATANT_KIND_NPC:
|
||||
return base
|
||||
var npc := _find_npc(attacker.get_npc_id())
|
||||
return base * (1.0 + (npc.strength * 0.04 if npc != null else 0.0))
|
||||
|
||||
|
||||
func _find_nearest_defender(origin: Vector3, max_distance: float) -> CombatantStateRecord:
|
||||
var best: CombatantStateRecord
|
||||
var best_distance := max_distance * max_distance
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if combatant == null or not combatant.is_alive() or combatant.is_hostile():
|
||||
continue
|
||||
if (
|
||||
combatant.get_kind()
|
||||
not in [SimulationIds.COMBATANT_KIND_NPC, SimulationIds.COMBATANT_KIND_PLAYER]
|
||||
):
|
||||
continue
|
||||
var distance := origin.distance_squared_to(combatant.get_position())
|
||||
if distance > best_distance:
|
||||
continue
|
||||
best = combatant
|
||||
best_distance = distance
|
||||
return best
|
||||
|
||||
|
||||
func _find_nearest_hostile(origin: Vector3, max_distance: float) -> CombatantStateRecord:
|
||||
var best: CombatantStateRecord
|
||||
var best_distance := max_distance * max_distance
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if combatant == null or not combatant.is_alive() or not combatant.is_hostile():
|
||||
continue
|
||||
var distance := origin.distance_squared_to(combatant.get_position())
|
||||
if distance > best_distance:
|
||||
continue
|
||||
best = combatant
|
||||
best_distance = distance
|
||||
return best
|
||||
|
||||
|
||||
func _reach(combatant: CombatantStateRecord) -> float:
|
||||
var weapon := SimulationItems.get_weapon(combatant.get_weapon_id())
|
||||
if weapon == null:
|
||||
return 1.5
|
||||
return (
|
||||
weapon.reach * RAIDER_REACH_FACTOR
|
||||
if combatant.get_kind() == SimulationIds.COMBATANT_KIND_WOLF
|
||||
else weapon.reach
|
||||
)
|
||||
|
||||
|
||||
func _actor_id_for(combatant: CombatantStateRecord) -> int:
|
||||
if combatant.get_npc_id() >= 0:
|
||||
return combatant.get_npc_id()
|
||||
if combatant.get_kind() == SimulationIds.COMBATANT_KIND_RAIDER:
|
||||
return SimulationIds.PLAYER_ACTOR_ID - 1
|
||||
return SimulationIds.PLAYER_ACTOR_ID - 2
|
||||
|
||||
|
||||
func _living_village_npc_count() -> int:
|
||||
var count := 0
|
||||
for npc in npcs:
|
||||
if not npc.is_dead:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _is_raiding() -> bool:
|
||||
var tribe := _get_faction(SimulationIds.FACTION_TRIBE)
|
||||
return tribe != null and tribe.get_war_plan() == SimulationIds.WAR_PLAN_RAIDING
|
||||
|
||||
|
||||
func _sync_npc_positions() -> void:
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if combatant == null or combatant.get_kind() != SimulationIds.COMBATANT_KIND_NPC:
|
||||
continue
|
||||
var npc := _find_npc(combatant.get_npc_id())
|
||||
if npc == null:
|
||||
continue
|
||||
if npc.is_dead:
|
||||
if combatant.is_alive():
|
||||
combatant.take_damage(combatant.get_health())
|
||||
continue
|
||||
combatant.set_position(npc.position)
|
||||
|
||||
|
||||
func _tick_cooldowns() -> void:
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if combatant != null:
|
||||
combatant.tick_cooldown()
|
||||
|
||||
|
||||
func get_combatant(combatant_id: StringName) -> CombatantStateRecord:
|
||||
return combatants.get(combatant_id) as CombatantStateRecord
|
||||
|
||||
|
||||
func get_faction(faction_id: StringName) -> FactionStateRecord:
|
||||
return factions.get(faction_id) as FactionStateRecord
|
||||
|
||||
|
||||
func get_all_combatants() -> Array[CombatantStateRecord]:
|
||||
var values: Array[CombatantStateRecord] = []
|
||||
for combatant in combatants.values():
|
||||
values.append(combatant)
|
||||
return values
|
||||
|
||||
|
||||
func get_living_hostiles() -> Array[CombatantStateRecord]:
|
||||
var hostiles: Array[CombatantStateRecord] = []
|
||||
for combatant_id in _sorted_combatant_ids():
|
||||
var combatant := combatants[combatant_id] as CombatantStateRecord
|
||||
if combatant != null and combatant.is_alive() and combatant.is_hostile():
|
||||
hostiles.append(combatant)
|
||||
return hostiles
|
||||
|
||||
|
||||
func _sorted_combatant_ids() -> Array:
|
||||
var keys: Array[String] = []
|
||||
for combatant_id in combatants.keys():
|
||||
keys.append(String(combatant_id))
|
||||
keys.sort()
|
||||
var ids: Array = []
|
||||
for key in keys:
|
||||
ids.append(StringName(key))
|
||||
return ids
|
||||
|
||||
|
||||
func _find_npc(npc_id: int) -> SimNPC:
|
||||
for npc in npcs:
|
||||
if npc.id == npc_id:
|
||||
return npc
|
||||
return null
|
||||
|
||||
|
||||
func _get_faction(faction_id: StringName) -> FactionStateRecord:
|
||||
return factions.get(faction_id) as FactionStateRecord
|
||||
|
||||
|
||||
func _narrative_event_requested(
|
||||
event_type: StringName,
|
||||
actor_id: int,
|
||||
source_id: StringName,
|
||||
action_display: String,
|
||||
action_id: StringName,
|
||||
item_id: StringName,
|
||||
required_amount: float
|
||||
) -> void:
|
||||
narrative_event_requested.emit(
|
||||
event_type, actor_id, source_id, action_display, action_id, item_id, required_amount
|
||||
)
|
||||
|
||||
|
||||
func append_state_records(record: SimulationStateRecord) -> void:
|
||||
var combatant_keys: Array[String] = []
|
||||
for combatant_id in combatants.keys():
|
||||
combatant_keys.append(String(combatant_id))
|
||||
combatant_keys.sort()
|
||||
for combatant_key in combatant_keys:
|
||||
var combatant := combatants[StringName(combatant_key)] as CombatantStateRecord
|
||||
record.combatants.append(combatant)
|
||||
var faction_keys: Array[String] = []
|
||||
for faction_id in factions.keys():
|
||||
faction_keys.append(String(faction_id))
|
||||
faction_keys.sort()
|
||||
for faction_key in faction_keys:
|
||||
var faction := factions[StringName(faction_key)] as FactionStateRecord
|
||||
record.factions.append(faction)
|
||||
|
||||
|
||||
func restore_state_records(
|
||||
combatant_records: Array[CombatantStateRecord],
|
||||
faction_records: Array[FactionStateRecord],
|
||||
restored_tick: int
|
||||
) -> void:
|
||||
current_tick = restored_tick
|
||||
combatants.clear()
|
||||
for combatant in combatant_records:
|
||||
combatants[combatant.get_combatant_id()] = combatant
|
||||
factions.clear()
|
||||
for faction in faction_records:
|
||||
factions[faction.get_faction_id()] = faction
|
||||
initialize_factions()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d33ic0xovdv53
|
||||
@@ -0,0 +1,25 @@
|
||||
class_name ItemDefinition
|
||||
extends Resource
|
||||
|
||||
@export var item_id: StringName
|
||||
@export var display_name: String
|
||||
@export var category: StringName = SimulationIds.ITEM_CATEGORY_WEAPON
|
||||
@export var equip_slot: StringName = SimulationIds.EQUIP_SLOT_HAND
|
||||
@export_range(0.0, 1000.0, 0.1) var damage := 0.0
|
||||
@export_range(0.0, 50.0, 0.1) var reach := 1.5
|
||||
@export_range(0.0, 5.0, 0.05) var attack_cooldown := 0.6
|
||||
|
||||
|
||||
func validate() -> Array[String]:
|
||||
var errors: Array[String] = []
|
||||
if item_id.is_empty():
|
||||
errors.append("item_id is empty")
|
||||
if display_name.is_empty():
|
||||
errors.append("display_name is empty for '%s'" % item_id)
|
||||
if damage <= 0.0:
|
||||
errors.append("damage must be positive for '%s'" % item_id)
|
||||
return errors
|
||||
|
||||
|
||||
func is_weapon() -> bool:
|
||||
return category == SimulationIds.ITEM_CATEGORY_WEAPON
|
||||
@@ -0,0 +1 @@
|
||||
uid://bf722tp11qt7j
|
||||
@@ -71,6 +71,36 @@ const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed"
|
||||
const KNOWLEDGE_ACQUISITION_COMMUNICATED := &"communicated"
|
||||
const KNOWLEDGE_ACQUISITION_LEGACY := &"legacy"
|
||||
|
||||
const ITEM_SWORD := &"item_sword"
|
||||
const ITEM_CLAW := &"item_claw"
|
||||
const ITEM_CATEGORY_WEAPON := &"weapon"
|
||||
const EQUIP_SLOT_HAND := &"hand"
|
||||
|
||||
const FACTION_VILLAGE := &"faction_village"
|
||||
const FACTION_TRIBE := &"faction_tribe"
|
||||
const STANCE_NEUTRAL := &"neutral"
|
||||
const STANCE_HOSTILE := &"hostile"
|
||||
const WAR_PLAN_NONE := &"none"
|
||||
const WAR_PLAN_PLANNED := &"planned"
|
||||
const WAR_PLAN_RAIDING := &"raiding"
|
||||
const WAR_PLAN_ABORTED := &"aborted"
|
||||
|
||||
const COMBATANT_KIND_NPC := &"npc"
|
||||
const COMBATANT_KIND_RAIDER := &"raider"
|
||||
const COMBATANT_KIND_WOLF := &"wolf"
|
||||
const COMBATANT_KIND_PLAYER := &"player"
|
||||
|
||||
const EVENT_COMBATANT_HURT := &"combatant_hurt"
|
||||
const EVENT_COMBATANT_KILLED := &"combatant_killed"
|
||||
const EVENT_RAID_STARTED := &"raid_started"
|
||||
const EVENT_WAR_RESOLVED := &"war_resolved"
|
||||
const EVENT_WAR_ABORTED := &"war_aborted"
|
||||
const EVENT_WOLF_HUNT := &"wolf_hunt"
|
||||
|
||||
|
||||
static func npc_inventory_id(npc_id: int) -> StringName:
|
||||
return StringName("npc_%d_inventory" % npc_id)
|
||||
|
||||
|
||||
static func npc_combatant_id(npc_id: int) -> StringName:
|
||||
return StringName("npc_%d_combatant" % npc_id)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
class_name SimulationItems
|
||||
extends RefCounted
|
||||
|
||||
static var _items: Dictionary = {}
|
||||
static var _cache_valid := false
|
||||
|
||||
|
||||
static func _ensure_cache() -> void:
|
||||
if _cache_valid:
|
||||
return
|
||||
_items = {}
|
||||
_add_weapon(SimulationIds.ITEM_SWORD, "Sword", 24.0, 2.4, 0.55)
|
||||
_add_weapon(SimulationIds.ITEM_CLAW, "Claw", 10.0, 1.2, 0.4)
|
||||
_cache_valid = true
|
||||
|
||||
|
||||
static func _add_weapon(
|
||||
item_id: StringName, display_name: String, damage: float, reach: float, cooldown: float
|
||||
) -> void:
|
||||
var item := ItemDefinition.new()
|
||||
item.item_id = item_id
|
||||
item.display_name = display_name
|
||||
item.category = SimulationIds.ITEM_CATEGORY_WEAPON
|
||||
item.equip_slot = SimulationIds.EQUIP_SLOT_HAND
|
||||
item.damage = damage
|
||||
item.reach = reach
|
||||
item.attack_cooldown = cooldown
|
||||
_items[item_id] = item
|
||||
|
||||
|
||||
static func invalidate_cache() -> void:
|
||||
_cache_valid = false
|
||||
|
||||
|
||||
static func get_item(item_id: StringName) -> ItemDefinition:
|
||||
_ensure_cache()
|
||||
return _items.get(item_id) as ItemDefinition
|
||||
|
||||
|
||||
static func get_items() -> Array[ItemDefinition]:
|
||||
_ensure_cache()
|
||||
var values: Array[ItemDefinition] = []
|
||||
for item in _items.values():
|
||||
values.append(item)
|
||||
return values
|
||||
|
||||
|
||||
static func get_weapon(item_id: StringName) -> ItemDefinition:
|
||||
var item := get_item(item_id)
|
||||
return item if item != null and item.is_weapon() else null
|
||||
@@ -0,0 +1 @@
|
||||
uid://doogu4fg3ysgi
|
||||
@@ -0,0 +1,225 @@
|
||||
class_name CombatantStateRecord
|
||||
extends RefCounted
|
||||
|
||||
signal changed(state: CombatantStateRecord)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const NO_NPC_ID := -1
|
||||
|
||||
var data: Dictionary
|
||||
|
||||
|
||||
func _init(record_data: Dictionary = {}) -> void:
|
||||
data = record_data.duplicate(true)
|
||||
|
||||
|
||||
static func _snap(value: float) -> float:
|
||||
return snappedf(value, 1.0 / 1024.0)
|
||||
|
||||
|
||||
static func create(
|
||||
combatant_id: StringName,
|
||||
kind: StringName,
|
||||
faction_id: StringName,
|
||||
display_name: String,
|
||||
position: Vector3,
|
||||
max_health: float,
|
||||
weapon_id: StringName,
|
||||
npc_id: int = NO_NPC_ID,
|
||||
hostile: bool = false
|
||||
) -> CombatantStateRecord:
|
||||
var snapped_position := Vector3(_snap(position.x), _snap(position.y), _snap(position.z))
|
||||
return (
|
||||
CombatantStateRecord
|
||||
. new(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"combatant_id": String(combatant_id),
|
||||
"kind": String(kind),
|
||||
"faction_id": String(faction_id),
|
||||
"display_name": display_name,
|
||||
"npc_id": npc_id,
|
||||
"position": [snapped_position.x, snapped_position.y, snapped_position.z],
|
||||
"health": max_health,
|
||||
"max_health": max_health,
|
||||
"weapon_id": String(weapon_id),
|
||||
"alive": true,
|
||||
"hostile": hostile,
|
||||
"attack_cooldown": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord:
|
||||
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
if not (
|
||||
record_data
|
||||
. has_all(
|
||||
[
|
||||
"combatant_id",
|
||||
"kind",
|
||||
"faction_id",
|
||||
"display_name",
|
||||
"npc_id",
|
||||
"position",
|
||||
"health",
|
||||
"max_health",
|
||||
"weapon_id",
|
||||
"alive",
|
||||
"hostile",
|
||||
"attack_cooldown",
|
||||
]
|
||||
)
|
||||
):
|
||||
return null
|
||||
var combatant_id := String(record_data["combatant_id"])
|
||||
var display_name := String(record_data["display_name"])
|
||||
if combatant_id.is_empty() or display_name.is_empty():
|
||||
return null
|
||||
if (
|
||||
StringName(record_data["kind"])
|
||||
not in [
|
||||
SimulationIds.COMBATANT_KIND_NPC,
|
||||
SimulationIds.COMBATANT_KIND_RAIDER,
|
||||
SimulationIds.COMBATANT_KIND_WOLF,
|
||||
SimulationIds.COMBATANT_KIND_PLAYER,
|
||||
]
|
||||
):
|
||||
return null
|
||||
var saved_position = record_data["position"]
|
||||
if not saved_position is Array or saved_position.size() != 3:
|
||||
return null
|
||||
for component in saved_position:
|
||||
if not is_finite(float(component)):
|
||||
return null
|
||||
var health := float(record_data["health"])
|
||||
var max_health := float(record_data["max_health"])
|
||||
var npc_id := int(record_data["npc_id"])
|
||||
if (
|
||||
not is_finite(health)
|
||||
or not is_finite(max_health)
|
||||
or health < 0.0
|
||||
or max_health <= 0.0
|
||||
or health > max_health
|
||||
or npc_id < NO_NPC_ID
|
||||
or int(record_data["attack_cooldown"]) < 0
|
||||
):
|
||||
return null
|
||||
var normalized := record_data.duplicate(true)
|
||||
normalized["schema_version"] = SCHEMA_VERSION
|
||||
normalized["combatant_id"] = combatant_id
|
||||
normalized["kind"] = String(record_data["kind"])
|
||||
normalized["faction_id"] = String(record_data["faction_id"])
|
||||
normalized["display_name"] = display_name
|
||||
normalized["npc_id"] = npc_id
|
||||
normalized["position"] = [
|
||||
_snap(float(saved_position[0])),
|
||||
_snap(float(saved_position[1])),
|
||||
_snap(float(saved_position[2]))
|
||||
]
|
||||
normalized["health"] = health
|
||||
normalized["max_health"] = max_health
|
||||
normalized["weapon_id"] = String(record_data["weapon_id"])
|
||||
normalized["alive"] = bool(record_data["alive"])
|
||||
normalized["hostile"] = bool(record_data["hostile"])
|
||||
normalized["attack_cooldown"] = int(record_data["attack_cooldown"])
|
||||
return CombatantStateRecord.new(normalized)
|
||||
|
||||
|
||||
func get_combatant_id() -> StringName:
|
||||
return StringName(data["combatant_id"])
|
||||
|
||||
|
||||
func get_kind() -> StringName:
|
||||
return StringName(data["kind"])
|
||||
|
||||
|
||||
func get_faction_id() -> StringName:
|
||||
return StringName(data["faction_id"])
|
||||
|
||||
|
||||
func get_display_name() -> String:
|
||||
return String(data["display_name"])
|
||||
|
||||
|
||||
func get_npc_id() -> int:
|
||||
return int(data["npc_id"])
|
||||
|
||||
|
||||
func get_position() -> Vector3:
|
||||
var saved_position: Array = data["position"]
|
||||
return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2]))
|
||||
|
||||
|
||||
func get_health() -> float:
|
||||
return float(data["health"])
|
||||
|
||||
|
||||
func get_max_health() -> float:
|
||||
return float(data["max_health"])
|
||||
|
||||
|
||||
func get_weapon_id() -> StringName:
|
||||
return StringName(data["weapon_id"])
|
||||
|
||||
|
||||
func is_alive() -> bool:
|
||||
return bool(data["alive"]) and get_health() > 0.0
|
||||
|
||||
|
||||
func is_hostile() -> bool:
|
||||
return bool(data["hostile"])
|
||||
|
||||
|
||||
func get_attack_cooldown() -> int:
|
||||
return int(data["attack_cooldown"])
|
||||
|
||||
|
||||
func is_attack_ready() -> bool:
|
||||
return is_alive() and get_attack_cooldown() <= 0
|
||||
|
||||
|
||||
func set_position(value: Vector3) -> void:
|
||||
if not value.is_finite() or value.is_equal_approx(get_position()):
|
||||
return
|
||||
data["position"] = [_snap(value.x), _snap(value.y), _snap(value.z)]
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func set_hostile(value: bool) -> void:
|
||||
if value == is_hostile():
|
||||
return
|
||||
data["hostile"] = value
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func tick_cooldown() -> void:
|
||||
if get_attack_cooldown() > 0:
|
||||
data["attack_cooldown"] = get_attack_cooldown() - 1
|
||||
|
||||
|
||||
func take_damage(amount: float) -> float:
|
||||
if not is_alive() or not is_finite(amount) or amount <= 0.0:
|
||||
return 0.0
|
||||
var previous := get_health()
|
||||
data["health"] = maxf(previous - amount, 0.0)
|
||||
if get_health() <= 0.0:
|
||||
data["alive"] = false
|
||||
changed.emit(self)
|
||||
return previous - get_health()
|
||||
|
||||
|
||||
func mark_attacked() -> void:
|
||||
data["attack_cooldown"] = _weapon_cooldown_ticks()
|
||||
|
||||
|
||||
func _weapon_cooldown_ticks() -> int:
|
||||
var weapon := SimulationItems.get_weapon(get_weapon_id())
|
||||
var seconds := weapon.attack_cooldown if weapon != null else 0.6
|
||||
return maxi(int(ceil(seconds / 1.2)), 1)
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return data.duplicate(true)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0tknjfogljjg
|
||||
@@ -0,0 +1,179 @@
|
||||
class_name FactionStateRecord
|
||||
extends RefCounted
|
||||
|
||||
signal changed(state: FactionStateRecord)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
var data: Dictionary
|
||||
|
||||
|
||||
func _init(record_data: Dictionary = {}) -> void:
|
||||
data = record_data.duplicate(true)
|
||||
|
||||
|
||||
static func _snap_food(value: float) -> float:
|
||||
return snappedf(value, 1.0 / 64.0)
|
||||
|
||||
|
||||
static func create(
|
||||
faction_id: StringName,
|
||||
display_name: String,
|
||||
food: float,
|
||||
warriors: int,
|
||||
aggression: float,
|
||||
morale: float
|
||||
) -> FactionStateRecord:
|
||||
return (
|
||||
FactionStateRecord
|
||||
. new(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"faction_id": String(faction_id),
|
||||
"display_name": display_name,
|
||||
"food": _snap_food(food),
|
||||
"warriors": warriors,
|
||||
"aggression": clampf(aggression, 0.0, 1.0),
|
||||
"morale": clampf(morale, 0.0, 1.0),
|
||||
"stance": String(SimulationIds.STANCE_NEUTRAL),
|
||||
"war_plan": String(SimulationIds.WAR_PLAN_NONE),
|
||||
"plan_tick": -1,
|
||||
"confidence": 0.0,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func from_dictionary(record_data: Dictionary) -> FactionStateRecord:
|
||||
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
if not (
|
||||
record_data
|
||||
. has_all(
|
||||
[
|
||||
"faction_id",
|
||||
"display_name",
|
||||
"food",
|
||||
"warriors",
|
||||
"aggression",
|
||||
"morale",
|
||||
"stance",
|
||||
"war_plan",
|
||||
"plan_tick",
|
||||
"confidence",
|
||||
]
|
||||
)
|
||||
):
|
||||
return null
|
||||
var faction_id := String(record_data["faction_id"])
|
||||
var display_name := String(record_data["display_name"])
|
||||
if faction_id.is_empty() or display_name.is_empty():
|
||||
return null
|
||||
var food := float(record_data["food"])
|
||||
var warriors := int(record_data["warriors"])
|
||||
var aggression := float(record_data["aggression"])
|
||||
var morale := float(record_data["morale"])
|
||||
var confidence := float(record_data["confidence"])
|
||||
if (
|
||||
not is_finite(food)
|
||||
or not is_finite(aggression)
|
||||
or not is_finite(morale)
|
||||
or not is_finite(confidence)
|
||||
or food < 0.0
|
||||
or warriors < 0
|
||||
or aggression < 0.0
|
||||
or aggression > 1.0
|
||||
or morale < 0.0
|
||||
or morale > 1.0
|
||||
or confidence < 0.0
|
||||
or confidence > 1.0
|
||||
):
|
||||
return null
|
||||
var normalized := record_data.duplicate(true)
|
||||
normalized["schema_version"] = SCHEMA_VERSION
|
||||
normalized["faction_id"] = faction_id
|
||||
normalized["display_name"] = display_name
|
||||
normalized["food"] = _snap_food(food)
|
||||
normalized["warriors"] = warriors
|
||||
normalized["aggression"] = aggression
|
||||
normalized["morale"] = morale
|
||||
normalized["stance"] = String(record_data["stance"])
|
||||
normalized["war_plan"] = String(record_data["war_plan"])
|
||||
normalized["plan_tick"] = int(record_data["plan_tick"])
|
||||
normalized["confidence"] = confidence
|
||||
return FactionStateRecord.new(normalized)
|
||||
|
||||
|
||||
func get_faction_id() -> StringName:
|
||||
return StringName(data["faction_id"])
|
||||
|
||||
|
||||
func get_display_name() -> String:
|
||||
return String(data["display_name"])
|
||||
|
||||
|
||||
func get_food() -> float:
|
||||
return maxf(float(data["food"]), 0.0)
|
||||
|
||||
|
||||
func get_warriors() -> int:
|
||||
return int(data["warriors"])
|
||||
|
||||
|
||||
func get_aggression() -> float:
|
||||
return clampf(float(data["aggression"]), 0.0, 1.0)
|
||||
|
||||
|
||||
func get_morale() -> float:
|
||||
return clampf(float(data["morale"]), 0.0, 1.0)
|
||||
|
||||
|
||||
func get_stance() -> StringName:
|
||||
return StringName(data["stance"])
|
||||
|
||||
|
||||
func get_war_plan() -> StringName:
|
||||
return StringName(data["war_plan"])
|
||||
|
||||
|
||||
func get_plan_tick() -> int:
|
||||
return int(data["plan_tick"])
|
||||
|
||||
|
||||
func get_confidence() -> float:
|
||||
return clampf(float(data["confidence"]), 0.0, 1.0)
|
||||
|
||||
|
||||
func set_food(value: float) -> void:
|
||||
var next := _snap_food(maxf(value, 0.0))
|
||||
if is_equal_approx(next, get_food()):
|
||||
return
|
||||
data["food"] = next
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func set_morale(value: float) -> void:
|
||||
var next := clampf(value, 0.0, 1.0)
|
||||
if is_equal_approx(next, get_morale()):
|
||||
return
|
||||
data["morale"] = next
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func set_stance(value: StringName) -> void:
|
||||
if String(value) == String(get_stance()):
|
||||
return
|
||||
data["stance"] = String(value)
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func set_war_plan(value: StringName, current_tick: int, confidence: float = -1.0) -> void:
|
||||
data["war_plan"] = String(value)
|
||||
data["plan_tick"] = current_tick
|
||||
if confidence >= 0.0:
|
||||
data["confidence"] = clampf(confidence, 0.0, 1.0)
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return data.duplicate(true)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dm6nxs26kblf7
|
||||
@@ -2,7 +2,7 @@ class_name SimulationStateRecord
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_NAME := "the_steward.simulation"
|
||||
const SCHEMA_VERSION := 13
|
||||
const SCHEMA_VERSION := 14
|
||||
const LEGACY_SCHEMA_VERSION := 1
|
||||
const EVENT_LEGACY_SCHEMA_VERSION := 2
|
||||
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
|
||||
@@ -14,6 +14,7 @@ const ANIMAL_LEGACY_SCHEMA_VERSION := 9
|
||||
const ROUTINE_LEGACY_SCHEMA_VERSION := 10
|
||||
const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11
|
||||
const PLAYER_NEEDS_SCHEMA_VERSION := 12
|
||||
const CONFLICT_SCHEMA_VERSION := 13
|
||||
const PREVIOUS_SCHEMA_VERSION := 7
|
||||
|
||||
var simulation: Dictionary
|
||||
@@ -27,6 +28,8 @@ var relationships: Array[RelationshipStateRecord] = []
|
||||
var event_knowledge: Array[KnownEventStateRecord] = []
|
||||
var opportunities: Array[OpportunityStateRecord] = []
|
||||
var player: PlayerStateRecord
|
||||
var combatants: Array[CombatantStateRecord] = []
|
||||
var factions: Array[FactionStateRecord] = []
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
@@ -55,6 +58,12 @@ func to_dictionary() -> Dictionary:
|
||||
var opportunity_data: Array[Dictionary] = []
|
||||
for opportunity_record in opportunities:
|
||||
opportunity_data.append(opportunity_record.to_dictionary())
|
||||
var combatant_data: Array[Dictionary] = []
|
||||
for combatant_record in combatants:
|
||||
combatant_data.append(combatant_record.to_dictionary())
|
||||
var faction_data: Array[Dictionary] = []
|
||||
for faction_record in factions:
|
||||
faction_data.append(faction_record.to_dictionary())
|
||||
|
||||
return {
|
||||
"schema": SCHEMA_NAME,
|
||||
@@ -69,7 +78,9 @@ func to_dictionary() -> Dictionary:
|
||||
"relationships": relationship_data,
|
||||
"event_knowledge": knowledge_data,
|
||||
"opportunities": opportunity_data,
|
||||
"player": player.to_dictionary()
|
||||
"player": player.to_dictionary(),
|
||||
"combatants": combatant_data,
|
||||
"factions": faction_data
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +114,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
ROUTINE_LEGACY_SCHEMA_VERSION,
|
||||
PLAYER_RELATIONSHIP_SCHEMA_VERSION,
|
||||
PLAYER_NEEDS_SCHEMA_VERSION,
|
||||
CONFLICT_SCHEMA_VERSION,
|
||||
]
|
||||
):
|
||||
record_data = _migrate_legacy(record_data, version)
|
||||
@@ -123,6 +135,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
"event_knowledge",
|
||||
"opportunities",
|
||||
"player",
|
||||
"combatants",
|
||||
"factions",
|
||||
]
|
||||
)
|
||||
):
|
||||
@@ -212,6 +226,35 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
if player_record == null:
|
||||
return null
|
||||
record.player = player_record
|
||||
|
||||
var combatant_data = record_data["combatants"]
|
||||
var faction_data = record_data["factions"]
|
||||
if not combatant_data is Array or not faction_data is Array:
|
||||
return null
|
||||
var combatant_ids := {}
|
||||
for item in combatant_data:
|
||||
if not item is Dictionary:
|
||||
return null
|
||||
var combatant_record := CombatantStateRecord.from_dictionary(item)
|
||||
if combatant_record == null:
|
||||
return null
|
||||
var combatant_id := String(combatant_record.get_combatant_id())
|
||||
if combatant_ids.has(combatant_id):
|
||||
return null
|
||||
combatant_ids[combatant_id] = true
|
||||
record.combatants.append(combatant_record)
|
||||
var faction_ids := {}
|
||||
for item in faction_data:
|
||||
if not item is Dictionary:
|
||||
return null
|
||||
var faction_record := FactionStateRecord.from_dictionary(item)
|
||||
if faction_record == null:
|
||||
return null
|
||||
var faction_id := String(faction_record.get_faction_id())
|
||||
if faction_ids.has(faction_id):
|
||||
return null
|
||||
faction_ids[faction_id] = true
|
||||
record.factions.append(faction_record)
|
||||
var npc_ids := {}
|
||||
var npc_records_by_id := {}
|
||||
|
||||
@@ -227,6 +270,11 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
npc_ids[npc_id] = true
|
||||
npc_records_by_id[npc_id] = npc_record
|
||||
record.npcs.append(npc_record)
|
||||
for combatant_record in record.combatants:
|
||||
if combatant_record.get_kind() != SimulationIds.COMBATANT_KIND_NPC:
|
||||
continue
|
||||
if not npc_ids.has(combatant_record.get_npc_id()):
|
||||
return null
|
||||
|
||||
var resource_ids := {}
|
||||
var resource_records_by_id := {}
|
||||
@@ -802,6 +850,20 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
||||
migrated["schema_version"] = SCHEMA_VERSION
|
||||
if not migrated.has("player"):
|
||||
migrated["player"] = PlayerStateRecord.create_default().to_dictionary()
|
||||
if version < CONFLICT_SCHEMA_VERSION:
|
||||
migrated["combatants"] = []
|
||||
migrated["factions"] = [
|
||||
(
|
||||
FactionStateRecord
|
||||
. create(SimulationIds.FACTION_VILLAGE, "Village", 20.0, 0, 0.2, 0.8)
|
||||
. to_dictionary()
|
||||
),
|
||||
(
|
||||
FactionStateRecord
|
||||
. create(SimulationIds.FACTION_TRIBE, "Hill Tribe", 4.0, 4, 0.7, 0.7)
|
||||
. to_dictionary()
|
||||
),
|
||||
]
|
||||
if version <= ANIMAL_LEGACY_SCHEMA_VERSION:
|
||||
migrated["animals"] = []
|
||||
if version == LEGACY_SCHEMA_VERSION:
|
||||
@@ -864,6 +926,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
||||
ROUTINE_LEGACY_SCHEMA_VERSION,
|
||||
PLAYER_RELATIONSHIP_SCHEMA_VERSION,
|
||||
PLAYER_NEEDS_SCHEMA_VERSION,
|
||||
CONFLICT_SCHEMA_VERSION,
|
||||
]
|
||||
):
|
||||
migrated["opportunities"] = []
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
var war_outcomes: Array[StringName] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_test_combat_and_wolf()
|
||||
_test_war_motivation()
|
||||
_test_player_combat_and_restore()
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_combat_and_wolf() -> void:
|
||||
var manager := _create_manager(981)
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
npc.position = Vector3.ZERO
|
||||
var npc_combatant: CombatantStateRecord = manager.get_combatant(
|
||||
SimulationIds.npc_combatant_id(npc.id)
|
||||
)
|
||||
_check(
|
||||
(
|
||||
npc_combatant != null
|
||||
and is_equal_approx(npc_combatant.get_health(), 100.0)
|
||||
and npc_combatant.get_faction_id() == SimulationIds.FACTION_VILLAGE
|
||||
),
|
||||
"Each living villager should gain a village combatant record"
|
||||
)
|
||||
var wolf_id: StringName = manager.spawn_wolf(Vector3(1.0, 0.0, 0.0))
|
||||
_check(wolf_id != &"", "A hungry wolf should spawn as a hostile combatant")
|
||||
var wolf: CombatantStateRecord = manager.get_combatant(wolf_id)
|
||||
_check(wolf.is_hostile(), "A spawned wolf should be hostile")
|
||||
manager.simulate_tick()
|
||||
_check(
|
||||
wolf.get_health() == 40.0 and npc_combatant.get_health() < 100.0,
|
||||
"A nearby wolf should wound the villager with its claw"
|
||||
)
|
||||
var hurt_found := _has_event(manager, SimulationIds.EVENT_COMBATANT_HURT)
|
||||
_check(hurt_found, "The wolf's attack should record a combatant_hurt fact")
|
||||
for _tick in 40:
|
||||
manager.simulate_tick()
|
||||
_check(
|
||||
npc.is_dead or not wolf.is_alive(),
|
||||
"A prolonged wolf fight should resolve with a death on one side"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_war_motivation() -> void:
|
||||
var strong_manager := _create_manager(982)
|
||||
_set_all_strength(strong_manager, 9.0)
|
||||
strong_manager.war_resolved.connect(_on_war_resolved)
|
||||
for _tick in 400:
|
||||
strong_manager.simulate_tick()
|
||||
var strong_plan: StringName = strong_manager.get_tribe_war_plan()
|
||||
var strong_raiders := _count_raiders(strong_manager)
|
||||
_check(
|
||||
(
|
||||
strong_plan in [SimulationIds.WAR_PLAN_PLANNED, SimulationIds.WAR_PLAN_NONE]
|
||||
and strong_raiders == 0
|
||||
),
|
||||
"A strong village should make the hungry tribe plan but never dare to raid"
|
||||
)
|
||||
strong_manager.free()
|
||||
|
||||
var weak_manager := _create_manager(983)
|
||||
_set_all_strength(weak_manager, 1.0)
|
||||
var weak_outcomes: Array[StringName] = []
|
||||
weak_manager.war_resolved.connect(func(outcome): weak_outcomes.append(outcome))
|
||||
for _tick in 200:
|
||||
weak_manager.simulate_tick()
|
||||
_check(
|
||||
weak_manager.get_tribe_war_plan() == SimulationIds.WAR_PLAN_PLANNED,
|
||||
"A hungry tribe should first watch a weak food-rich village before striking"
|
||||
)
|
||||
for _tick in 200:
|
||||
weak_manager.simulate_tick()
|
||||
_check(
|
||||
weak_manager.get_tribe_war_plan() == SimulationIds.WAR_PLAN_RAIDING,
|
||||
"A weak village with plenty of food should be raided once the tribe commits"
|
||||
)
|
||||
_check(_count_raiders(weak_manager) > 0, "The raid should spawn hostile raiders")
|
||||
var resolved := false
|
||||
for _tick in 600:
|
||||
weak_manager.simulate_tick()
|
||||
if weak_manager.get_tribe_war_plan() != SimulationIds.WAR_PLAN_RAIDING:
|
||||
resolved = true
|
||||
break
|
||||
_check(
|
||||
resolved and not weak_outcomes.is_empty(),
|
||||
"The raid should resolve into a war outcome with recorded consequences"
|
||||
)
|
||||
weak_manager.free()
|
||||
|
||||
|
||||
func _test_player_combat_and_restore() -> void:
|
||||
var manager := _create_manager(984)
|
||||
var wolf_id: StringName = manager.spawn_wolf(Vector3(0.0, 0.0, 0.0))
|
||||
var wolf: CombatantStateRecord = manager.get_combatant(wolf_id)
|
||||
_check(
|
||||
is_equal_approx(manager.player_attack(wolf_id), 24.0),
|
||||
"The player's sword strike should deal its configured damage"
|
||||
)
|
||||
_check(is_equal_approx(wolf.get_health(), 16.0), "The wolf should survive one sword strike")
|
||||
manager.player_attack(wolf_id)
|
||||
_check(not wolf.is_alive(), "A second sword strike should kill the wolf")
|
||||
_check(
|
||||
_has_event(manager, SimulationIds.EVENT_COMBATANT_KILLED),
|
||||
"The player's kill should record a combatant_killed fact"
|
||||
)
|
||||
_check(
|
||||
manager.get_living_hostile_combatants().is_empty(),
|
||||
"The dead wolf should leave the living-hostile set"
|
||||
)
|
||||
|
||||
var saved_json: String = manager.serialize_state()
|
||||
var restored := _create_manager(985)
|
||||
_check(
|
||||
restored.restore_state_from_json(saved_json),
|
||||
"Combatants and factions should survive schema-v14 save and restore"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
restored.get_state_checksum() == manager.get_state_checksum()
|
||||
and restored.get_faction(SimulationIds.FACTION_TRIBE) != null
|
||||
and not restored.get_combatant(wolf_id).is_alive()
|
||||
),
|
||||
"Restore should preserve combatant health, factions, and checksum"
|
||||
)
|
||||
restored.free()
|
||||
manager.free()
|
||||
|
||||
|
||||
func _set_all_strength(manager: Node, strength: float) -> void:
|
||||
for npc in manager.npcs:
|
||||
npc.strength = strength
|
||||
|
||||
|
||||
func _count_raiders(manager: Node) -> int:
|
||||
var count := 0
|
||||
for combatant in manager.conflict_system.get_all_combatants():
|
||||
if combatant.get_kind() == SimulationIds.COMBATANT_KIND_RAIDER:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _has_event(manager: Node, event_type: StringName) -> bool:
|
||||
for event in manager.economic_events:
|
||||
if StringName(event.data["event_type"]) == event_type:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _on_war_resolved(_outcome: StringName) -> void:
|
||||
war_outcomes.append(_outcome)
|
||||
|
||||
|
||||
func _create_manager(seed_value: int = 981) -> Node:
|
||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||
manager.simulation_seed = seed_value
|
||||
manager.debug_logs = false
|
||||
var home_positions: Array[Vector3] = [
|
||||
Vector3(0.0, 0.0, 0.0),
|
||||
Vector3(2.0, 0.0, 0.0),
|
||||
Vector3(10.0, 0.0, 0.0),
|
||||
Vector3(12.0, 0.0, 0.0),
|
||||
Vector3(30.0, 0.0, 30.0),
|
||||
Vector3(40.0, 0.0, 40.0),
|
||||
]
|
||||
manager.home_positions = home_positions
|
||||
root.add_child(manager)
|
||||
manager.set_process(false)
|
||||
return manager
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Conflict and war passed: combat, wolves, motivation, raids")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ch6e6vuiek3ju
|
||||
Reference in New Issue
Block a user