feat: add combat, wild wolves, factions, and tribe war motivation

This commit is contained in:
Rijad Zuzo
2026-08-11 20:29:47 +02:00
parent e359e5253a
commit 292cdd9153
16 changed files with 1475 additions and 3 deletions
+225
View File
@@ -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
+179
View File
@@ -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
+65 -2
View File
@@ -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"] = []