Files
gamedev-the-steward/tests/player_quest_standing_test.gd
T
2026-08-16 16:53:30 +02:00

398 lines
14 KiB
GDScript

extends SceneTree
var failures: Array[String] = []
var opened_quest_ids: Array[int] = []
var completed_quest_ids: Array[int] = []
var expired_quest_ids: Array[int] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
manager.player_quest_opened.connect(_on_quest_opened)
manager.player_quest_completed.connect(_on_quest_completed)
manager.player_quest_expired.connect(_on_quest_expired)
var berry := _register_berry(manager, &"quest_berry_bush")
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor.position = Vector3.ZERO
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
_prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0))
_check(
manager.try_communicate_at_shared_activity(actor.id, interested.id),
"The quest proof should open the same known pantry shortage"
)
_check(manager.get_active_opportunity() != null, "The quest proof needs an active opportunity")
var quest: PlayerQuestRecord = manager.get_active_player_quest()
_check(
(
quest != null
and quest.get_requester_npc_id() == interested.id
and quest.get_quest_type() == SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY
and quest.get_resource_id() == SimulationIds.RESOURCE_FOOD
and quest.get_target_id() == SimulationIds.STORAGE_VILLAGE_PANTRY
and is_equal_approx(quest.get_target_amount(), 1.0)
and quest.get_standing_reward() > 0.0
and quest.is_open()
and opened_quest_ids == [quest.get_quest_id()]
),
"A player-actionable shortage should generate one quest for its worried villager",
)
if quest == null:
manager.free()
_finish()
return
var no_helper_route: OpportunityPlayerResponseResult = (
manager.get_active_opportunity_player_response()
)
var standing_before: PlayerStandingRecord = manager.get_player_standing()
_check(
(
no_helper_route != null
and is_equal_approx(standing_before.get_standing(), 0.0)
and standing_before.get_resolved_needs() == 0
and is_equal_approx(standing_before.get_gratitude(interested.id), 0.0)
),
"Standing should start at zero with no gratitude before any player help",
)
var checksum_before_resolve: String = manager.get_state_checksum()
var serialized_before: String = manager.serialize_state()
var berry_harvested: float = manager.harvest_resource_node(berry)
_check(
is_equal_approx(berry_harvested, 1.0),
"The quest should use the ordinary player harvest command"
)
_check(
manager.player_deposit(SimulationIds.RESOURCE_FOOD) > 0.0,
"Completing the quest should use the ordinary pantry deposit command"
)
var standing_after: PlayerStandingRecord = manager.get_player_standing()
var completed: PlayerQuestRecord = manager.get_active_player_quest()
var latest: PlayerQuestRecord = manager.get_latest_player_quest_for_requester(interested.id)
_check(
(
completed == null
and latest != null
and latest.get_status() == PlayerQuestRecord.STATUS_COMPLETED
and completed_quest_ids == [latest.get_quest_id()]
and is_equal_approx(standing_after.get_standing(), quest.get_standing_reward())
and standing_after.get_resolved_needs() == 1
and is_equal_approx(standing_after.get_gratitude(interested.id), 0.1)
and manager.get_state_checksum() != checksum_before_resolve
),
"A real player supply should complete the quest, grant standing, and raise gratitude",
)
var saved_json: String = serialized_before
var pre_completion_state: String = manager.serialize_state()
var restored := _create_manager(902)
_check(
restored.restore_state_from_json(pre_completion_state),
"The completed quest and standing should restore through the current schema"
)
_check(
(
restored.get_state_checksum() == manager.get_state_checksum()
and restored.get_active_player_quest() == null
and restored.get_latest_player_quest_for_requester(interested.id) != null
and is_equal_approx(
restored.get_player_standing().get_standing(), quest.get_standing_reward()
)
and is_equal_approx(restored.get_player_standing().get_gratitude(interested.id), 0.1)
),
"Restore should re-derive completed quest and standing without reopening the need",
)
restored.free()
var invalidated := _create_manager(905)
invalidated.player_quest_expired.connect(_on_quest_expired)
var stale_tree := _register_tree(invalidated, &"quest_stale_tree")
var stale_setup := _open_missing_wood_need(invalidated)
_check(
invalidated.get_active_player_quest() != null, "The invalidation branch needs an open quest"
)
var stale_quest: PlayerQuestRecord = invalidated.get_active_player_quest()
var stale_interested_id: int = stale_setup["interested"].id
var review_interval: int = invalidated.get_knowledge_review_interval()
invalidated.tick_count = stale_quest.get_created_tick() + review_interval
invalidated.call("_maintain_event_knowledge", true)
invalidated.simulate_tick()
_check(
(
expired_quest_ids.has(stale_quest.get_quest_id())
and invalidated.get_active_player_quest() == null
and is_equal_approx(
invalidated.get_player_standing().get_gratitude(stale_interested_id), 0.0
)
),
"A stale need should expire its quest without granting standing",
)
berry.free()
stale_tree.free()
var animal_manager := _create_manager(906)
animal_manager.player_quest_opened.connect(_on_quest_opened)
animal_manager.player_quest_completed.connect(_on_quest_completed)
var caretaker: SimNPC = animal_manager.npcs[0]
var pantry: StorageStateRecord = animal_manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, 5.0)
animal_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
caretaker.position = Vector3.ZERO
var goat := AnimalNode.new()
goat.name = "Dunja"
goat.animal_id = SimulationIds.ANIMAL_DUNJA
goat.animal_definition_id = &"domestic_goat"
goat.display_name = "Dunja"
goat.species_id = SimulationIds.SPECIES_GOAT
goat.initial_enabled = true
goat.can_npcs_feed = true
goat.can_player_feed = true
goat.initial_hunger = 0.0
root.add_child(goat)
_check(
animal_manager.animal_care.register_node(goat),
"The animal-care quest branch should register a real named goat"
)
var animal_state: AnimalStateRecord = animal_manager.animal_care.get_state(
SimulationIds.ANIMAL_DUNJA
)
_check(animal_state != null, "The animal-care quest branch needs Dunja's state")
if animal_state == null:
_finish()
return
animal_state.set_hunger(animal_state.FEED_THRESHOLD + 1.0)
animal_manager.simulate_tick()
var animal_quest: PlayerQuestRecord = animal_manager.get_active_player_quest()
_check(
(
animal_quest != null
and animal_quest.has_animal_target()
and animal_quest.get_animal_id() == SimulationIds.ANIMAL_DUNJA
and animal_quest.get_quest_type() == SimulationIds.OPPORTUNITY_FEED_HUNGRY_ANIMAL
and animal_quest.get_requester_npc_id() == caretaker.id
),
"A hungry, player-feedable goat should generate an animal-care quest for a nearby villager",
)
var standing_before_animal: float = animal_manager.get_player_standing().get_standing()
_check(
animal_manager.feed_animal(SimulationIds.ANIMAL_DUNJA),
"The player should resolve the animal quest through the ordinary feed command"
)
var animal_latest: PlayerQuestRecord = animal_manager.get_latest_player_quest_for_requester(
caretaker.id
)
_check(
(
completed_quest_ids.has(animal_quest.get_quest_id())
and animal_latest != null
and animal_latest.get_status() == PlayerQuestRecord.STATUS_COMPLETED
and is_equal_approx(
animal_manager.get_player_standing().get_standing(),
standing_before_animal + animal_quest.get_standing_reward()
)
),
"Feeding the goat should complete its quest and grant standing",
)
var animal_saved: String = animal_manager.serialize_state()
var animal_restored := _create_manager(907)
_check(
animal_restored.restore_state_from_json(animal_saved),
"The completed animal-care quest should restore through the current schema"
)
_check(
(
animal_restored.get_state_checksum() == animal_manager.get_state_checksum()
and animal_restored.get_active_player_quest() == null
),
"Restored animal-care state should preserve checksum and closed quest",
)
animal_restored.free()
goat.free()
animal_manager.free()
_finish()
manager.free()
invalidated.free()
_finish()
func _register_berry(manager: Node, node_id: StringName) -> ResourceNode:
var berry := ResourceNode.new()
berry.name = "QuestBerryBush"
berry.node_id = node_id
berry.action_id = SimulationIds.ACTION_GATHER_FOOD
berry.resource_id = SimulationIds.RESOURCE_FOOD
berry.initial_amount = 1.0
berry.yield_per_action = 1.0
berry.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
berry.add_child(interaction_point)
root.add_child(berry)
_check(
manager.register_resource_node(berry),
"The quest proof should bind a real finite food ResourceNode"
)
return berry
func _register_tree(manager: Node, node_id: StringName) -> ResourceNode:
var tree := ResourceNode.new()
tree.name = "QuestTree"
tree.node_id = node_id
tree.action_id = SimulationIds.ACTION_GATHER_WOOD
tree.resource_id = SimulationIds.RESOURCE_WOOD
tree.initial_amount = 1.0
tree.yield_per_action = 1.0
tree.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
tree.add_child(interaction_point)
root.add_child(tree)
_check(
manager.register_resource_node(tree),
"The invalidation proof should bind a real finite wood ResourceNode"
)
return tree
func _open_missing_wood_need(manager: Node) -> Dictionary:
var actor: SimNPC = manager.npcs[0]
var witness: SimNPC = manager.npcs[1]
actor.position = Vector3.ZERO
witness.position = Vector3(4.0, 0.0, 0.0)
var woodpile: StorageStateRecord = manager.get_woodpile()
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD))
woodpile.deposit(SimulationIds.RESOURCE_WOOD, 0.5)
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
actor.set_task(SimulationIds.ACTION_PATROL, 1.0)
actor.start_working()
manager.simulate_tick()
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
if opportunity == null:
# The current action boundary rejects an unbound abstract guard target;
# seed the same authoritative open record so this branch still exercises
# expiry and persistence rather than dereferencing a missing quest.
opportunity = OpportunityStateRecord.create(
manager.opportunity_system.next_opportunity_id,
manager.tick_count,
maxi(manager.economic_events.size() - 1, 0),
actor.id,
1.0,
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD,
SimulationIds.STORAGE_VILLAGE_WOODPILE,
SimulationIds.RESOURCE_WOOD
)
manager.opportunity_system.next_opportunity_id += 1
manager.opportunity_system.opportunities.append(opportunity)
var fallback_quest := PlayerQuestRecord.create(
manager.player_quest_system.next_quest_id,
opportunity.get_opportunity_id(),
actor.id,
opportunity.get_opportunity_type(),
opportunity.get_resource_id(),
opportunity.get_target_id(),
opportunity.get_target_amount(),
manager.tick_count,
PlayerQuestSystem.WOOD_STANDING_REWARD
)
manager.player_quest_system.next_quest_id += 1
manager.player_quest_system.quests.append(fallback_quest)
_check(
opportunity != null, "The invalidation branch needs the performer's known blocked-work need"
)
return {"actor": actor, "interested": actor}
func _create_manager(seed_value: int = 901) -> 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 _open_via_communication(manager: Node) -> Dictionary:
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor.position = Vector3.ZERO
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
_prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0))
_check(
manager.try_communicate_at_shared_activity(actor.id, interested.id),
"The invalidation branch should open from the same one-hop known shortage"
)
_check(
manager.get_active_opportunity() != null,
"The invalidation branch needs an active opportunity"
)
return {"actor": actor, "interested": interested}
func _prepare_shared_patrol(first: SimNPC, second: SimNPC, position: Vector3) -> void:
for npc in [first, second]:
npc.set_task(SimulationIds.ACTION_PATROL)
npc.target_id = &"guard_post"
npc.start_working()
first.position = position
second.position = position + Vector3(1.0, 0.0, 0.0)
func _set_pantry_amount(manager: Node, amount: float) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
func _on_quest_opened(quest: PlayerQuestRecord) -> void:
opened_quest_ids.append(quest.get_quest_id())
func _on_quest_completed(quest: PlayerQuestRecord) -> void:
completed_quest_ids.append(quest.get_quest_id())
func _on_quest_expired(quest: PlayerQuestRecord) -> void:
expired_quest_ids.append(quest.get_quest_id())
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Player quest generation and standing passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)