Compare commits

...

10 Commits

55 changed files with 3888 additions and 198 deletions
+20
View File
@@ -21,6 +21,9 @@ SimulationClock
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
-> RelationshipSystem applies evidence-gated social consequences -> RelationshipSystem applies evidence-gated social consequences
-> VillageOpportunitySystem projects one known unresolved need -> VillageOpportunitySystem projects one known unresolved need
-> PlayerQuestSystem turns open needs into named player quests and
grants Standing as the player resolves them through real supply/feed
-> PlayerNeedsSystem advances the player's hunger, energy, and carry
-> WorldViewManager presents travel, NPC state, and world-state cues -> WorldViewManager presents travel, NPC state, and world-state cues
-> ActiveWorldAdapter supplies loaded-world positions/capacity -> ActiveWorldAdapter supplies loaded-world positions/capacity
-> LoadedResourceSpatialIndex bounds finite-anchor discovery -> LoadedResourceSpatialIndex bounds finite-anchor discovery
@@ -64,6 +67,22 @@ would otherwise obscure that lifecycle:
When no such helper exists, it can separately derive an ephemeral When no such helper exists, it can separately derive an ephemeral
`OpportunityPlayerResponseResult` from the real destination capacity and `OpportunityPlayerResponseResult` from the real destination capacity and
enabled player-usable finite sources; enabled player-usable finite sources;
- `simulation/quests/PlayerQuestSystem.gd` turns real open needs into named
player quests (`PlayerQuestRecord`) and owns the player's `PlayerStandingRecord`
reputation. A quest is generated only when the player can genuinely act
through the ordinary economy (an unassisted pantry/wood shortage, or a hungry
player-feedable animal), and it is resolved only by a real player supply or
feed event. Completing a quest grants Standing and raises the requester's
gratitude; a requester who personally trusts the player can ask them directly
even when another capable helper exists. Quests, Standing, and gratitude are
persisted and never become quest-only simulation state;
- `simulation/quests/PlayerNeedsSystem.gd` owns the player's embodied hunger,
energy, starvation, death, and carried inventory, advancing on the
deterministic tick and mutating finite resources and storage only through the
ordinary economy and recorder;
- `simulation/quests/PlayerNegotiationSystem.gd` owns the read-only villager
talk surface plus accept/decline of personal requests, delegating quest
lifecycle to `PlayerQuestSystem`;
- `simulation/persistence/` owns save-slot file safety; - `simulation/persistence/` owns save-slot file safety;
- `simulation/state/` owns versioned serialized record contracts; - `simulation/state/` owns versioned serialized record contracts;
- `simulation/definitions/` owns stable IDs and immutable action/profession - `simulation/definitions/` owns stable IDs and immutable action/profession
@@ -102,6 +121,7 @@ hard to read.
| `simulation/relationships/` | Directed social consequences and relationship queries | | `simulation/relationships/` | Directed social consequences and relationship queries |
| `simulation/population/` | Transient per-tick population query indexes | | `simulation/population/` | Transient per-tick population query indexes |
| `simulation/opportunities/` | Knowledge-gated unresolved-condition projections | | `simulation/opportunities/` | Knowledge-gated unresolved-condition projections |
| `simulation/quests/` | Named player quests and Standing reputation from real need resolution |
| `simulation/state/` | Versioned, serializable mutable records | | `simulation/state/` | Versioned, serializable mutable records |
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions | | `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
| `simulation/persistence/` | Validated local save-file storage | | `simulation/persistence/` | Validated local save-file storage |
+29 -1
View File
@@ -841,10 +841,38 @@ Next:
1. Let an inspected villager who owns an open opportunity surface that exact 1. Let an inspected villager who owns an open opportunity surface that exact
village need plus the authoritative direct-help route, capable helper, or village need plus the authoritative direct-help route, capable helper, or
honest unavailable state that currently exists. honest unavailable state that currently exists. **Complete:** the quiet
`VillagerFieldNoteHud` now shows a restrained `Need` line for exactly that
note, derived from the existing player-response/helper queries and the real
storage state. It names the helper when one is capable, shows the direct
finite-resource-to-storage route when the player response derives it, or
honestly states why help is unavailable, and it re-derives from live facts
after restore without entering saves.
2. Prove that the ordinary finite-resource-to-storage harvest resolves an 2. Prove that the ordinary finite-resource-to-storage harvest resolves an
available player route and updates the context without dialogue branching, available player route and updates the context without dialogue branching,
acceptance state, rewards, a quest log, or saved presentation state. acceptance state, rewards, a quest log, or saved presentation state.
**Complete:** a runtime scenario stages the real pantry crisis, surfaces the
need for its interested villager, and proves that one ordinary
`harvest_resource_node` extraction resolves the same open opportunity and
clears the need segment on the next refresh.
3. Give the player a reason to keep helping: a persistent **Standing** loop.
**Complete:** resolving real village needs (pantry, wood, hungry animals)
now grants Standing (Stranger → Known Hand → Trusted → Village Steward →
Voice of Jajce) and raises the requester's gratitude. The world's quest
generator turns each open need into a named player quest in a quiet
`QuestJournalHud`, and villagers who personally trust the player start
asking them directly even when another helper could act. All of it flows
through the ordinary finite-resource harvest/feed contracts, survives
save/restore deterministically, and adds no quest-only state.
4. Make the player a citizen with a body. **Complete:** the player now has
hunger and energy that advance on the simulation tick, eats from the real
pantry, weakens when starving, and can die by starvation (halving Standing).
Gathering fills a carried inventory with capacity, and depositing at the
pantry or woodpile is an explicit transaction that resolves the same quests.
Pressing `E` on a villager opens a personal talk where you accept or decline
their real request. The embodied needs, carry, and negotiation rules live in
focused `PlayerNeedsSystem`/`PlayerNegotiationSystem` collaborators and are
covered by headless, runtime, and schema-restore regressions.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already mechanics. The next proof is a beautiful stage for the systems that already
+57
View File
@@ -984,6 +984,63 @@ resolve the need without adding a dialogue tree, quest acceptance, rewards, a
quest log, reputation, a generic conversation framework, or saved presentation quest log, reputation, a generic conversation framework, or saved presentation
state in that slice. state in that slice.
The first bounded field-note opportunity surfacing slice is complete:
- `VillagerInspectionResult` now carries an optional read-only
`VillagerOpportunityNote` that is present only when the inspected villager is
the interested party of an open village opportunity;
- `VillagerOpportunityNote.derive` decides the honest help state from the
existing authoritative queries: the derived player-response route wins, then
the capable helper by name, then a real unavailable reason (need already met,
storage has no room, no stocked source in reach, or no storage at all);
- the quiet `VillagerFieldNoteHud` shows a restrained `Need` line for exactly
that note, coexists with the existing `E` prompt and cinematic mode, and
re-derives it from live state without entering saves or drawing RNG;
- a real player finite-resource harvest resolves the same open
`OpportunityStateRecord` through the existing economy/event history, and the
need segment clears on the next refresh;
- unit, runtime, and query-neutrality checks cover the derive branches, the
staged pantry-crisis surfacing, helper naming, ordinary harvest resolution,
and the absence of saved presentation state.
The first bounded player-Standing and quest slice is complete:
- `PlayerStandingRecord` is a versioned, deterministic 0100 reputation with
named tiers (Stranger, Known Hand, Trusted, Village Steward, Voice of Jajce)
and per-requester gratitude, persisted through world schema v12;
- `PlayerQuestRecord` plus `PlayerQuestSystem` turn real open needs into named
player quests: an unassisted pantry or wood shortage (or a hungry,
player-feedable animal) generates one quest for its requester, resolved only
by a real player supply or feed event through the ordinary economy;
- completing a quest grants Standing and raises the requester's gratitude, an
NPC who trusts the player personally can ask them directly even when another
capable helper exists, and tier advances emit a celebration beat;
- a read-only `QuestJournalHud` surfaces the active quest, Standing tier, and
gratitude feedback without entering saves or drawing RNG;
- headless, runtime, schema-restore, and GUT unit regressions cover generation,
completion, invalidation, animal care, personal requests, and tier
progression.
The first embodied-citizen slice is complete:
- the player now has a body in the world: hunger and energy advance on the
deterministic tick (`PlayerStateRecord`), eating at the pantry reduces the
player's own hunger, starvation weakens movement, and death by starvation
halves Standing and allows a respawn, all persisted through world schema v13;
- the player carries a real inventory with capacity: gathering a finite
resource fills the hands, depositing at the pantry or woodpile is an explicit
transaction that resolves opportunities and quests through the same supply
contract used by NPCs;
- pressing `E` on a nearby villager opens a personal talk: the villager greets
you and surfaces their real need, and you can accept their request (which
surfaces the same open quest) or decline it (closing the quest without
gratitude);
- `PlayerNeedsSystem`, `PlayerNegotiationSystem`, and `SimulationEventRecorder`
keep the player-citizen rules out of the manager;
- headless, runtime, carry/deposit, talk/negotiation, and schema-restore
regressions cover needs, death, respawn, carrying, depositing, quest
resolution, and personal request accept/decline.
Recently completed: Recently completed:
- `Jajce Villager Field Note 12`: a separate player-facing note selects the - `Jajce Villager Field Note 12`: a separate player-facing note selects the
+21
View File
@@ -276,6 +276,27 @@ The village currently tracks:
- per-resource modifiers; - per-resource modifiers;
- per-resource priorities. - per-resource priorities.
### Player Standing and quests
The world now rewards player help with a persistent **Standing** reputation
(0100) and named tiers: Stranger, Known Hand, Trusted, Village Steward, and
Voice of Jajce. The quest generator turns real open needs into player quests:
- an unassisted pantry or wood shortage generates a named quest from its
worried villager;
- a hungry, player-feedable goat generates an animal-care quest;
- completing a quest through the ordinary finite-resource harvest or feed
contract grants Standing, raises the requester's gratitude, and emits a
thank-you beat;
- a requester who personally trusts the player (gratitude ≥ 0.5 and Standing
tier at least Known Hand) asks them directly even when another capable
helper exists;
- a compact `QuestJournalHud` shows the active quest, Standing tier, and
gratitude feedback; tier advances trigger a celebration beat.
Quests, Standing, and gratitude persist through world schema v12 and never
become quest-only simulation state.
### NPC task lifecycle ### NPC task lifecycle
Task states are: Task states are:
+9 -3
View File
@@ -3,7 +3,7 @@
## Current contract ## Current contract
`SimulationStateRecord` is the versioned JSON boundary for the current `SimulationStateRecord` is the versioned JSON boundary for the current
simulation. The current world schema is v11 and captures: simulation. The current world schema is v13 and captures:
- simulation seed, tick interval, tick count, clock remainder, and elapsed - simulation seed, tick interval, tick count, clock remainder, and elapsed
clock ticks; clock ticks;
@@ -25,14 +25,20 @@ simulation. The current world schema is v11 and captures:
historical communicator provenance; historical communicator provenance;
- opportunity records with stable type/status, interested NPC, trigger event, - opportunity records with stable type/status, interested NPC, trigger event,
target storage/resource/amount, exact later resolution event identity, or a target storage/resource/amount, exact later resolution event identity, or a
deterministic invalidation reason and close tick. deterministic invalidation reason and close tick;
- the player's Standing reputation (0100), resolved-need count, and per-NPC
gratitude, plus named player quest records that reference the originating
opportunity or animal, requester NPC, resource/target/amount, Standing
reward, and exact resolution or invalidation tick;
- the player's embodied state: hunger, energy, starvation progress, death
count, and carried inventory with its capacity (schema v13).
The top-level identity is: The top-level identity is:
```json ```json
{ {
"schema": "the_steward.simulation", "schema": "the_steward.simulation",
"schema_version": 11 "schema_version": 13
} }
``` ```
+96 -2
View File
@@ -15,6 +15,7 @@
[ext_resource type="Script" uid="uid://dwwxux24jc6yq" path="res://world/ui/village_whisper_hud.gd" id="15_whisper"] [ext_resource type="Script" uid="uid://dwwxux24jc6yq" path="res://world/ui/village_whisper_hud.gd" id="15_whisper"]
[ext_resource type="Script" path="res://world/ui/player_interaction_hud.gd" id="16_interaction"] [ext_resource type="Script" path="res://world/ui/player_interaction_hud.gd" id="16_interaction"]
[ext_resource type="Script" path="res://world/ui/villager_field_note_hud.gd" id="17_villager_note"] [ext_resource type="Script" path="res://world/ui/villager_field_note_hud.gd" id="17_villager_note"]
[ext_resource type="Script" path="res://world/ui/quest_journal_hud.gd" id="18_quest_journal"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
radius = 0.4 radius = 0.4
@@ -24,13 +25,14 @@ height = 1.7
[node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")] [node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")]
[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "world_view_manager", "pantry_storage", "guard_site", "study_site")] [node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "world_view_manager", "pantry_storage", "woodpile_storage", "guard_site", "study_site")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)
script = ExtResource("1_h2yge") script = ExtResource("1_h2yge")
camera_rig = NodePath("../CameraRig") camera_rig = NodePath("../CameraRig")
simulation_manager = NodePath("../SimulationManager") simulation_manager = NodePath("../SimulationManager")
world_view_manager = NodePath("../WorldViewManager") world_view_manager = NodePath("../WorldViewManager")
pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry") pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry")
woodpile_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillageWoodpile")
guard_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/GuardPost") guard_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/GuardPost")
study_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/StudyDesk") study_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/StudyDesk")
stomach_capacity_for_food = 10 stomach_capacity_for_food = 10
@@ -204,7 +206,7 @@ anchors_preset = 2
anchor_top = 1.0 anchor_top = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = 34.0 offset_left = 34.0
offset_top = -194.0 offset_top = -246.0
offset_right = 430.0 offset_right = 430.0
offset_bottom = -46.0 offset_bottom = -46.0
grow_vertical = 0 grow_vertical = 0
@@ -290,6 +292,18 @@ theme_override_font_sizes/font_size = 13
text = "Why · Awaiting the next decision" text = "Why · Awaiting the next decision"
autowrap_mode = 2 autowrap_mode = 2
[node name="Need" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"]
custom_minimum_size = Vector2(380, 0)
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.93, 0.7, 0.32, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94)
theme_override_constants/outline_size = 5
theme_override_font_sizes/font_size = 14
text = "Need · "
autowrap_mode = 2
visible = false
[node name="VillageWhisperLayer" type="CanvasLayer" parent="."] [node name="VillageWhisperLayer" type="CanvasLayer" parent="."]
layer = 3 layer = 3
@@ -378,6 +392,86 @@ text = "The pantry is empty."
horizontal_alignment = 1 horizontal_alignment = 1
vertical_alignment = 1 vertical_alignment = 1
[node name="QuestJournalLayer" type="CanvasLayer" parent="."]
layer = 4
[node name="QuestJournal" type="Control" parent="QuestJournalLayer" node_paths=PackedStringArray("simulation_manager")]
visible = false
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -360.0
offset_top = -150.0
offset_right = -16.0
offset_bottom = -20.0
grow_horizontal = 0
grow_vertical = 0
mouse_filter = 2
script = ExtResource("18_quest_journal")
simulation_manager = NodePath("../../SimulationManager")
[node name="Accent" type="ColorRect" parent="QuestJournalLayer/QuestJournal"]
layout_mode = 1
anchors_preset = 9
anchor_bottom = 1.0
offset_right = 2.0
grow_vertical = 2
mouse_filter = 2
color = Color(0.55, 0.42, 0.2, 0.9)
[node name="Copy" type="VBoxContainer" parent="QuestJournalLayer/QuestJournal"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 12.0
offset_top = 8.0
mouse_filter = 2
theme_override_constants/separation = 2
alignment = 2
[node name="Standing" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"]
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.98, 0.92, 0.79, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 18
text = "Standing · Stranger · 0/100"
[node name="QuestKicker" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"]
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.93, 0.7, 0.36, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.9)
theme_override_constants/outline_size = 5
theme_override_font_sizes/font_size = 11
text = "ACTIVE QUEST"
[node name="Quest" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"]
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.9, 0.84, 0.7, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.9)
theme_override_constants/outline_size = 4
theme_override_font_sizes/font_size = 13
text = "Amina · bring 1 food to the village pantry"
autowrap_mode = 2
[node name="Thanks" type="Label" parent="QuestJournalLayer/QuestJournal/Copy"]
visible = false
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.95, 0.76, 0.42, 1)
theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94)
theme_override_constants/outline_size = 5
theme_override_font_sizes/font_size = 13
text = "Gratitude"
horizontal_alignment = 1
autowrap_mode = 2
[node name="DemoController" type="Node" parent="." unique_id=644713371 node_paths=PackedStringArray("ui", "active_npcs_parent", "simulation_manager", "world_view_manager", "camera_rig", "player", "pantry_storage", "crisis_caption")] [node name="DemoController" type="Node" parent="." unique_id=644713371 node_paths=PackedStringArray("ui", "active_npcs_parent", "simulation_manager", "world_view_manager", "camera_rig", "player", "pantry_storage", "crisis_caption")]
script = ExtResource("13_demo") script = ExtResource("13_demo")
ui = NodePath("../UI") ui = NodePath("../UI")
+2
View File
@@ -4,6 +4,8 @@ extends RefCounted
const KIND_ANIMAL := &"animal" const KIND_ANIMAL := &"animal"
const KIND_RESOURCE := &"resource" const KIND_RESOURCE := &"resource"
const KIND_PANTRY := &"pantry" const KIND_PANTRY := &"pantry"
const KIND_DEPOSIT := &"deposit"
const KIND_TALK := &"talk"
const KIND_GUARD := &"guard" const KIND_GUARD := &"guard"
const KIND_STUDY := &"study" const KIND_STUDY := &"study"
+53
View File
@@ -0,0 +1,53 @@
class_name PlayerTalkResult
extends RefCounted
var npc_id: int
var npc_name: String
var greeting: String
var has_need: bool
var need_text: String
var quest_available: bool
var quest_reward: float
var can_accept: bool
var can_decline: bool
func _init(
talk_npc_id: int,
talk_npc_name: String,
talk_greeting: String,
talk_has_need: bool,
talk_need_text: String,
talk_quest_available: bool,
talk_quest_reward: float,
talk_can_accept: bool,
talk_can_decline: bool
) -> void:
npc_id = talk_npc_id
npc_name = talk_npc_name
greeting = talk_greeting
has_need = talk_has_need
need_text = talk_need_text
quest_available = talk_quest_available
quest_reward = talk_quest_reward
can_accept = talk_can_accept
can_decline = talk_can_decline
func cache_key() -> String:
return (
"|"
. join(
[
str(npc_id),
npc_name,
greeting,
str(has_need),
need_text,
str(quest_available),
"%.2f" % quest_reward,
str(can_accept),
str(can_decline),
]
)
)
+1
View File
@@ -0,0 +1 @@
uid://i7pjb5605cgr
+6 -1
View File
@@ -13,6 +13,7 @@ var target_name: String
var carried_amounts: Dictionary var carried_amounts: Dictionary
var decision_reason: String var decision_reason: String
var _has_decision_reason: bool var _has_decision_reason: bool
var opportunity_note: VillagerOpportunityNote
func _init( func _init(
@@ -24,7 +25,8 @@ func _init(
inspection_target_id: StringName, inspection_target_id: StringName,
inspection_target_name: String, inspection_target_name: String,
inspection_carried_amounts: Dictionary, inspection_carried_amounts: Dictionary,
inspection_decision_reason := "" inspection_decision_reason := "",
inspection_opportunity_note: VillagerOpportunityNote = null
) -> void: ) -> void:
npc_id = inspection_npc_id npc_id = inspection_npc_id
npc_name = inspection_npc_name npc_name = inspection_npc_name
@@ -36,6 +38,7 @@ func _init(
carried_amounts = inspection_carried_amounts.duplicate(true) carried_amounts = inspection_carried_amounts.duplicate(true)
_has_decision_reason = not inspection_decision_reason.strip_edges().is_empty() _has_decision_reason = not inspection_decision_reason.strip_edges().is_empty()
decision_reason = (inspection_decision_reason if _has_decision_reason else AWAITING_REASON) decision_reason = (inspection_decision_reason if _has_decision_reason else AWAITING_REASON)
opportunity_note = inspection_opportunity_note
func has_decision_reason() -> bool: func has_decision_reason() -> bool:
@@ -67,6 +70,7 @@ func cache_key() -> String:
var carried_parts: Array[String] = [] var carried_parts: Array[String] = []
for item_id in carried_keys: for item_id in carried_keys:
carried_parts.append("%s=%.3f" % [String(item_id), float(carried_amounts[item_id])]) carried_parts.append("%s=%.3f" % [String(item_id), float(carried_amounts[item_id])])
var note_part := opportunity_note.cache_key() if opportunity_note != null else "-"
return ( return (
"|" "|"
. join( . join(
@@ -80,6 +84,7 @@ func cache_key() -> String:
target_name, target_name,
",".join(carried_parts), ",".join(carried_parts),
decision_reason, decision_reason,
note_part,
] ]
) )
) )
+79
View File
@@ -0,0 +1,79 @@
class_name VillagerOpportunityNote
extends RefCounted
const HELP_PLAYER_ROUTE := &"player_route"
const HELP_HELPER := &"helper"
const HELP_UNAVAILABLE := &"unavailable"
var has_need := false
var need_text := ""
var help_kind: StringName = &""
var help_text := ""
func _init(
note_has_need := false,
note_need_text := "",
note_help_kind: StringName = &"",
note_help_text := ""
) -> void:
has_need = note_has_need
need_text = note_need_text
help_kind = note_help_kind
help_text = note_help_text
func cache_key() -> String:
return "%s|%s|%s|%s" % [has_need, need_text, String(help_kind), help_text]
static func derive(
opportunity: OpportunityStateRecord,
player_response: OpportunityPlayerResponseResult,
helper: OpportunityHelperResult,
helper_npc_name: String,
storage: StorageStateRecord,
target_display_name: String,
resource_display_name: String
) -> VillagerOpportunityNote:
if opportunity == null:
return VillagerOpportunityNote.new()
var need_text := "The %s needs %s" % [target_display_name, resource_display_name]
if player_response != null:
return (
VillagerOpportunityNote
. new(
true,
need_text,
HELP_PLAYER_ROUTE,
"You can help · forage %s to the %s" % [resource_display_name, target_display_name],
)
)
if helper != null:
return VillagerOpportunityNote.new(
true,
need_text,
HELP_HELPER,
"%s is bringing %s" % [helper_npc_name, resource_display_name]
)
if storage == null:
return VillagerOpportunityNote.new(
true, need_text, HELP_UNAVAILABLE, "No storage is within reach right now."
)
var remaining := maxf(
opportunity.get_target_amount() - storage.get_amount(opportunity.get_resource_id()), 0.0
)
if remaining <= 0.0:
return VillagerOpportunityNote.new(
true,
need_text,
HELP_UNAVAILABLE,
"The %s already has enough %s." % [target_display_name, resource_display_name]
)
if storage.get_available_capacity() < remaining:
return VillagerOpportunityNote.new(
true, need_text, HELP_UNAVAILABLE, "The %s has no room right now." % target_display_name
)
return VillagerOpportunityNote.new(
true, need_text, HELP_UNAVAILABLE, "No stocked source is in reach right now."
)
+1
View File
@@ -0,0 +1 @@
uid://ds7o7atkwyfon
+260 -10
View File
@@ -11,6 +11,7 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool)
@export var world_view_manager: Node @export var world_view_manager: Node
@export var pantry_storage: StorageNode @export var pantry_storage: StorageNode
@export var woodpile_storage: StorageNode
@export var guard_site: ActivitySite @export var guard_site: ActivitySite
@export var study_site: ActivitySite @export var study_site: ActivitySite
@@ -37,7 +38,7 @@ func _physics_process(delta: float) -> void:
var direction := (right * input.x + forward * -input.y).normalized() var direction := (right * input.x + forward * -input.y).normalized()
var target_velocity := direction * move_speed var target_velocity := direction * move_speed * _needs_speed_factor()
velocity.x = move_toward(velocity.x, target_velocity.x, acceleration * delta) velocity.x = move_toward(velocity.x, target_velocity.x, acceleration * delta)
velocity.z = move_toward(velocity.z, target_velocity.z, acceleration * delta) velocity.z = move_toward(velocity.z, target_velocity.z, acceleration * delta)
@@ -69,6 +70,10 @@ func try_interact() -> void:
_execute_animal_interaction(context) _execute_animal_interaction(context)
PlayerInteractionResult.KIND_RESOURCE: PlayerInteractionResult.KIND_RESOURCE:
_execute_resource_interaction(context) _execute_resource_interaction(context)
PlayerInteractionResult.KIND_DEPOSIT:
_execute_deposit_interaction(context)
PlayerInteractionResult.KIND_TALK:
_execute_talk_interaction(context)
PlayerInteractionResult.KIND_GUARD: PlayerInteractionResult.KIND_GUARD:
simulation_manager.add_safety(3.0) simulation_manager.add_safety(3.0)
interaction_feedback.emit( interaction_feedback.emit(
@@ -112,11 +117,69 @@ func get_interaction_context() -> PlayerInteractionResult:
"Work here · +2 knowledge", "Work here · +2 knowledge",
study_site study_site
) )
if is_near_storage(woodpile_storage) and _get_player_carried(SimulationIds.RESOURCE_WOOD) > 0.0:
return _build_deposit_context(
SimulationIds.RESOURCE_WOOD,
SimulationIds.STORAGE_VILLAGE_WOODPILE,
woodpile_storage,
"Village woodpile"
)
if is_near_storage(pantry_storage) and _get_player_carried(SimulationIds.RESOURCE_FOOD) > 0.0:
return _build_deposit_context(
SimulationIds.RESOURCE_FOOD,
SimulationIds.STORAGE_VILLAGE_PANTRY,
pantry_storage,
"Village pantry"
)
if is_near_storage(pantry_storage): if is_near_storage(pantry_storage):
return _build_pantry_context() return _build_pantry_context()
var talk_npc := _find_talk_villager()
if talk_npc != null:
return _build_talk_context(talk_npc)
return null return null
func _find_talk_villager() -> SimNPC:
if (
simulation_manager == null
or world_view_manager == null
or not world_view_manager.has_method("find_nearest_active_npc_id")
):
return null
var npc_id: int = world_view_manager.find_nearest_active_npc_id(
global_position, interaction_range
)
if npc_id < 0:
return null
var npc := _find_npc(npc_id)
if npc == null or npc.is_dead:
return null
return npc
func _build_talk_context(npc: SimNPC) -> PlayerInteractionResult:
var talk: PlayerTalkResult = simulation_manager.get_villager_talk(npc.id)
if talk == null or not talk.has_need:
return null
var prompt := "Talk to %s" % npc.npc_name
if talk.quest_available:
prompt = "Accept %s's request" % npc.npc_name
elif talk.can_accept:
prompt = "Offer to help %s" % npc.npc_name
var detail := talk.need_text
if talk.quest_available:
detail = "Help +%.0f Standing" % talk.quest_reward
return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_TALK,
SimulationIds.ACTION_IDLE,
SimulationIds.npc_inventory_id(npc.id),
npc.npc_name,
prompt,
detail,
null
)
func get_nearby_villager_inspection() -> VillagerInspectionResult: func get_nearby_villager_inspection() -> VillagerInspectionResult:
if ( if (
simulation_manager == null simulation_manager == null
@@ -142,6 +205,7 @@ func get_nearby_villager_inspection() -> VillagerInspectionResult:
var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id) var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id)
if decision != null and decision.action_id == npc.current_task: if decision != null and decision.action_id == npc.current_task:
reason = decision.reason reason = decision.reason
var opportunity_note := _build_opportunity_note(npc)
return VillagerInspectionResult.new( return VillagerInspectionResult.new(
npc.id, npc.id,
npc.npc_name, npc.npc_name,
@@ -151,10 +215,62 @@ func get_nearby_villager_inspection() -> VillagerInspectionResult:
npc.target_id, npc.target_id,
target_name, target_name,
carried_amounts, carried_amounts,
reason reason,
opportunity_note
) )
func _build_opportunity_note(npc: SimNPC) -> VillagerOpportunityNote:
if (
simulation_manager == null
or not simulation_manager.has_method("get_active_opportunity")
or not simulation_manager.has_method("get_active_opportunity_player_response")
or not simulation_manager.has_method("get_active_opportunity_helper")
):
return null
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
if (
opportunity == null
or opportunity.get_interested_npc_id() != npc.id
or (
opportunity.get_target_id()
not in [SimulationIds.STORAGE_VILLAGE_PANTRY, SimulationIds.STORAGE_VILLAGE_WOODPILE]
)
):
return null
var player_response: OpportunityPlayerResponseResult = (
simulation_manager.get_active_opportunity_player_response()
)
var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
var helper_name := ""
if helper != null:
var helper_npc := _find_npc(helper.helper_npc_id)
if helper_npc != null:
helper_name = helper_npc.npc_name
return VillagerOpportunityNote.derive(
opportunity,
player_response,
helper,
helper_name,
_get_opportunity_storage(opportunity),
_get_target_display_name(opportunity.get_target_id()),
_display_id(opportunity.get_resource_id()).to_lower()
)
func _get_opportunity_storage(opportunity: OpportunityStateRecord) -> StorageStateRecord:
if simulation_manager == null:
return null
if not simulation_manager.has_method("get_pantry"):
return null
if opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_PANTRY:
return simulation_manager.get_pantry() as StorageStateRecord
if opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_WOODPILE:
if simulation_manager.has_method("get_woodpile"):
return simulation_manager.get_woodpile() as StorageStateRecord
return null
func _find_feed_animal() -> AnimalNode: func _find_feed_animal() -> AnimalNode:
if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null: if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null:
push_error("Player: SimulationManager has no animal-care service") push_error("Player: SimulationManager has no animal-care service")
@@ -237,14 +353,40 @@ func _build_resource_context(node: ResourceNode) -> PlayerInteractionResult:
definition.display_name if definition != null else String(node.action_id).capitalize() definition.display_name if definition != null else String(node.action_id).capitalize()
) )
var display_name := String(node.node_id).replace("_", " ").capitalize() var display_name := String(node.node_id).replace("_", " ").capitalize()
var carried := _get_player_carried(node.resource_id)
var detail := (
"%s · %.1f remaining · carrying %.1f" % [display_name, node.get_amount_remaining(), carried]
)
if _get_available_carry() <= 0.0:
detail = "Hands full — deposit before gathering"
return PlayerInteractionResult.new( return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_RESOURCE, PlayerInteractionResult.KIND_RESOURCE,
node.action_id, node.action_id,
node.node_id, node.node_id,
display_name, display_name,
prompt, prompt,
"%s · %.1f remaining" % [display_name, node.get_amount_remaining()], detail,
node node,
"" if _get_available_carry() > 0.0 else "You cannot carry more right now."
)
func _build_deposit_context(
resource_id: StringName, target_id: StringName, target_node: StorageNode, display_name: String
) -> PlayerInteractionResult:
var carried := _get_player_carried(resource_id)
return PlayerInteractionResult.new(
PlayerInteractionResult.KIND_DEPOSIT,
(
SimulationIds.ACTION_DEPOSIT_FOOD
if resource_id == SimulationIds.RESOURCE_FOOD
else SimulationIds.ACTION_DEPOSIT_WOOD
),
target_id,
display_name,
"Deposit %s" % String(resource_id).capitalize(),
"%.1f %s in your hands" % [carried, String(resource_id).to_lower()],
target_node
) )
@@ -296,23 +438,83 @@ func _execute_animal_interaction(context: PlayerInteractionResult) -> void:
func _execute_resource_interaction(context: PlayerInteractionResult) -> void: func _execute_resource_interaction(context: PlayerInteractionResult) -> void:
var node := context.target_node as ResourceNode var node := context.target_node as ResourceNode
if node == null or not simulation_manager.has_method("harvest_resource_node"): if node == null or not simulation_manager.has_method("player_gather"):
push_error("Player: SimulationManager cannot harvest the resolved ResourceNode") push_error("Player: SimulationManager cannot gather the resolved ResourceNode")
return return
var extracted: float = simulation_manager.harvest_resource_node(node) var extracted: float = simulation_manager.player_gather(node)
if extracted <= 0.0: if extracted <= 0.0:
interaction_feedback.emit( interaction_feedback.emit(
"%s is unavailable" % context.display_name, "Nothing could be gathered.", false "%s is unavailable" % context.display_name,
(
context.blocked_reason
if not context.blocked_reason.is_empty()
else "Nothing could be gathered."
),
false
) )
return return
var destination := "pantry" if node.resource_id == SimulationIds.RESOURCE_FOOD else "woodpile" var carried := _get_player_carried(node.resource_id)
interaction_feedback.emit( interaction_feedback.emit(
"%s gathered" % String(node.resource_id).capitalize(), "%s gathered" % String(node.resource_id).capitalize(),
"%.1f moved to the village %s." % [extracted, destination], "%.1f added to your hands · carrying %.1f" % [extracted, carried],
true true
) )
func _deposit_carried(resource_id: StringName) -> float:
if not simulation_manager.has_method("player_deposit"):
push_error("Player: SimulationManager cannot deposit carried resources")
return 0.0
return simulation_manager.player_deposit(resource_id)
func _execute_deposit_interaction(context: PlayerInteractionResult) -> void:
var resource_id := (
SimulationIds.RESOURCE_FOOD
if context.action_id == SimulationIds.ACTION_DEPOSIT_FOOD
else SimulationIds.RESOURCE_WOOD
)
var deposited: float = _deposit_carried(resource_id)
if deposited <= 0.0:
interaction_feedback.emit("Deposit blocked", "You are not carrying that here.", false)
return
interaction_feedback.emit(
"Deposited to %s" % context.display_name, "%.1f moved into the village." % deposited, true
)
func _execute_talk_interaction(context: PlayerInteractionResult) -> void:
var npc_id := _talk_npc_id_from_target(context.target_id)
if npc_id < 0:
return
var quest: PlayerQuestRecord = simulation_manager.accept_villager_request(npc_id)
if quest == null:
interaction_feedback.emit(
"%s declines" % context.display_name,
"They have no request you can accept right now.",
false
)
return
interaction_feedback.emit(
"Request accepted",
(
"%s asked you for help · +%.0f Standing on completion"
% [context.display_name, quest.get_standing_reward()]
),
true
)
func _talk_npc_id_from_target(target_id: StringName) -> int:
var prefix := "npc_"
var suffix := "_inventory"
var raw := String(target_id)
if not raw.begins_with(prefix) or not raw.ends_with(suffix):
return -1
var id_text := raw.trim_prefix(prefix).trim_suffix(suffix)
return id_text.to_int()
func _execute_pantry_interaction(context: PlayerInteractionResult) -> void: func _execute_pantry_interaction(context: PlayerInteractionResult) -> void:
if not context.is_available(): if not context.is_available():
interaction_feedback.emit("The pantry is empty", context.blocked_reason, false) interaction_feedback.emit("The pantry is empty", context.blocked_reason, false)
@@ -320,11 +522,45 @@ func _execute_pantry_interaction(context: PlayerInteractionResult) -> void:
var food_before := _get_pantry_food() var food_before := _get_pantry_food()
simulation_manager.eat_food(stomach_capacity_for_food) simulation_manager.eat_food(stomach_capacity_for_food)
var consumed := food_before - _get_pantry_food() var consumed := food_before - _get_pantry_food()
var player_hunger := _get_player_hunger()
if consumed > 0.0 and player_hunger > 0.0:
simulation_manager.player_eat(consumed)
interaction_feedback.emit( interaction_feedback.emit(
"Ate from the pantry", "%.0f village food consumed." % consumed, consumed > 0.0 "Ate from the pantry", "%.0f village food consumed." % consumed, consumed > 0.0
) )
func _get_player_hunger() -> float:
if simulation_manager == null or not simulation_manager.has_method("get_player_state"):
return 0.0
var state: PlayerStateRecord = simulation_manager.get_player_state()
return state.get_hunger() if state != null else 0.0
func is_starving() -> bool:
if simulation_manager == null or not simulation_manager.has_method("get_player_state"):
return false
var state: PlayerStateRecord = simulation_manager.get_player_state()
return state.is_starving() if state != null else false
func _needs_speed_factor() -> float:
if simulation_manager == null or not simulation_manager.has_method("get_player_state"):
return 1.0
var state: PlayerStateRecord = simulation_manager.get_player_state()
if state == null or state.is_dead():
return 0.0
var factor := 1.0
if state.is_starving():
factor *= 0.5
var energy := state.get_energy()
if energy < 20.0:
factor *= 0.6
elif energy < 45.0:
factor *= 0.8
return factor
func _get_animal_feed_cost() -> float: func _get_animal_feed_cost() -> float:
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL) var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
return definition.completion_cost_amount if definition != null else 1.0 return definition.completion_cost_amount if definition != null else 1.0
@@ -337,6 +573,20 @@ func _get_pantry_food() -> float:
return pantry.get_amount(SimulationIds.RESOURCE_FOOD) if pantry != null else 0.0 return pantry.get_amount(SimulationIds.RESOURCE_FOOD) if pantry != null else 0.0
func _get_player_carried(resource_id: StringName) -> float:
if simulation_manager == null or not simulation_manager.has_method("get_player_state"):
return 0.0
var state: PlayerStateRecord = simulation_manager.get_player_state()
return state.get_inventory_amount(resource_id) if state != null else 0.0
func _get_available_carry() -> float:
if simulation_manager == null or not simulation_manager.has_method("get_player_state"):
return 0.0
var state: PlayerStateRecord = simulation_manager.get_player_state()
return state.get_available_carry() if state != null else 0.0
func is_near_storage(storage_node: StorageNode) -> bool: func is_near_storage(storage_node: StorageNode) -> bool:
if storage_node == null: if storage_node == null:
return false return false
+172 -153
View File
@@ -1,6 +1,7 @@
extends Node extends Node
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd") const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
const SimulationEventRecorderScript := preload("res://simulation/events/SimulationEventRecorder.gd")
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_system.gd") const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_system.gd")
const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd") const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd")
@@ -23,6 +24,14 @@ signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord)
signal opportunity_opened(opportunity: OpportunityStateRecord) signal opportunity_opened(opportunity: OpportunityStateRecord)
signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord) signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord)
signal opportunity_invalidated(opportunity: OpportunityStateRecord) signal opportunity_invalidated(opportunity: OpportunityStateRecord)
signal player_quest_opened(quest: PlayerQuestRecord)
signal player_quest_completed(quest: PlayerQuestRecord)
signal player_quest_expired(quest: PlayerQuestRecord)
signal player_standing_changed(standing: PlayerStandingRecord)
signal player_tier_advanced(tier: int, tier_name: String)
signal player_needs_changed(state: PlayerStateRecord)
signal player_inventory_changed(state: PlayerStateRecord)
signal player_died(state: PlayerStateRecord)
var village := SimVillage.new() var village := SimVillage.new()
@@ -39,11 +48,16 @@ var tick_count := 0
var wander_random_sources := {} var wander_random_sources := {}
var resource_states: Dictionary = {} var resource_states: Dictionary = {}
var event_log := SimulationEventLogScript.new() var event_log := SimulationEventLogScript.new()
var event_recorder := SimulationEventRecorderScript.new()
var economy := VillageEconomyScript.new() var economy := VillageEconomyScript.new()
var animal_care := AnimalCareSystemScript.new() var animal_care := AnimalCareSystemScript.new()
var relationship_system := RelationshipSystemScript.new() var relationship_system := RelationshipSystemScript.new()
var event_knowledge_system := EventKnowledgeSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new()
var opportunity_system := VillageOpportunitySystem.new() var opportunity_system := VillageOpportunitySystem.new()
var player_quest_system := PlayerQuestSystem.new()
var player_needs := PlayerNeedsSystem.new()
var player_negotiation := PlayerNegotiationSystem.new()
var _last_player_tier := PlayerStandingRecord.TIER_STRANGER
var storage_states: Dictionary: var storage_states: Dictionary:
get: get:
return economy.storage_states return economy.storage_states
@@ -71,11 +85,24 @@ const NPC_NAMES := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"]
func _ready() -> void: func _ready() -> void:
add_to_group("simulation_manager") add_to_group("simulation_manager")
event_log.event_recorded.connect(_on_economic_event_recorded) event_log.event_recorded.connect(_on_economic_event_recorded)
event_recorder.configure(event_log, Callable(self, "get_tick_count_for_recording"))
event_recorder.set_npcs(npcs)
player_needs.configure(economy, event_recorder)
player_needs.set_resource_states(resource_states)
player_negotiation.configure(
player_quest_system,
Callable(self, "_find_npc_by_id"),
Callable(self, "get_active_opportunity"),
Callable(self, "get_active_opportunity_player_response"),
player_quest_opened,
player_quest_expired,
player_standing_changed
)
economy.inventory_changed.connect(_on_economy_inventory_changed) economy.inventory_changed.connect(_on_economy_inventory_changed)
economy.economic_event_requested.connect(_record_economic_event) economy.economic_event_requested.connect(event_recorder.record_economic)
economy.narrative_event_requested.connect(record_narrative_event) economy.narrative_event_requested.connect(event_recorder.record_narrative)
animal_care.economic_event_requested.connect(_record_economic_event_at) animal_care.economic_event_requested.connect(event_recorder.record_economic_at)
animal_care.narrative_event_requested.connect(record_narrative_event) animal_care.narrative_event_requested.connect(event_recorder.record_narrative)
action_selector.relationship_system = relationship_system action_selector.relationship_system = relationship_system
var definition_errors := SimulationDefinitions.validate() var definition_errors := SimulationDefinitions.validate()
if not definition_errors.is_empty(): if not definition_errors.is_empty():
@@ -164,6 +191,11 @@ func simulate_tick() -> void:
if debug_logs: if debug_logs:
print("--- Tick ", tick_count, " ---") print("--- Tick ", tick_count, " ---")
animal_care.advance(tick_count) animal_care.advance(tick_count)
for quest in player_quest_system.consider_animal_care_quests(
animal_care, npcs, get_pantry(), tick_count
):
player_quest_opened.emit(quest)
_advance_player_needs()
var village_was_changed := false var village_was_changed := false
_population_view.rebuild(npcs) _population_view.rebuild(npcs)
for npc in npcs: for npc in npcs:
@@ -175,6 +207,9 @@ func simulate_tick() -> void:
) )
if invalidated != null: if invalidated != null:
opportunity_invalidated.emit(invalidated) opportunity_invalidated.emit(invalidated)
var expired_quest := player_quest_system.on_opportunity_invalidated(invalidated, tick_count)
if expired_quest != null:
player_quest_expired.emit(expired_quest)
if tick_count % get_knowledge_review_interval() == 0: if tick_count % get_knowledge_review_interval() == 0:
_maintain_event_knowledge(true) _maintain_event_knowledge(true)
if debug_logs: if debug_logs:
@@ -239,7 +274,7 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void:
var display_name := ( var display_name := (
definition.display_name if definition != null else String(selection.action_id) definition.display_name if definition != null else String(selection.action_id)
) )
record_narrative_event(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name) event_recorder.record_narrative(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name)
func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void: func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void:
@@ -247,7 +282,7 @@ func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target:
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
npc_died.emit(npc) npc_died.emit(npc)
npc_task_changed.emit(npc, previous_task, npc.current_task) npc_task_changed.emit(npc, previous_task, npc.current_task)
record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id) event_recorder.record_narrative(SimulationIds.EVENT_NPC_DIED, npc.id)
_notify_mourning(npc) _notify_mourning(npc)
if debug_logs: if debug_logs:
print("[SimulationManager] NPC died: ", npc.npc_name) print("[SimulationManager] NPC died: ", npc.npc_name)
@@ -293,7 +328,7 @@ func _apply_action_completion(
SimulationIds.ACTION_SLEEP: SimulationIds.ACTION_SLEEP:
npc.energy = minf(npc.energy + 40.0, 100.0) npc.energy = minf(npc.energy + 40.0, 100.0)
npc.position = npc.home_position npc.position = npc.home_position
record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id) event_recorder.record_narrative(SimulationIds.EVENT_NPC_SLEPT, npc.id)
SimulationIds.ACTION_FEED_ANIMAL: SimulationIds.ACTION_FEED_ANIMAL:
pass pass
SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER: SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
@@ -320,7 +355,7 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
var resource_id := resource_state.get_resource_id() var resource_id := resource_state.get_resource_id()
npc.add_inventory(resource_id, extracted) npc.add_inventory(resource_id, extracted)
npc_inventory_changed.emit(npc, resource_id, npc.get_inventory_amount(resource_id)) npc_inventory_changed.emit(npc, resource_id, npc.get_inventory_amount(resource_id))
_record_economic_event( event_recorder.record_economic(
SimulationIds.EVENT_RESOURCE_EXTRACTED, SimulationIds.EVENT_RESOURCE_EXTRACTED,
npc.id, npc.id,
resource_state.get_node_id(), resource_state.get_node_id(),
@@ -329,7 +364,7 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
extracted extracted
) )
if resource_state.get_amount_remaining() <= 0.0: if resource_state.get_amount_remaining() <= 0.0:
record_narrative_event( event_recorder.record_narrative(
SimulationIds.EVENT_RESOURCE_DEPLETED, npc.id, resource_state.get_node_id() SimulationIds.EVENT_RESOURCE_DEPLETED, npc.id, resource_state.get_node_id()
) )
if debug_logs: if debug_logs:
@@ -540,93 +575,8 @@ func _notify_mourning(dead_npc: SimNPC) -> void:
print("[SimulationManager] ", mourner.npc_name, " is mourning ", dead_npc.npc_name) print("[SimulationManager] ", mourner.npc_name, " is mourning ", dead_npc.npc_name)
func _record_economic_event( func get_tick_count_for_recording() -> int:
event_type: StringName, return tick_count
actor_id: int,
source_id: StringName,
destination_id: StringName,
item_id: StringName,
amount: float
) -> void:
_record_economic_event_at(
event_type,
actor_id,
source_id,
destination_id,
item_id,
amount,
_get_event_world_position(actor_id, source_id, destination_id)
)
func _record_economic_event_at(
event_type: StringName,
actor_id: int,
source_id: StringName,
destination_id: StringName,
item_id: StringName,
amount: float,
world_position: Vector3
) -> void:
event_log.record_economic(
tick_count, event_type, actor_id, source_id, destination_id, item_id, amount, world_position
)
func record_narrative_event(
event_type: StringName,
actor_id: int,
source_id: StringName = &"",
action_display: String = "",
action_id: StringName = &"",
item_id: StringName = &"",
required_amount: float = 0.0
) -> void:
_record_narrative_event_at(
event_type,
actor_id,
source_id,
action_display,
_get_event_world_position(actor_id, source_id, &""),
action_id,
item_id,
required_amount
)
func _record_narrative_event_at(
event_type: StringName,
actor_id: int,
source_id: StringName,
action_display: String,
world_position: Vector3,
action_id: StringName = &"",
item_id: StringName = &"",
required_amount: float = 0.0
) -> void:
event_log.record_narrative(
tick_count,
event_type,
actor_id,
source_id,
action_display,
world_position,
action_id,
item_id,
required_amount
)
func _get_event_world_position(
actor_id: int, source_id: StringName, destination_id: StringName
) -> Vector3:
for npc in npcs:
if npc.id != actor_id:
continue
if not npc.target_id.is_empty() and npc.target_id in [source_id, destination_id]:
return npc.travel_target_position
return npc.position
return Vector3.ZERO
func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]: func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
@@ -643,21 +593,41 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
) )
for learned_record in learned_records: for learned_record in learned_records:
_apply_new_event_knowledge(learned_record, event) _apply_new_event_knowledge(learned_record, event)
if StringName(event.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED:
_finish_quest_resolution(
player_quest_system.on_animal_fed(
animal_care.get_state(StringName(event.data["destination_id"])), event, tick_count
)
)
_update_opportunity_from_event(event) _update_opportunity_from_event(event)
economic_event_recorded.emit(event) economic_event_recorded.emit(event)
func _finish_quest_resolution(resolution: Dictionary) -> void:
if not resolution.has("quest"):
return
var quest: PlayerQuestRecord = resolution["quest"]
if resolution["completed"]:
player_quest_completed.emit(quest)
var standing := player_quest_system.standing
player_standing_changed.emit(standing)
if standing.get_tier() > _last_player_tier:
_last_player_tier = standing.get_tier()
player_tier_advanced.emit(standing.get_tier(), standing.get_tier_name())
return
player_quest_expired.emit(quest)
func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bool: func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bool:
var speaker := _find_npc_by_id(speaker_id) var speaker := _find_npc_by_id(speaker_id)
var listener := _find_npc_by_id(listener_id) var listener := _find_npc_by_id(listener_id)
if not _can_communicate_at_shared_activity(speaker, listener): if not _can_communicate_at_shared_activity(speaker, listener):
return false return false
var known_event_ids := event_knowledge_system.get_communicable_event_ids( for event_id in event_knowledge_system.get_communicable_event_ids(
speaker.id, speaker.id,
_get_lasting_event_ids(speaker.id), _get_lasting_event_ids(speaker.id),
opportunity_system.get_open_trigger_event_id() opportunity_system.get_open_trigger_event_id()
) ):
for event_id in known_event_ids:
var event := event_log.get_by_id(event_id) var event := event_log.get_by_id(event_id)
if event == null: if event == null:
continue continue
@@ -674,31 +644,8 @@ func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bo
func _can_communicate_at_shared_activity(speaker: SimNPC, listener: SimNPC) -> bool: func _can_communicate_at_shared_activity(speaker: SimNPC, listener: SimNPC) -> bool:
if speaker == null or listener == null or speaker.id == listener.id: return EventKnowledgeSystem.can_communicate_at_shared_activity(
return false speaker, listener, storage_states, KNOWLEDGE_COMMUNICATION_RADIUS
if speaker.is_dead or listener.is_dead:
return false
if (
speaker.task_state != SimNPC.TASK_STATE_WORKING
or listener.task_state != SimNPC.TASK_STATE_WORKING
):
return false
if speaker.target_id.is_empty() or speaker.target_id != listener.target_id:
return false
if storage_states.has(speaker.target_id):
return false
var speaker_action := SimulationDefinitions.get_action(speaker.current_task)
var listener_action := SimulationDefinitions.get_action(listener.current_task)
if (
speaker_action == null
or listener_action == null
or speaker_action.target_type != SimulationIds.TARGET_ACTIVITY
or listener_action.target_type != SimulationIds.TARGET_ACTIVITY
):
return false
return (
speaker.position.distance_squared_to(listener.position)
<= KNOWLEDGE_COMMUNICATION_RADIUS * KNOWLEDGE_COMMUNICATION_RADIUS
) )
@@ -729,8 +676,19 @@ func _update_opportunity_from_event(event: EconomicEventRecord) -> void:
return return
if changed.get_status() == OpportunityStateRecord.STATUS_OPEN: if changed.get_status() == OpportunityStateRecord.STATUS_OPEN:
opportunity_opened.emit(changed) opportunity_opened.emit(changed)
var quest := player_quest_system.consider_opportunity_opened(
changed,
get_active_opportunity_player_response(),
tick_count,
player_quest_system.can_request_personally(changed)
)
if quest != null:
player_quest_opened.emit(quest)
return return
opportunity_resolved.emit(changed, event) opportunity_resolved.emit(changed, event)
_finish_quest_resolution(
player_quest_system.on_opportunity_resolved(changed, event, tick_count)
)
_maintain_event_knowledge(false) _maintain_event_knowledge(false)
@@ -745,33 +703,9 @@ func _maintain_event_knowledge(expire_by_age: bool) -> void:
func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]: func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]:
var lasting: Array[KnownEventStateRecord] = [] return event_knowledge_system.collect_lasting_records(
var seen := {} relationship_system.get_all_sorted(), opportunity_system.get_open_opportunity()
for relationship in relationship_system.get_all_sorted(): )
var event_id := relationship.get_last_trust_cause_event_id()
if event_id == RelationshipStateRecord.NO_CAUSE_EVENT:
continue
var record := event_knowledge_system.get_record(relationship.get_observer_id(), event_id)
if record == null:
continue
var key := "%d:%d" % [record.get_knower_id(), record.get_event_id()]
if seen.has(key):
continue
seen[key] = true
lasting.append(record)
var open_opportunity: OpportunityStateRecord = opportunity_system.get_open_opportunity()
if open_opportunity != null:
var opportunity_record := event_knowledge_system.get_record(
open_opportunity.get_interested_npc_id(), open_opportunity.get_trigger_event_id()
)
if opportunity_record != null:
var opportunity_key := (
"%d:%d" % [opportunity_record.get_knower_id(), opportunity_record.get_event_id()]
)
if not seen.has(opportunity_key):
seen[opportunity_key] = true
lasting.append(opportunity_record)
return lasting
func _get_lasting_event_ids(npc_id: int) -> Array[int]: func _get_lasting_event_ids(npc_id: int) -> Array[int]:
@@ -816,6 +750,76 @@ func get_active_opportunity() -> OpportunityStateRecord:
return opportunity_system.get_open_opportunity() return opportunity_system.get_open_opportunity()
func get_active_player_quest() -> PlayerQuestRecord:
return player_quest_system.get_active_quest()
func _advance_player_needs() -> void:
if player_needs.advance(village):
player_died.emit(player_needs.state)
player_needs.apply_death_consequence(player_quest_system.standing)
_last_player_tier = player_quest_system.standing.get_tier()
player_standing_changed.emit(player_quest_system.standing)
return
player_needs_changed.emit(player_needs.state)
func respawn_player() -> bool:
if not player_needs.state.is_dead():
return false
player_needs.respawn()
player_needs_changed.emit(player_needs.state)
return true
func get_player_state() -> PlayerStateRecord:
return player_needs.state
func player_eat(amount: float) -> float:
player_needs_changed.emit(player_needs.state)
return player_needs.eat(amount)
func player_gather(node: ResourceNode) -> float:
var gathered := player_needs.gather_node(node)
if gathered > 0.0:
player_inventory_changed.emit(player_needs.state)
return gathered
func player_deposit(resource_id: StringName) -> float:
var deposited := player_needs.deposit_resource(resource_id)
if deposited > 0.0:
village_changed.emit(village)
player_inventory_changed.emit(player_needs.state)
return deposited
func get_player_quests() -> Array[PlayerQuestRecord]:
return player_quest_system.get_all_sorted()
func get_villager_talk(npc_id: int) -> PlayerTalkResult:
return player_negotiation.get_talk(npc_id)
func accept_villager_request(npc_id: int) -> PlayerQuestRecord:
return player_negotiation.accept_request(npc_id, tick_count)
func decline_villager_request(npc_id: int) -> PlayerQuestRecord:
return player_negotiation.decline_request(npc_id, tick_count)
func get_latest_player_quest_for_requester(requester_npc_id: int) -> PlayerQuestRecord:
return player_quest_system.get_latest_for_requester(requester_npc_id)
func get_player_standing() -> PlayerStandingRecord:
return player_quest_system.standing
func get_active_opportunity_helper() -> OpportunityHelperResult: func get_active_opportunity_helper() -> OpportunityHelperResult:
var active := get_active_opportunity() var active := get_active_opportunity()
if active == null: if active == null:
@@ -890,6 +894,11 @@ func get_pantry() -> StorageStateRecord:
return economy.get_pantry() return economy.get_pantry()
func get_animal_display_name(animal_id: StringName) -> String:
var animal_state := animal_care.get_state(animal_id)
return animal_state.get_display_name() if animal_state != null else ""
func get_woodpile() -> StorageStateRecord: func get_woodpile() -> StorageStateRecord:
return economy.get_woodpile() return economy.get_woodpile()
@@ -1015,7 +1024,7 @@ func harvest_resource_node(node: ResourceNode) -> float:
if node.interaction_point != null if node.interaction_point != null
else node.global_position else node.global_position
) )
_record_economic_event_at( event_recorder.record_economic_at(
SimulationIds.EVENT_RESOURCE_EXTRACTED, SimulationIds.EVENT_RESOURCE_EXTRACTED,
-1, -1,
resource_state.get_node_id(), resource_state.get_node_id(),
@@ -1033,7 +1042,7 @@ func harvest_resource_node(node: ResourceNode) -> float:
event_position event_position
) )
if resource_state.get_amount_remaining() <= 0.0: if resource_state.get_amount_remaining() <= 0.0:
_record_narrative_event_at( event_recorder.record_narrative_at(
SimulationIds.EVENT_RESOURCE_DEPLETED, SimulationIds.EVENT_RESOURCE_DEPLETED,
-1, -1,
resource_state.get_node_id(), resource_state.get_node_id(),
@@ -1101,6 +1110,7 @@ func create_state_record() -> SimulationStateRecord:
"wander_random_streams": wander_streams, "wander_random_streams": wander_streams,
"next_event_id": next_event_id, "next_event_id": next_event_id,
"next_opportunity_id": opportunity_system.next_opportunity_id, "next_opportunity_id": opportunity_system.next_opportunity_id,
"next_quest_id": player_quest_system.next_quest_id,
"cycle_duration_seconds": clock.cycle_duration_seconds "cycle_duration_seconds": clock.cycle_duration_seconds
} }
record.village = VillageStateRecord.capture(village) record.village = VillageStateRecord.capture(village)
@@ -1125,6 +1135,10 @@ func create_state_record() -> SimulationStateRecord:
record.event_knowledge.append(known_event) record.event_knowledge.append(known_event)
for opportunity in opportunity_system.get_all_sorted(): for opportunity in opportunity_system.get_all_sorted():
record.opportunities.append(opportunity) record.opportunities.append(opportunity)
record.player_standing = player_quest_system.standing
for quest in player_quest_system.get_all_sorted():
record.player_quests.append(quest)
record.player_state = player_needs.state
return record return record
@@ -1162,6 +1176,11 @@ func restore_state(record: SimulationStateRecord) -> bool:
relationship_system.restore(record.relationships) relationship_system.restore(record.relationships)
event_knowledge_system.restore(record.event_knowledge) event_knowledge_system.restore(record.event_knowledge)
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"])) opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
player_quest_system.restore(
record.player_quests, int(record.simulation.get("next_quest_id", 0)), record.player_standing
)
_last_player_tier = player_quest_system.standing.get_tier()
player_needs.restore(record.player_state)
_maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0) _maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0)
wander_random_sources.clear() wander_random_sources.clear()
var wander_streams: Array = record.simulation["wander_random_streams"] var wander_streams: Array = record.simulation["wander_random_streams"]
+14
View File
@@ -99,6 +99,20 @@ func get_state(animal_id: StringName) -> AnimalStateRecord:
return states.get(animal_id) as AnimalStateRecord return states.get(animal_id) as AnimalStateRecord
func get_all_states() -> Array[AnimalStateRecord]:
var all_states: Array[AnimalStateRecord] = []
for animal_id in states.keys():
var animal_state := states[animal_id] as AnimalStateRecord
if animal_state != null:
all_states.append(animal_state)
all_states.sort_custom(_sort_states_by_id)
return all_states
static func _sort_states_by_id(first: AnimalStateRecord, second: AnimalStateRecord) -> bool:
return String(first.get_animal_id()) < String(second.get_animal_id())
func reserve(animal_id: StringName, agent_id: int) -> bool: func reserve(animal_id: StringName, agent_id: int) -> bool:
var animal_state := get_state(animal_id) var animal_state := get_state(animal_id)
return animal_state != null and animal_state.can_npc_feed() and animal_state.reserve(agent_id) return animal_state != null and animal_state.can_npc_feed() and animal_state.reserve(agent_id)
+1
View File
@@ -53,6 +53,7 @@ const EVENT_ANIMAL_FED := &"animal_fed"
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood" const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
const OPPORTUNITY_FEED_HUNGRY_ANIMAL := &"feed_hungry_animal"
const OPPORTUNITY_STATUS_OPEN := &"open" const OPPORTUNITY_STATUS_OPEN := &"open"
const OPPORTUNITY_STATUS_RESOLVED := &"resolved" const OPPORTUNITY_STATUS_RESOLVED := &"resolved"
const OPPORTUNITY_STATUS_INVALIDATED := &"invalidated" const OPPORTUNITY_STATUS_INVALIDATED := &"invalidated"
@@ -0,0 +1,113 @@
class_name SimulationEventRecorder
extends RefCounted
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
var event_log: RefCounted
var npcs: Array[SimNPC] = []
var tick_provider: Callable
func configure(log: RefCounted, tick_source: Callable) -> void:
event_log = log
tick_provider = tick_source
func set_npcs(npc_list: Array[SimNPC]) -> void:
npcs = npc_list
func record_economic(
event_type: StringName,
actor_id: int,
source_id: StringName,
destination_id: StringName,
item_id: StringName,
amount: float
) -> void:
record_economic_at(
event_type,
actor_id,
source_id,
destination_id,
item_id,
amount,
get_event_world_position(actor_id, source_id, destination_id)
)
func record_economic_at(
event_type: StringName,
actor_id: int,
source_id: StringName,
destination_id: StringName,
item_id: StringName,
amount: float,
world_position: Vector3
) -> void:
event_log.record_economic(
tick_provider.call(),
event_type,
actor_id,
source_id,
destination_id,
item_id,
amount,
world_position
)
func record_narrative(
event_type: StringName,
actor_id: int,
source_id: StringName = &"",
action_display: String = "",
action_id: StringName = &"",
item_id: StringName = &"",
required_amount: float = 0.0
) -> void:
record_narrative_at(
event_type,
actor_id,
source_id,
action_display,
get_event_world_position(actor_id, source_id, &""),
action_id,
item_id,
required_amount
)
func record_narrative_at(
event_type: StringName,
actor_id: int,
source_id: StringName,
action_display: String,
world_position: Vector3,
action_id: StringName = &"",
item_id: StringName = &"",
required_amount: float = 0.0
) -> void:
event_log.record_narrative(
tick_provider.call(),
event_type,
actor_id,
source_id,
action_display,
world_position,
action_id,
item_id,
required_amount
)
func get_event_world_position(
actor_id: int, source_id: StringName, destination_id: StringName
) -> Vector3:
for npc in npcs:
if npc.id != actor_id:
continue
if not npc.target_id.is_empty() and npc.target_id in [source_id, destination_id]:
return npc.travel_target_position
return npc.position
return Vector3.ZERO
@@ -0,0 +1 @@
uid://yl07qwlrpxjs
@@ -204,6 +204,65 @@ func restore(records: Array[KnownEventStateRecord]) -> void:
known_events[_key(record.get_knower_id(), record.get_event_id())] = record known_events[_key(record.get_knower_id(), record.get_event_id())] = record
static func can_communicate_at_shared_activity(
speaker: SimNPC, listener: SimNPC, storage_states: Dictionary, radius: float
) -> bool:
if speaker == null or listener == null or speaker.id == listener.id:
return false
if speaker.is_dead or listener.is_dead:
return false
if (
speaker.task_state != SimNPC.TASK_STATE_WORKING
or listener.task_state != SimNPC.TASK_STATE_WORKING
):
return false
if speaker.target_id.is_empty() or speaker.target_id != listener.target_id:
return false
if storage_states.has(speaker.target_id):
return false
var speaker_action := SimulationDefinitions.get_action(speaker.current_task)
var listener_action := SimulationDefinitions.get_action(listener.current_task)
if (
speaker_action == null
or listener_action == null
or speaker_action.target_type != SimulationIds.TARGET_ACTIVITY
or listener_action.target_type != SimulationIds.TARGET_ACTIVITY
):
return false
return speaker.position.distance_squared_to(listener.position) <= radius * radius
func collect_lasting_records(
relationships: Array[RelationshipStateRecord], open_opportunity: OpportunityStateRecord
) -> Array[KnownEventStateRecord]:
var lasting: Array[KnownEventStateRecord] = []
var seen := {}
for relationship in relationships:
var event_id := relationship.get_last_trust_cause_event_id()
if event_id == RelationshipStateRecord.NO_CAUSE_EVENT:
continue
var record := get_record(relationship.get_observer_id(), event_id)
if record == null:
continue
var key := "%d:%d" % [record.get_knower_id(), record.get_event_id()]
if seen.has(key):
continue
seen[key] = true
lasting.append(record)
if open_opportunity != null:
var opportunity_record := get_record(
open_opportunity.get_interested_npc_id(), open_opportunity.get_trigger_event_id()
)
if opportunity_record != null:
var opportunity_key := (
"%d:%d" % [opportunity_record.get_knower_id(), opportunity_record.get_event_id()]
)
if not seen.has(opportunity_key):
seen[opportunity_key] = true
lasting.append(opportunity_record)
return lasting
func _remember( func _remember(
knower_id: int, knower_id: int,
event_id: int, event_id: int,
@@ -537,6 +537,8 @@ static func _is_valid_supply(
var event_type := StringName(event.data["event_type"]) var event_type := StringName(event.data["event_type"])
var actor_id := int(event.data["actor_id"]) var actor_id := int(event.data["actor_id"])
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED:
if actor_id < 0:
return StringName(event.data["source_id"]) == &"player_carry"
return ( return (
_find_npc(actor_id, npcs) != null _find_npc(actor_id, npcs) != null
and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id)
+117
View File
@@ -0,0 +1,117 @@
class_name PlayerNeedsSystem
extends RefCounted
var state := PlayerStateRecord.create()
var economy: RefCounted
var event_recorder: RefCounted
var resource_states: Dictionary = {}
func configure(economy_service: RefCounted, recorder: RefCounted) -> void:
economy = economy_service
event_recorder = recorder
func set_resource_states(states: Dictionary) -> void:
resource_states = states
func advance(village: SimVillage) -> bool:
if state.is_dead():
return false
var hunger_rate := 0.125 * village.food_modifier
if state.advance(hunger_rate, 0.5):
state.die()
return true
return false
func eat(amount: float) -> float:
return state.eat(amount)
func gather_node(node: ResourceNode) -> float:
if node == null:
return 0.0
var resource_state := resource_states.get(node.node_id) as ResourceStateRecord
if resource_state == null or not resource_state.can_player_use_resource():
return 0.0
var available := state.get_available_carry()
if available <= 0.0:
return 0.0
var extracted := resource_state.extract(minf(resource_state.get_yield_per_action(), available))
if extracted <= 0.0:
return 0.0
var carried := state.add_inventory(resource_state.get_resource_id(), extracted)
if carried <= 0.0:
resource_state.data["amount_remaining"] = (
resource_state.get_amount_remaining() + extracted
)
return 0.0
var event_position := (
node.interaction_point.global_position
if node.interaction_point != null
else node.global_position
)
event_recorder.record_economic_at(
SimulationIds.EVENT_RESOURCE_EXTRACTED,
-1,
resource_state.get_node_id(),
&"player_carry",
resource_state.get_resource_id(),
carried,
event_position
)
if resource_state.get_amount_remaining() <= 0.0:
event_recorder.record_narrative_at(
SimulationIds.EVENT_RESOURCE_DEPLETED,
-1,
resource_state.get_node_id(),
"",
event_position
)
return carried
func deposit_resource(resource_id: StringName) -> float:
if resource_id.is_empty():
return 0.0
var carried := state.get_inventory_amount(resource_id)
if carried <= 0.0:
return 0.0
if economy == null or not economy.has_method("get_storage_for_resource"):
return 0.0
var storage: StorageStateRecord = economy.get_storage_for_resource(resource_id)
if storage == null:
return 0.0
var deposited: float = economy.deposit_resource(resource_id, carried)
if deposited <= 0.0:
return 0.0
state.remove_inventory(resource_id, deposited)
event_recorder.record_economic(
SimulationIds.EVENT_STORAGE_DEPOSITED,
-1,
&"player_carry",
storage.get_storage_id(),
resource_id,
deposited
)
return deposited
func get_carried_amount(item_id: StringName) -> float:
return state.get_inventory_amount(item_id)
func respawn() -> void:
if state.is_dead():
state.respawn()
func apply_death_consequence(standing: PlayerStandingRecord) -> void:
var reduced := standing.get_standing() * PlayerStateRecord.DEATH_STANDING_PENALTY
standing.data["standing"] = reduced
func restore(record: PlayerStateRecord) -> void:
state = record if record != null else PlayerStateRecord.create()
@@ -0,0 +1 @@
uid://brf3a7drbfs7
@@ -0,0 +1,67 @@
class_name PlayerNegotiationSystem
extends RefCounted
var quest_system: PlayerQuestSystem
var npc_lookup: Callable
var opportunity_provider: Callable
var player_response_provider: Callable
var quest_opened_signal: Signal
var quest_expired_signal: Signal
var standing_changed_signal: Signal
func configure(
quests: PlayerQuestSystem,
lookup: Callable,
opportunity_source: Callable,
response_source: Callable,
opened: Signal,
expired: Signal,
standing: Signal
) -> void:
quest_system = quests
npc_lookup = lookup
opportunity_provider = opportunity_source
player_response_provider = response_source
quest_opened_signal = opened
quest_expired_signal = expired
standing_changed_signal = standing
func get_talk(npc_id: int) -> PlayerTalkResult:
var npc := npc_lookup.call(npc_id) as SimNPC
if npc == null or npc.is_dead:
return null
return quest_system.get_talk_for_requester(npc.id, npc.npc_name, opportunity_provider.call())
func accept_request(npc_id: int, current_tick: int) -> PlayerQuestRecord:
var npc := npc_lookup.call(npc_id) as SimNPC
if npc == null:
return null
var opportunity: OpportunityStateRecord = opportunity_provider.call()
if opportunity == null or opportunity.get_interested_npc_id() != npc.id:
return null
var result: Dictionary = quest_system.accept_opportunity_quest(
opportunity, player_response_provider.call(), current_tick
)
var quest := result.get("quest") as PlayerQuestRecord
if quest != null and result.get("created", false) and not quest_opened_signal.is_null():
quest_opened_signal.emit(quest)
return quest
func decline_request(npc_id: int, current_tick: int) -> PlayerQuestRecord:
var npc := npc_lookup.call(npc_id) as SimNPC
if npc == null:
return null
var opportunity: OpportunityStateRecord = opportunity_provider.call()
if opportunity == null or opportunity.get_interested_npc_id() != npc.id:
return null
var quest := quest_system.decline_opportunity_quest(opportunity, current_tick)
if quest != null:
if not quest_expired_signal.is_null():
quest_expired_signal.emit(quest)
if not standing_changed_signal.is_null():
standing_changed_signal.emit(quest_system.standing)
return quest
@@ -0,0 +1 @@
uid://7oayv5ga327o
+283
View File
@@ -0,0 +1,283 @@
class_name PlayerQuestSystem
extends RefCounted
const PANTRY_STANDING_REWARD := 8.0
const WOOD_STANDING_REWARD := 8.0
const ANIMAL_CARE_STANDING_REWARD := 6.0
const PERSONAL_TRUST_THRESHOLD := 0.5
const MIN_PERSONAL_REQUEST_TIER := PlayerStandingRecord.TIER_KNOWN_HAND
var quests: Array[PlayerQuestRecord] = []
var next_quest_id := 0
var standing := PlayerStandingRecord.create()
func consider_animal_care(
animal_state: AnimalStateRecord,
requester_npc_id: int,
current_tick: int,
pantry: StorageStateRecord
) -> PlayerQuestRecord:
if (
animal_state == null
or requester_npc_id < 0
or current_tick < 0
or pantry == null
or not animal_state.needs_feed()
or not animal_state.can_player_feed_animal()
):
return null
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
if (
definition == null
or not definition.has_completion_cost()
or (
pantry.get_amount(definition.completion_cost_resource_id)
< definition.completion_cost_amount
)
):
return null
if _find_open_quest_for_animal(animal_state.get_animal_id()) != null:
return null
var quest := PlayerQuestRecord.create(
next_quest_id,
PlayerQuestRecord.NO_OPPORTUNITY,
requester_npc_id,
SimulationIds.OPPORTUNITY_FEED_HUNGRY_ANIMAL,
definition.completion_cost_resource_id,
animal_state.get_animal_id(),
definition.completion_cost_amount,
current_tick,
ANIMAL_CARE_STANDING_REWARD,
animal_state.get_animal_id()
)
next_quest_id += 1
quests.append(quest)
return quest
func on_animal_fed(
animal_state: AnimalStateRecord, resolution_event: EconomicEventRecord, current_tick: int
) -> Dictionary:
if animal_state == null or resolution_event == null:
return {}
var quest := _find_open_quest_for_animal(animal_state.get_animal_id())
if quest == null:
return {}
var event_id := int(resolution_event.data["event_id"])
var actor_id := int(resolution_event.data["actor_id"])
if actor_id < 0:
if not quest.complete(event_id, current_tick):
return {}
standing.grant_standing(quest.get_standing_reward(), quest.get_requester_npc_id())
return {"quest": quest, "completed": true}
if quest.expire(current_tick):
return {"quest": quest, "completed": false}
return {}
func consider_opportunity_opened(
opportunity: OpportunityStateRecord,
player_response: OpportunityPlayerResponseResult,
current_tick: int,
personal_request_eligible := false
) -> PlayerQuestRecord:
if opportunity == null or not opportunity.is_open() or current_tick < 0:
return null
if player_response == null and not personal_request_eligible:
return null
if _find_open_quest_for_opportunity(opportunity.get_opportunity_id()) != null:
return null
var reward := _standing_reward_for(opportunity)
if reward <= 0.0:
return null
var quest := PlayerQuestRecord.create(
next_quest_id,
opportunity.get_opportunity_id(),
opportunity.get_interested_npc_id(),
opportunity.get_opportunity_type(),
opportunity.get_resource_id(),
opportunity.get_target_id(),
opportunity.get_target_amount(),
current_tick,
reward
)
next_quest_id += 1
quests.append(quest)
return quest
func can_request_personally(opportunity: OpportunityStateRecord) -> bool:
if (
opportunity == null
or not opportunity.is_open()
or standing.get_tier() < MIN_PERSONAL_REQUEST_TIER
):
return false
return standing.get_gratitude(opportunity.get_interested_npc_id()) >= PERSONAL_TRUST_THRESHOLD
func on_opportunity_resolved(
opportunity: OpportunityStateRecord, resolution_event: EconomicEventRecord, current_tick: int
) -> Dictionary:
var quest := _find_open_quest_for_opportunity(opportunity.get_opportunity_id())
if quest == null or resolution_event == null:
return {}
var event_id := int(resolution_event.data["event_id"])
var actor_id := int(resolution_event.data["actor_id"])
if actor_id < 0:
if not quest.complete(event_id, current_tick):
return {}
standing.grant_standing(quest.get_standing_reward(), quest.get_requester_npc_id())
return {"quest": quest, "completed": true}
if quest.expire(current_tick):
return {"quest": quest, "completed": false}
return {}
func get_talk_for_requester(
requester_id: int, requester_name: String, opportunity: OpportunityStateRecord
) -> PlayerTalkResult:
var has_need := opportunity != null and opportunity.is_open()
var need_text := ""
if has_need:
need_text = "I'm worried about the %s." % String(opportunity.get_resource_id()).capitalize()
var quest := get_latest_for_requester(requester_id)
var quest_available := quest != null and quest.is_open()
var reward := quest.get_standing_reward() if quest_available else 0.0
var can_accept := has_need and (quest_available or can_request_personally(opportunity))
return PlayerTalkResult.new(
requester_id,
requester_name,
"%s greets you." % requester_name,
has_need,
need_text,
quest_available,
reward,
can_accept,
has_need
)
func accept_opportunity_quest(
opportunity: OpportunityStateRecord,
player_response: OpportunityPlayerResponseResult,
current_tick: int
) -> Dictionary:
var existing := _find_open_quest_for_opportunity(opportunity.get_opportunity_id())
if existing != null:
return {"quest": existing, "created": false}
var created := consider_opportunity_opened(
opportunity, player_response, current_tick, can_request_personally(opportunity)
)
return {"quest": created, "created": created != null}
func decline_opportunity_quest(
opportunity: OpportunityStateRecord, current_tick: int
) -> PlayerQuestRecord:
var quest := _find_open_quest_for_opportunity(opportunity.get_opportunity_id())
if quest == null:
return null
if not quest.expire(current_tick):
return null
var requester_id := quest.get_requester_npc_id()
var gratitude := standing.get_gratitude(requester_id)
standing.data["gratitude"][requester_id] = maxf(gratitude - 0.1, 0.0)
return quest
func on_opportunity_invalidated(
opportunity: OpportunityStateRecord, current_tick: int
) -> PlayerQuestRecord:
var quest := _find_open_quest_for_opportunity(opportunity.get_opportunity_id())
if quest == null:
return null
if quest.expire(current_tick):
return quest
return null
func consider_animal_care_quests(
animal_care: AnimalCareSystem,
npcs: Array[SimNPC],
pantry: StorageStateRecord,
current_tick: int
) -> Array[PlayerQuestRecord]:
var opened: Array[PlayerQuestRecord] = []
for animal_state in animal_care.get_all_states():
var requester := _nearest_npc_id(animal_state.get_position(), npcs)
var quest := consider_animal_care(animal_state, requester, current_tick, pantry)
if quest != null:
opened.append(quest)
return opened
static func _nearest_npc_id(from_position: Vector3, npcs: Array[SimNPC]) -> int:
var nearest_id := -1
var nearest_distance := INF
for npc in npcs:
if npc.is_dead:
continue
var distance := npc.position.distance_squared_to(from_position)
if distance < nearest_distance:
nearest_distance = distance
nearest_id = npc.id
return nearest_id
func get_active_quest() -> PlayerQuestRecord:
for quest in quests:
if quest.is_open():
return quest
return null
func get_all_sorted() -> Array[PlayerQuestRecord]:
var sorted := quests.duplicate()
sorted.sort_custom(_sort_by_id)
return sorted
func get_latest_for_requester(requester_npc_id: int) -> PlayerQuestRecord:
for index in range(quests.size() - 1, -1, -1):
var quest := quests[index]
if quest.get_requester_npc_id() == requester_npc_id:
return quest
return null
func restore(
records: Array[PlayerQuestRecord], restored_next_id: int, standing_record: PlayerStandingRecord
) -> void:
quests = records.duplicate()
quests.sort_custom(_sort_by_id)
next_quest_id = maxi(restored_next_id, 0)
standing = standing_record if standing_record != null else PlayerStandingRecord.create()
func _find_open_quest_for_opportunity(opportunity_id: int) -> PlayerQuestRecord:
for quest in quests:
if quest.is_open() and quest.get_opportunity_id() == opportunity_id:
return quest
return null
func _find_open_quest_for_animal(animal_id: StringName) -> PlayerQuestRecord:
for quest in quests:
if quest.is_open() and quest.has_animal_target() and quest.get_animal_id() == animal_id:
return quest
return null
func _standing_reward_for(opportunity: OpportunityStateRecord) -> float:
match opportunity.get_opportunity_type():
SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY:
return PANTRY_STANDING_REWARD
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
return WOOD_STANDING_REWARD
return 0.0
static func _sort_by_id(first: PlayerQuestRecord, second: PlayerQuestRecord) -> bool:
return first.get_quest_id() < second.get_quest_id()
@@ -0,0 +1 @@
uid://cojertujqx8ts
+224
View File
@@ -0,0 +1,224 @@
class_name PlayerQuestRecord
extends RefCounted
const SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
const STATUS_OPEN := &"open"
const STATUS_COMPLETED := &"completed"
const STATUS_EXPIRED := &"expired"
const NO_OPPORTUNITY := -1
var data: Dictionary
func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true)
static func create(
quest_id: int,
opportunity_id: int,
requester_npc_id: int,
quest_type: StringName,
resource_id: StringName,
target_id: StringName,
target_amount: float,
created_tick: int,
standing_reward: float,
animal_id: StringName = &""
) -> PlayerQuestRecord:
return (
PlayerQuestRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"quest_id": quest_id,
"opportunity_id": opportunity_id,
"requester_npc_id": requester_npc_id,
"quest_type": String(quest_type),
"resource_id": String(resource_id),
"target_id": String(target_id),
"target_amount": target_amount,
"status": String(STATUS_OPEN),
"created_tick": created_tick,
"standing_reward": standing_reward,
"resolution_event_id": -1,
"resolved_tick": -1,
"animal_id": String(animal_id),
}
)
)
static func from_dictionary(record_data: Dictionary) -> PlayerQuestRecord:
var version := int(record_data.get("schema_version", -1))
if version not in [LEGACY_SCHEMA_VERSION, SCHEMA_VERSION]:
return null
var normalized := record_data.duplicate(true)
if version == LEGACY_SCHEMA_VERSION:
normalized["schema_version"] = SCHEMA_VERSION
normalized["animal_id"] = ""
if not (
normalized
. has_all(
[
"quest_id",
"opportunity_id",
"requester_npc_id",
"quest_type",
"resource_id",
"target_id",
"target_amount",
"status",
"created_tick",
"standing_reward",
"resolution_event_id",
"resolved_tick",
"animal_id",
]
)
):
return null
var quest_id := int(normalized["quest_id"])
var opportunity_id := int(normalized["opportunity_id"])
var requester_npc_id := int(normalized["requester_npc_id"])
var quest_type := StringName(normalized["quest_type"])
var resource_id := StringName(normalized["resource_id"])
var target_id := StringName(normalized["target_id"])
var target_amount := float(normalized["target_amount"])
var status := StringName(normalized["status"])
var created_tick := int(normalized["created_tick"])
var standing_reward := float(normalized["standing_reward"])
var resolution_event_id := int(normalized["resolution_event_id"])
var resolved_tick := int(normalized["resolved_tick"])
var animal_id := StringName(normalized["animal_id"])
if (
quest_id < 0
or requester_npc_id < 0
or quest_type.is_empty()
or resource_id.is_empty()
or target_id.is_empty()
or not is_finite(target_amount)
or target_amount <= 0.0
or created_tick < 0
or not is_finite(standing_reward)
or standing_reward <= 0.0
or resolution_event_id < -1
or (opportunity_id < 0 and animal_id.is_empty())
or (not animal_id.is_empty() and opportunity_id != NO_OPPORTUNITY)
):
return null
match status:
STATUS_OPEN:
if resolution_event_id != -1 or resolved_tick != -1:
return null
STATUS_COMPLETED:
if resolution_event_id < 0 or resolved_tick < created_tick:
return null
STATUS_EXPIRED:
if resolution_event_id != -1 or resolved_tick < created_tick:
return null
_:
return null
return (
PlayerQuestRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"quest_id": quest_id,
"opportunity_id": opportunity_id,
"requester_npc_id": requester_npc_id,
"quest_type": String(quest_type),
"resource_id": String(resource_id),
"target_id": String(target_id),
"target_amount": target_amount,
"status": String(status),
"created_tick": created_tick,
"standing_reward": standing_reward,
"resolution_event_id": resolution_event_id,
"resolved_tick": resolved_tick,
"animal_id": String(animal_id),
}
)
)
func get_quest_id() -> int:
return int(data["quest_id"])
func get_opportunity_id() -> int:
return int(data["opportunity_id"])
func get_requester_npc_id() -> int:
return int(data["requester_npc_id"])
func get_quest_type() -> StringName:
return StringName(data["quest_type"])
func get_resource_id() -> StringName:
return StringName(data["resource_id"])
func get_target_id() -> StringName:
return StringName(data["target_id"])
func get_target_amount() -> float:
return float(data["target_amount"])
func get_status() -> StringName:
return StringName(data["status"])
func get_created_tick() -> int:
return int(data["created_tick"])
func get_standing_reward() -> float:
return float(data["standing_reward"])
func get_resolution_event_id() -> int:
return int(data["resolution_event_id"])
func get_resolved_tick() -> int:
return int(data["resolved_tick"])
func get_animal_id() -> StringName:
return StringName(data["animal_id"])
func has_animal_target() -> bool:
return not get_animal_id().is_empty()
func is_open() -> bool:
return get_status() == STATUS_OPEN
func complete(resolution_event_id: int, resolved_tick: int) -> bool:
if not is_open() or resolution_event_id < 0 or resolved_tick < get_created_tick():
return false
data["status"] = String(STATUS_COMPLETED)
data["resolution_event_id"] = resolution_event_id
data["resolved_tick"] = resolved_tick
return true
func expire(resolved_tick: int) -> bool:
if not is_open() or resolved_tick < get_created_tick():
return false
data["status"] = String(STATUS_EXPIRED)
data["resolved_tick"] = resolved_tick
return true
func to_dictionary() -> Dictionary:
return data.duplicate(true)
@@ -0,0 +1 @@
uid://rix0e3rygdoo
+120
View File
@@ -0,0 +1,120 @@
class_name PlayerStandingRecord
extends RefCounted
const SCHEMA_VERSION := 1
const MAX_STANDING := 100.0
const TIER_STRANGER := 0
const TIER_KNOWN_HAND := 1
const TIER_TRUSTED := 2
const TIER_VILLAGE_STEWARD := 3
const TIER_VOICE_OF_JAJCE := 4
const TIER_THRESHOLDS := {
TIER_STRANGER: 0.0,
TIER_KNOWN_HAND: 15.0,
TIER_TRUSTED: 35.0,
TIER_VILLAGE_STEWARD: 60.0,
TIER_VOICE_OF_JAJCE: 85.0,
}
const TIER_NAMES := {
TIER_STRANGER: "Stranger",
TIER_KNOWN_HAND: "Known Hand",
TIER_TRUSTED: "Trusted",
TIER_VILLAGE_STEWARD: "Village Steward",
TIER_VOICE_OF_JAJCE: "Voice of Jajce",
}
var data: Dictionary
func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true)
static func create() -> PlayerStandingRecord:
return (
PlayerStandingRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"standing": 0.0,
"resolved_needs": 0,
"gratitude": {},
}
)
)
static func from_dictionary(record_data: Dictionary) -> PlayerStandingRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
if not record_data.has_all(["standing", "resolved_needs", "gratitude"]):
return null
var standing := float(record_data["standing"])
var resolved_needs := int(record_data["resolved_needs"])
var gratitude = record_data["gratitude"]
if (
not is_finite(standing)
or standing < 0.0
or standing > MAX_STANDING
or resolved_needs < 0
or not gratitude is Dictionary
):
return null
var normalized_gratitude := {}
for raw_npc_id in gratitude:
var npc_id := int(raw_npc_id)
var value := float(gratitude[raw_npc_id])
if npc_id < 0 or not is_finite(value) or value < 0.0 or value > 1.0:
return null
normalized_gratitude[npc_id] = value
return (
PlayerStandingRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"standing": standing,
"resolved_needs": resolved_needs,
"gratitude": normalized_gratitude,
}
)
)
func grant_standing(amount: float, requester_npc_id: int) -> void:
if not is_finite(amount) or amount <= 0.0 or requester_npc_id < 0:
return
data["standing"] = minf(get_standing() + amount, MAX_STANDING)
data["resolved_needs"] = int(data["resolved_needs"]) + 1
var gratitude := get_gratitude(requester_npc_id)
data["gratitude"][requester_npc_id] = minf(gratitude + 0.1, 1.0)
func get_standing() -> float:
return float(data["standing"])
func get_resolved_needs() -> int:
return int(data["resolved_needs"])
func get_gratitude(npc_id: int) -> float:
return float(data["gratitude"].get(npc_id, 0.0))
func get_tier() -> int:
var current := TIER_STRANGER
for tier in [TIER_VOICE_OF_JAJCE, TIER_VILLAGE_STEWARD, TIER_TRUSTED, TIER_KNOWN_HAND]:
if get_standing() >= float(TIER_THRESHOLDS[tier]):
current = tier
break
return current
func get_tier_name() -> String:
return String(TIER_NAMES[get_tier()])
func to_dictionary() -> Dictionary:
return data.duplicate(true)
@@ -0,0 +1 @@
uid://v827brnqruus
+220
View File
@@ -0,0 +1,220 @@
class_name PlayerStateRecord
extends RefCounted
const SCHEMA_VERSION := 1
const HUNGER_DEATH_THRESHOLD := 100.0
const STACK_THRESHOLD := 90.0
const DEATH_STANDING_PENALTY := 0.5
const RESET_HUNGER := 20.0
const RESET_ENERGY := 70.0
const DEFAULT_CARRY_CAPACITY := 4.0
var data: Dictionary
func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true)
static func create() -> PlayerStateRecord:
return (
PlayerStateRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"hunger": 20.0,
"energy": 90.0,
"is_starving": false,
"starvation_ticks": 0,
"starvation_death_threshold": 600,
"is_dead": false,
"deaths": 0,
"inventory": {},
"carry_capacity": DEFAULT_CARRY_CAPACITY,
}
)
)
static func from_dictionary(record_data: Dictionary) -> PlayerStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
if not (
record_data
. has_all(
[
"hunger",
"energy",
"is_starving",
"starvation_ticks",
"starvation_death_threshold",
"is_dead",
"deaths",
]
)
):
return null
var hunger := float(record_data["hunger"])
var energy := float(record_data["energy"])
var starvation_ticks := int(record_data["starvation_ticks"])
var death_threshold := int(record_data["starvation_death_threshold"])
var deaths := int(record_data["deaths"])
if (
not is_finite(hunger)
or hunger < 0.0
or hunger > HUNGER_DEATH_THRESHOLD
or not is_finite(energy)
or energy < 0.0
or energy > 100.0
or starvation_ticks < 0
or death_threshold < 1
or deaths < 0
):
return null
var inventory := {}
var carry_capacity := DEFAULT_CARRY_CAPACITY
if record_data.has("inventory"):
if not record_data["inventory"] is Dictionary:
return null
var normalized := {}
for raw_item_id in record_data["inventory"]:
var item_id := String(raw_item_id)
var amount := float(record_data["inventory"][raw_item_id])
if (
item_id.is_empty()
or normalized.has(item_id)
or not is_finite(amount)
or amount < 0.0
):
return null
normalized[item_id] = amount
inventory = normalized
if record_data.has("carry_capacity"):
carry_capacity = float(record_data["carry_capacity"])
if not is_finite(carry_capacity) or carry_capacity < 0.0:
return null
return (
PlayerStateRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"hunger": hunger,
"energy": energy,
"is_starving": bool(record_data["is_starving"]),
"starvation_ticks": starvation_ticks,
"starvation_death_threshold": death_threshold,
"is_dead": bool(record_data["is_dead"]),
"deaths": deaths,
"inventory": inventory,
"carry_capacity": carry_capacity,
}
)
)
func get_hunger() -> float:
return float(data["hunger"])
func get_energy() -> float:
return float(data["energy"])
func is_starving() -> bool:
return bool(data["is_starving"])
func is_dead() -> bool:
return bool(data["is_dead"])
func get_starvation_ticks() -> int:
return int(data["starvation_ticks"])
func get_deaths() -> int:
return int(data["deaths"])
func get_inventory_amount(item_id: StringName) -> float:
return float(data["inventory"].get(String(item_id), 0.0))
func get_carry_capacity() -> float:
return float(data["carry_capacity"])
func get_carried_total() -> float:
var total := 0.0
for amount in data["inventory"].values():
total += float(amount)
return total
func get_available_carry() -> float:
return maxf(get_carry_capacity() - get_carried_total(), 0.0)
func add_inventory(item_id: StringName, amount: float) -> float:
if item_id.is_empty() or not is_finite(amount) or amount <= 0.0 or is_dead():
return 0.0
var accepted := minf(amount, get_available_carry())
if accepted <= 0.0:
return 0.0
data["inventory"][String(item_id)] = get_inventory_amount(item_id) + accepted
return accepted
func remove_inventory(item_id: StringName, requested_amount: float) -> float:
if item_id.is_empty() or not is_finite(requested_amount) or requested_amount <= 0.0:
return 0.0
var removed := minf(requested_amount, get_inventory_amount(item_id))
if removed <= 0.0:
return 0.0
data["inventory"][String(item_id)] = get_inventory_amount(item_id) - removed
if is_equal_approx(get_inventory_amount(item_id), 0.0):
data["inventory"].erase(String(item_id))
return removed
func eat(amount: float) -> float:
if amount <= 0.0 or is_dead():
return 0.0
var previous := get_hunger()
data["hunger"] = clampf(previous - amount, 0.0, HUNGER_DEATH_THRESHOLD)
var consumed := previous - get_hunger()
if consumed > 0.0:
data["energy"] = minf(get_energy() + consumed * 0.5, 100.0)
return consumed
func advance(hunger_rate: float, energy_rate: float) -> bool:
if is_dead():
return false
data["hunger"] = minf(get_hunger() + maxf(hunger_rate, 0.0), HUNGER_DEATH_THRESHOLD)
data["energy"] = clampf(get_energy() - maxf(energy_rate, 0.0), 0.0, 100.0)
data["is_starving"] = get_hunger() >= STACK_THRESHOLD
if is_starving():
data["starvation_ticks"] = int(data["starvation_ticks"]) + 1
if get_starvation_ticks() >= int(data["starvation_death_threshold"]):
return true
else:
data["starvation_ticks"] = 0
return false
func die() -> void:
data["is_dead"] = true
data["deaths"] = int(data["deaths"]) + 1
func respawn() -> void:
data["hunger"] = RESET_HUNGER
data["energy"] = RESET_ENERGY
data["is_starving"] = false
data["starvation_ticks"] = 0
data["is_dead"] = false
func to_dictionary() -> Dictionary:
return data.duplicate(true)
@@ -0,0 +1 @@
uid://v86fryuks1u3
+97 -2
View File
@@ -2,7 +2,7 @@ class_name SimulationStateRecord
extends RefCounted extends RefCounted
const SCHEMA_NAME := "the_steward.simulation" const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 11 const SCHEMA_VERSION := 13
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
const EVENT_LEGACY_SCHEMA_VERSION := 2 const EVENT_LEGACY_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
@@ -13,6 +13,8 @@ const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8
const ANIMAL_LEGACY_SCHEMA_VERSION := 9 const ANIMAL_LEGACY_SCHEMA_VERSION := 9
const ROUTINE_LEGACY_SCHEMA_VERSION := 10 const ROUTINE_LEGACY_SCHEMA_VERSION := 10
const PREVIOUS_SCHEMA_VERSION := 7 const PREVIOUS_SCHEMA_VERSION := 7
const PLAYER_LEGACY_SCHEMA_VERSION := 11
const PLAYER_STATE_LEGACY_SCHEMA_VERSION := 12
var simulation: Dictionary var simulation: Dictionary
var village: VillageStateRecord var village: VillageStateRecord
@@ -24,6 +26,9 @@ var economic_events: Array[EconomicEventRecord] = []
var relationships: Array[RelationshipStateRecord] = [] var relationships: Array[RelationshipStateRecord] = []
var event_knowledge: Array[KnownEventStateRecord] = [] var event_knowledge: Array[KnownEventStateRecord] = []
var opportunities: Array[OpportunityStateRecord] = [] var opportunities: Array[OpportunityStateRecord] = []
var player_standing: PlayerStandingRecord
var player_quests: Array[PlayerQuestRecord] = []
var player_state: PlayerStateRecord
func to_dictionary() -> Dictionary: func to_dictionary() -> Dictionary:
@@ -52,6 +57,9 @@ func to_dictionary() -> Dictionary:
var opportunity_data: Array[Dictionary] = [] var opportunity_data: Array[Dictionary] = []
for opportunity_record in opportunities: for opportunity_record in opportunities:
opportunity_data.append(opportunity_record.to_dictionary()) opportunity_data.append(opportunity_record.to_dictionary())
var player_quest_data: Array[Dictionary] = []
for player_quest_record in player_quests:
player_quest_data.append(player_quest_record.to_dictionary())
return { return {
"schema": SCHEMA_NAME, "schema": SCHEMA_NAME,
@@ -65,7 +73,10 @@ func to_dictionary() -> Dictionary:
"economic_events": event_data, "economic_events": event_data,
"relationships": relationship_data, "relationships": relationship_data,
"event_knowledge": knowledge_data, "event_knowledge": knowledge_data,
"opportunities": opportunity_data "opportunities": opportunity_data,
"player_standing": player_standing.to_dictionary(),
"player_quests": player_quest_data,
"player_state": player_state.to_dictionary()
} }
@@ -97,6 +108,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
OPPORTUNITY_LEGACY_SCHEMA_VERSION, OPPORTUNITY_LEGACY_SCHEMA_VERSION,
ANIMAL_LEGACY_SCHEMA_VERSION, ANIMAL_LEGACY_SCHEMA_VERSION,
ROUTINE_LEGACY_SCHEMA_VERSION, ROUTINE_LEGACY_SCHEMA_VERSION,
PLAYER_LEGACY_SCHEMA_VERSION,
PLAYER_STATE_LEGACY_SCHEMA_VERSION,
] ]
): ):
record_data = _migrate_legacy(record_data, version) record_data = _migrate_legacy(record_data, version)
@@ -116,6 +129,9 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
"relationships", "relationships",
"event_knowledge", "event_knowledge",
"opportunities", "opportunities",
"player_standing",
"player_quests",
"player_state",
] ]
) )
): ):
@@ -418,6 +434,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
record.relationships.append(relationship_record) record.relationships.append(relationship_record)
var opportunity_ids := {} var opportunity_ids := {}
var opportunity_records_by_id := {}
var trigger_event_ids := {} var trigger_event_ids := {}
var resolution_event_ids := {} var resolution_event_ids := {}
var has_open_opportunity := false var has_open_opportunity := false
@@ -452,12 +469,80 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
): ):
return null return null
opportunity_ids[opportunity_id] = true opportunity_ids[opportunity_id] = true
opportunity_records_by_id[opportunity_id] = opportunity_record
trigger_event_ids[trigger_event_id] = true trigger_event_ids[trigger_event_id] = true
highest_opportunity_id = maxi(highest_opportunity_id, opportunity_id) highest_opportunity_id = maxi(highest_opportunity_id, opportunity_id)
record.opportunities.append(opportunity_record) record.opportunities.append(opportunity_record)
if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id: if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id:
return null return null
var standing_data = record_data["player_standing"]
if not standing_data is Dictionary:
return null
var standing_record := PlayerStandingRecord.from_dictionary(standing_data)
if standing_record == null:
return null
record.player_standing = standing_record
var player_quest_data = record_data["player_quests"]
if not player_quest_data is Array:
return null
var quest_ids := {}
var quest_opportunity_ids := {}
var highest_quest_id := -1
for item in player_quest_data:
if not item is Dictionary:
return null
var quest_record := PlayerQuestRecord.from_dictionary(item)
if quest_record == null:
return null
var quest_id := quest_record.get_quest_id()
var quest_opportunity_id := quest_record.get_opportunity_id()
var requester_id := quest_record.get_requester_npc_id()
var has_valid_requester := npc_ids.has(requester_id)
var has_valid_opportunity := (
quest_record.has_animal_target() or opportunity_ids.has(quest_opportunity_id)
)
if (
quest_ids.has(quest_id)
or quest_opportunity_ids.has(quest_opportunity_id)
or not has_valid_requester
or not has_valid_opportunity
):
return null
if (
quest_record.get_status() == PlayerQuestRecord.STATUS_OPEN
and (
(not quest_record.has_animal_target())
and not opportunity_records_by_id.has(quest_opportunity_id)
)
):
return null
if (
quest_record.get_resolution_event_id() >= 0
and not event_ids.has(quest_record.get_resolution_event_id())
):
return null
var dedup_key := (
"animal:%s" % String(quest_record.get_animal_id())
if quest_record.has_animal_target()
else "opp:%d" % quest_opportunity_id
)
if quest_ids.has(quest_id) or quest_opportunity_ids.has(dedup_key):
return null
quest_ids[quest_id] = true
quest_opportunity_ids[dedup_key] = true
highest_quest_id = maxi(highest_quest_id, quest_id)
record.player_quests.append(quest_record)
var player_state_data = record_data["player_state"]
if not player_state_data is Dictionary:
return null
var player_state_record := PlayerStateRecord.from_dictionary(player_state_data)
if player_state_record == null:
return null
record.player_state = player_state_record
return record return record
@@ -687,6 +772,8 @@ static func _is_valid_supply_resolution(
): ):
return false return false
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED:
if actor_id < 0:
return StringName(event.data["source_id"]) == &"player_carry"
return ( return (
npc_ids.has(actor_id) npc_ids.has(actor_id)
and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id)
@@ -772,6 +859,14 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
var opportunity_simulation_data: Dictionary = migrated.get("simulation", {}) var opportunity_simulation_data: Dictionary = migrated.get("simulation", {})
opportunity_simulation_data["next_opportunity_id"] = 0 opportunity_simulation_data["next_opportunity_id"] = 0
migrated["simulation"] = opportunity_simulation_data migrated["simulation"] = opportunity_simulation_data
if version <= PLAYER_LEGACY_SCHEMA_VERSION:
migrated["player_standing"] = PlayerStandingRecord.create().to_dictionary()
migrated["player_quests"] = []
var quest_simulation_data: Dictionary = migrated.get("simulation", {})
quest_simulation_data["next_quest_id"] = 0
migrated["simulation"] = quest_simulation_data
if version <= PLAYER_STATE_LEGACY_SCHEMA_VERSION:
migrated["player_state"] = PlayerStateRecord.create().to_dictionary()
return migrated return migrated
@@ -0,0 +1,161 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
for _frame in 10:
await physics_frame
var manager: Node = main_scene.get_node("SimulationManager")
var view: Node = main_scene.get_node("WorldViewManager")
var player: CharacterBody3D = main_scene.get_node("Player")
var demo: Node = main_scene.get_node("DemoController")
var field_note := main_scene.get_node("VillagerInspectionLayer/VillagerFieldNote")
manager.set_process(false)
player.set_physics_process(false)
_freeze_visuals(view)
var staged: Dictionary = demo.call("stage_pantry_crisis")
_check(not staged.is_empty(), "The runtime proof should stage a real open pantry need")
if staged.is_empty():
_finish()
return
var interested: SimNPC = staged["interested"]
var pantry_position: Vector3 = (
StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY).get_interaction_position()
)
player.global_position = pantry_position
_move_population_away(manager, view, interested.id, pantry_position)
_set_loaded_position(manager, view, interested, pantry_position + Vector3(1.0, 0.0, 0.0))
var checksum_before: String = manager.get_state_checksum()
var serialized_before: String = manager.serialize_state()
var events_before: int = manager.economic_events.size()
for _query in 3:
player.call("get_nearby_villager_inspection")
field_note.call("refresh_note", true)
var need_label := field_note.get_node("Copy/Need") as Label
_check(
(
manager.get_state_checksum() == checksum_before
and manager.serialize_state() == serialized_before
and manager.economic_events.size() == events_before
and field_note.visible
and need_label.visible
and "Need" in need_label.text
),
"The inspected interested villager should surface the real need without mutating state",
)
if not need_label.visible:
_finish()
return
var helper: OpportunityHelperResult = manager.get_active_opportunity_helper()
var helper_npc := _find_npc(manager.npcs, helper.helper_npc_id) if helper != null else null
_check(
(
helper != null
and helper_npc != null
and helper_npc.npc_name in need_label.text
and "bringing" in need_label.text.to_lower()
),
"The open need with a capable helper should name that helper on the field note",
)
var checksum_after_helper: String = manager.get_state_checksum()
var food_node: ResourceNode = _find_food_node(manager)
_check(food_node != null, "The resolution proof needs a stocked player-usable food source")
if food_node == null:
_finish()
return
var harvested: float = manager.harvest_resource_node(food_node)
_check(
(
harvested > 0.0
and manager.get_active_opportunity() == null
and (
manager.opportunity_system.get_latest().get_status()
== OpportunityStateRecord.STATUS_RESOLVED
)
),
"An ordinary finite-resource harvest should resolve the same open need",
)
field_note.call("refresh_note", true)
_check(
not need_label.visible and manager.get_state_checksum() != checksum_after_helper,
"Resolving the need should clear the surfaced need segment from the field note",
)
_finish()
func _find_food_node(manager: Node) -> ResourceNode:
for node in ResourceNode.get_all():
var state: ResourceStateRecord = (
manager.get_resource_state(node.node_id) as ResourceStateRecord
)
if (
node.resource_id == SimulationIds.RESOURCE_FOOD
and state != null
and state.can_player_use_resource()
and state.can_extract()
and state.get_amount_remaining() >= 1.0
):
return node
return null
func _find_npc(npcs: Array[SimNPC], npc_id: int) -> SimNPC:
for npc in npcs:
if npc.id == npc_id:
return npc
return null
func _move_population_away(manager: Node, view: Node, keep_id: int, origin: Vector3) -> void:
for npc in manager.npcs:
if npc.id == keep_id:
continue
_set_loaded_position(
manager, view, npc, origin + Vector3(40.0 + float(npc.id) * 2.0, 0.0, 20.0)
)
func _set_loaded_position(manager: Node, view: Node, npc: SimNPC, position: Vector3) -> void:
manager.synchronize_npc_position(npc.id, position)
var visual := view.active_npc_visuals.get(npc.id) as Node3D
if visual != null:
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
visual.global_position = position
func _freeze_visuals(view: Node) -> void:
for visual in view.active_npc_visuals.values():
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Field-note opportunity surfacing passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://dg66jcyhajmxp
+13 -2
View File
@@ -298,17 +298,28 @@ func _run() -> void:
opportunity_bush_state.set_amount_remaining(1.0) opportunity_bush_state.set_amount_remaining(1.0)
player.global_position = opportunity_bush.interaction_point.global_position player.global_position = opportunity_bush.interaction_point.global_position
player.call("try_interact") player.call("try_interact")
var carried_after_gather: float = simulation_manager.get_player_state().get_inventory_amount(
SimulationIds.RESOURCE_FOOD
)
var pantry_position: Vector3 = (
main_scene
. get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry")
. get_interaction_position()
)
player.global_position = pantry_position
player.call("try_interact")
village_ui.selected_npc_index = contributor.id village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector") village_ui.call("_refresh_npc_inspector")
_check( _check(
( (
player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED is_equal_approx(carried_after_gather, 1.0)
and player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and "Resolved village need" in inspector_label.text and "Resolved village need" in inspector_label.text
and "◆ Player restocked the pantry" in inspector_label.text and "◆ Player restocked the pantry" in inspector_label.text
and whisper_kicker.text == "NEED MET" and whisper_kicker.text == "NEED MET"
and "The player restocked the pantry" in whisper_message.text and "The player restocked the pantry" in whisper_message.text
), ),
"Player gathering should resolve the restored need in debug detail and transient HUD" "Player gathering and depositing should resolve the restored need in debug detail and transient HUD"
) )
var woodpile_state: StorageStateRecord = simulation_manager.get_woodpile() var woodpile_state: StorageStateRecord = simulation_manager.get_woodpile()
+188
View File
@@ -0,0 +1,188 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
var berry := _register_resource(
manager,
&"carry_berry_bush",
SimulationIds.ACTION_GATHER_FOOD,
SimulationIds.RESOURCE_FOOD,
3.0
)
var pantry: StorageStateRecord = manager.get_pantry()
_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
interested.position = Vector3(4.0, 0.0, 0.0)
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
var quest: PlayerQuestRecord = manager.get_active_player_quest()
_check(quest != null, "The carry loop needs an open player quest")
if quest == null:
berry.free()
manager.free()
_finish()
return
var state: PlayerStateRecord = manager.get_player_state()
var carried: float = manager.player_gather(berry)
_check(
(
is_equal_approx(carried, 2.0)
and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 2.0)
and is_equal_approx(manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
),
"Gathering should fill the player's hands instead of the pantry directly",
)
var capacity_limited: float = manager.player_gather(berry)
_check(
(
is_equal_approx(capacity_limited, 1.0)
and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 3.0)
),
"Remaining resource amount should cap the second gather",
)
var hands_full: float = manager.player_gather(berry)
_check(
(
is_equal_approx(hands_full, 0.0)
and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 3.0)
),
"An empty resource should refuse further gathering",
)
var pantry_before: float = manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD)
var deposited: float = manager.player_deposit(SimulationIds.RESOURCE_FOOD)
_check(
(
is_equal_approx(deposited, 3.0)
and is_equal_approx(
manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD), pantry_before + 3.0
)
and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and manager.get_active_player_quest() == null
and (
manager.get_latest_player_quest_for_requester(interested.id).get_status()
== PlayerQuestRecord.STATUS_COMPLETED
)
),
"Depositing carried food should resolve the quest through the real pantry transaction",
)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(902)
_check(
restored.restore_state_from_json(saved_json),
"The emptied carry state should restore through the current schema"
)
_check(
(
restored.get_state_checksum() == manager.get_state_checksum()
and is_equal_approx(
restored.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0
)
),
"Restore should preserve the player's empty carried inventory and checksum",
)
var carry_state_saved: String = manager.serialize_state()
manager.player_gather(berry)
var mid_carry: String = manager.serialize_state()
var mid_restored := _create_manager(903)
_check(
mid_restored.restore_state_from_json(mid_carry),
"A partially carried inventory should restore through the current schema"
)
_check(
(
mid_restored.get_state_checksum() == manager.get_state_checksum()
and is_equal_approx(
mid_restored.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD),
manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD)
)
),
"Restore should preserve the player's carried inventory mid-transport",
)
berry.free()
manager.free()
restored.free()
mid_restored.free()
_finish()
func _register_resource(
manager: Node,
node_id: StringName,
action_id: StringName,
resource_id: StringName,
amount: float
) -> ResourceNode:
var node := ResourceNode.new()
node.name = "CarryResource"
node.node_id = node_id
node.action_id = action_id
node.resource_id = resource_id
node.initial_amount = amount
node.yield_per_action = 2.0
node.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
node.add_child(interaction_point)
root.add_child(node)
_check(
manager.register_resource_node(node),
"The carry loop should bind a real finite ResourceNode"
)
return node
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 _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 _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Player carry and deposit loop passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://bwyg38d3ieiex
+127
View File
@@ -0,0 +1,127 @@
extends SceneTree
var failures: Array[String] = []
var died_events := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
manager.player_died.connect(_on_player_died)
var state: PlayerStateRecord = manager.get_player_state()
_check(
(
state != null
and is_equal_approx(state.get_hunger(), 20.0)
and is_equal_approx(state.get_energy(), 90.0)
and not state.is_dead()
),
"A fresh player should start with embodied needs and no death state",
)
for _tick in 3:
manager.simulate_tick()
_check(
state.get_hunger() > 20.0 and state.get_energy() < 90.0,
"Simulation ticks should advance the player's hunger and drain energy",
)
state.data["hunger"] = 80.0
var hunger_before_eat: float = state.get_hunger()
var eaten: float = manager.player_eat(10.0)
_check(
(
is_equal_approx(eaten, 10.0)
and state.get_hunger() < hunger_before_eat
and state.get_energy() > 80.0
),
"Eating should reduce the player's own hunger and restore energy",
)
var initial_standing: float = manager.get_player_standing().get_standing()
manager.get_player_standing().grant_standing(20.0, 1)
var standing_after_help: float = manager.get_player_standing().get_standing()
state.data["hunger"] = PlayerStateRecord.STACK_THRESHOLD
state.data["starvation_ticks"] = (int(state.data["starvation_death_threshold"]) - 1)
manager.simulate_tick()
_check(
(
died_events == 1
and state.is_dead()
and is_equal_approx(
manager.get_player_standing().get_standing(),
standing_after_help * PlayerStateRecord.DEATH_STANDING_PENALTY
)
),
"Starvation death should emit a death event and cut standing as a consequence",
)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(902)
_check(
restored.restore_state_from_json(saved_json),
"The dead player state should restore through the current schema"
)
_check(
(
restored.get_state_checksum() == manager.get_state_checksum()
and restored.get_player_state().is_dead()
and restored.get_player_state().get_deaths() == 1
),
"Restore should preserve the embodied death state and checksum",
)
_check(manager.respawn_player(), "A dead player should be able to respawn")
_check(
(
not state.is_dead()
and is_equal_approx(state.get_hunger(), PlayerStateRecord.RESET_HUNGER)
and is_equal_approx(state.get_energy(), PlayerStateRecord.RESET_ENERGY)
),
"Respawn should reset needs to the documented baseline",
)
manager.free()
restored.free()
_finish()
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 _on_player_died(_state: PlayerStateRecord) -> void:
died_events += 1
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Embodied player needs passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://jeax46jjayqj
+363
View File
@@ -0,0 +1,363 @@
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),
"Completing the quest should go through the ordinary player harvest 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.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()
_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)
+1
View File
@@ -0,0 +1 @@
uid://s2vxgyidroc0
+202
View File
@@ -0,0 +1,202 @@
extends SceneTree
var failures: Array[String] = []
var opened_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_expired.connect(_on_quest_expired)
var berry := _register_resource(manager, &"talk_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
interested.position = Vector3(4.0, 0.0, 0.0)
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
_check(
opportunity != null and opportunity.get_interested_npc_id() == interested.id,
"The talk proof needs an open need owned by the inspected villager",
)
if opportunity == null:
berry.free()
manager.free()
_finish()
return
var talk: PlayerTalkResult = manager.get_villager_talk(interested.id)
_check(
(
talk != null
and talk.npc_id == interested.id
and talk.npc_name == interested.npc_name
and talk.has_need
and talk.can_accept
and interested.npc_name in talk.greeting
),
"Talking should greet the villager and surface their real need",
)
var checksum_before: String = manager.get_state_checksum()
var accepted: PlayerQuestRecord = manager.accept_villager_request(interested.id)
print(
" debug accepted=",
accepted,
" opened=",
opened_quest_ids,
" active=",
manager.get_active_player_quest(),
" checksum_same=",
manager.get_state_checksum() == checksum_before,
" resolved=",
manager.get_player_standing().get_resolved_needs(),
)
_check(
(
accepted != null
and accepted.is_open()
and accepted.get_requester_npc_id() == interested.id
and opened_quest_ids == [accepted.get_quest_id()]
and manager.get_state_checksum() == checksum_before
and manager.get_player_standing().get_resolved_needs() == 0
),
"Accepting a request should surface the same open quest without granting standing yet",
)
var accepted_again: PlayerQuestRecord = manager.accept_villager_request(interested.id)
_check(
accepted_again != null and accepted_again.get_quest_id() == accepted.get_quest_id(),
"Re-accepting should not duplicate the open quest",
)
var carry_state: PlayerStateRecord = manager.get_player_state()
carry_state.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
var deposited: float = manager.player_deposit(SimulationIds.RESOURCE_FOOD)
_check(
(
is_equal_approx(deposited, 1.0)
and manager.get_active_player_quest() == null
and manager.get_player_standing().get_resolved_needs() == 1
),
"Completing the accepted request through a real deposit should resolve it",
)
var declined := _create_manager(904)
declined.player_quest_opened.connect(_on_quest_opened)
declined.player_quest_expired.connect(_on_quest_expired)
var berry2 := _register_resource(declined, &"talk_berry_bush_2")
var actor2: SimNPC = declined.npcs[0]
var interested2: SimNPC = declined.npcs[2]
_set_pantry_amount(declined, 1.0)
for npc in declined.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor2.position = Vector3.ZERO
interested2.position = Vector3(4.0, 0.0, 0.0)
actor2.hunger = 60.0
interested2.hunger = 90.0
declined.economy.withdraw_to_inventory(actor2, SimulationIds.RESOURCE_FOOD, 1.0)
var declined_quest: PlayerQuestRecord = declined.get_active_player_quest()
_check(declined_quest != null, "The decline branch needs an open quest")
if declined_quest == null:
berry.free()
berry2.free()
manager.free()
declined.free()
_finish()
return
var gratitude_before: float = declined.get_player_standing().get_gratitude(interested2.id)
var refused: PlayerQuestRecord = declined.decline_villager_request(interested2.id)
_check(
(
refused != null
and refused.get_quest_id() == declined_quest.get_quest_id()
and expired_quest_ids.has(declined_quest.get_quest_id())
and declined.get_active_player_quest() == null
and declined.get_player_standing().get_gratitude(interested2.id) <= gratitude_before
),
"Declining a personal request should close the quest without granting gratitude",
)
berry.free()
berry2.free()
manager.free()
declined.free()
_finish()
func _register_resource(manager: Node, node_id: StringName) -> ResourceNode:
var node := ResourceNode.new()
node.name = "TalkResource"
node.node_id = node_id
node.action_id = SimulationIds.ACTION_GATHER_FOOD
node.resource_id = SimulationIds.RESOURCE_FOOD
node.initial_amount = 2.0
node.yield_per_action = 2.0
node.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
node.add_child(interaction_point)
root.add_child(node)
_check(
manager.register_resource_node(node),
"The talk proof should bind a real finite food ResourceNode"
)
return node
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 _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_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 talk and personal negotiation passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://cdh2d6y6jvajo
+173
View File
@@ -0,0 +1,173 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
for _frame in 10:
await physics_frame
var manager: Node = main_scene.get_node("SimulationManager")
var view: Node = main_scene.get_node("WorldViewManager")
var player: CharacterBody3D = main_scene.get_node("Player")
var journal := main_scene.get_node("QuestJournalLayer/QuestJournal")
manager.set_process(false)
player.set_physics_process(false)
_freeze_visuals(view)
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
_set_loaded_position(manager, view, npc, Vector3(40.0 + npc.id * 10.0, 0.0, 30.0))
actor.position = Vector3.ZERO
interested.position = Vector3(4.0, 0.0, 0.0)
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
var quest: PlayerQuestRecord = manager.get_active_player_quest()
_check(quest != null, "An unassisted known need should generate a player quest")
if quest == null:
manager.set_process(true)
_finish()
return
_check(
(
quest.get_requester_npc_id() == interested.id
and journal.visible
and "Standing" in (journal.get_node("Copy/Standing") as Label).text
and "Stranger" in (journal.get_node("Copy/Standing") as Label).text
and interested.npc_name in (journal.get_node("Copy/Quest") as Label).text
),
"The quest journal should surface the standing tier and the named need",
)
var food_node: ResourceNode = _find_food_node(manager)
_check(food_node != null, "The runtime proof needs a stocked player-usable food source")
if food_node == null:
_finish()
return
var checksum_before: String = manager.get_state_checksum()
_check(
manager.harvest_resource_node(food_node) > 0.0,
"The player should resolve the quest through the ordinary finite harvest"
)
_check(
(
manager.get_active_player_quest() == null
and is_equal_approx(
manager.get_player_standing().get_standing(), quest.get_standing_reward()
)
and is_equal_approx(manager.get_player_standing().get_gratitude(interested.id), 0.1)
and manager.get_state_checksum() != checksum_before
),
"Resolving the need should complete the quest and grant standing + gratitude",
)
var saved_json: String = manager.serialize_state()
var restored := _create_restored_manager(saved_json)
_check(
(
restored != null
and restored.get_state_checksum() == manager.get_state_checksum()
and restored.get_player_standing().get_resolved_needs() == 1
and is_equal_approx(
restored.get_player_standing().get_standing(), quest.get_standing_reward()
)
),
"Completed quest standing should restore deterministically through the schema",
)
manager.set_process(true)
_finish()
func _create_restored_manager(saved_json: String) -> Node:
var restored: Node = load("res://simulation/SimulationManager.gd").new()
restored.simulation_seed = 909
restored.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),
]
restored.home_positions = home_positions
root.add_child(restored)
restored.set_process(false)
if not restored.restore_state_from_json(saved_json):
return null
return restored
func _find_food_node(manager: Node) -> ResourceNode:
for node in ResourceNode.get_all():
var state: ResourceStateRecord = (
manager.get_resource_state(node.node_id) as ResourceStateRecord
)
if (
node.resource_id == SimulationIds.RESOURCE_FOOD
and state != null
and state.can_player_use_resource()
and state.can_extract()
and state.get_amount_remaining() >= 1.0
):
return node
return null
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 _set_loaded_position(manager: Node, view: Node, npc: SimNPC, position: Vector3) -> void:
manager.synchronize_npc_position(npc.id, position)
var visual := view.active_npc_visuals.get(npc.id) as Node3D
if visual != null:
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
visual.global_position = position
func _freeze_visuals(view: Node) -> void:
for visual in view.active_npc_visuals.values():
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Quest journal runtime surfacing passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://bl3m6a3k78cxe
+66 -23
View File
@@ -35,11 +35,17 @@ func _run() -> void:
_check( _check(
is_equal_approx(bush_state.get_amount_remaining(), 0.0), is_equal_approx(bush_state.get_amount_remaining(), 0.0),
"Player should deplete the nearby bush" "Player should deplete the nearby bush into their hands"
)
var carried_after_gather: float = simulation_manager.get_player_state().get_inventory_amount(
SimulationIds.RESOURCE_FOOD
) )
_check( _check(
is_equal_approx(simulation_manager.village.food, food_before + 1.0), (
"Village should receive exactly the bush's remaining food" is_equal_approx(carried_after_gather, 1.0)
and is_equal_approx(simulation_manager.village.food, food_before)
),
"Gathering should fill the player's hands without bypassing the village store",
) )
_check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation") _check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation")
var player_extraction: EconomicEventRecord var player_extraction: EconomicEventRecord
@@ -65,24 +71,41 @@ func _run() -> void:
"Player depletion should preserve the resource interaction position" "Player depletion should preserve the resource interaction position"
) )
player.global_position = tree.interaction_point.global_position
var wood_before: float = simulation_manager.village.wood
var tree_before := tree_state.get_amount_remaining()
player.try_interact()
_check(
is_equal_approx(tree_state.get_amount_remaining(), tree_before - tree.yield_per_action),
"Player should extract the tree's configured yield"
)
_check(
is_equal_approx(simulation_manager.village.wood, wood_before + tree.yield_per_action),
"Village should receive exactly the extracted wood"
)
var pantry := ( var pantry := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
) )
var pantry_state: StorageStateRecord = simulation_manager.get_pantry() var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
player.global_position = pantry.get_interaction_position()
player.try_interact()
var food_after_deposit: float = simulation_manager.village.food
_check(
(
is_equal_approx(food_after_deposit, food_before + 1.0)
and is_equal_approx(
simulation_manager.get_player_state().get_inventory_amount(
SimulationIds.RESOURCE_FOOD
),
0.0
)
),
"Depositing at the typed pantry should deliver the carried food to the village",
)
player.global_position = tree.interaction_point.global_position
var wood_before: float = simulation_manager.village.wood
var tree_before := tree_state.get_amount_remaining()
player.try_interact()
var carried_wood: float = simulation_manager.get_player_state().get_inventory_amount(
SimulationIds.RESOURCE_WOOD
)
_check(
(
is_equal_approx(tree_state.get_amount_remaining(), tree_before - tree.yield_per_action)
and is_equal_approx(carried_wood, tree.yield_per_action)
),
"Player should extract the tree's configured yield into their hands",
)
pantry_state.deposit(SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5) pantry_state.deposit(SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5)
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
bush_state.set_amount_remaining(1.0) bush_state.set_amount_remaining(1.0)
@@ -90,20 +113,40 @@ func _run() -> void:
player.try_interact() player.try_interact()
_check( _check(
( (
is_equal_approx(bush_state.get_amount_remaining(), 0.5) is_equal_approx(bush_state.get_amount_remaining(), 0.0)
and is_equal_approx(pantry_state.get_available_capacity(), 0.0) and is_equal_approx(
simulation_manager.get_player_state().get_inventory_amount(
SimulationIds.RESOURCE_FOOD
),
1.0
)
), ),
"Player harvesting should leave overflow at its source when storage fills" "Player gathering should still extract into their hands regardless of storage capacity",
) )
pantry_state.deposit(SimulationIds.RESOURCE_FOOD, 3.0) pantry_state.withdraw(
SimulationIds.RESOURCE_FOOD, pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
)
pantry_state.deposit(SimulationIds.RESOURCE_FOOD, 5.0)
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
var pantry_food_before := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) var pantry_food_before := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
player.global_position = pantry.get_interaction_position() player.global_position = pantry.get_interaction_position()
player.try_interact() player.try_interact()
var food_after_first_deposit := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
var food_carried_after_first: float = (
simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD)
)
_check( _check(
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before, (
"Player should eat from the typed pantry StorageNode" food_after_first_deposit > pantry_food_before
and is_equal_approx(food_carried_after_first, 0.0)
),
"Player should deposit carried food at the typed pantry",
)
player.try_interact()
_check(
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < food_after_first_deposit,
"Player should eat from the pantry once their hands are empty",
) )
var guard_site := ( var guard_site := (
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
+70
View File
@@ -0,0 +1,70 @@
extends GutTest
const PlayerQuestSystemScript := preload("res://simulation/quests/PlayerQuestSystem.gd")
func test_personal_request_needs_standing_tier_and_gratitude() -> void:
var system := PlayerQuestSystemScript.new()
var opportunity := _open_opportunity()
var pantry := _pantry()
assert_false(system.can_request_personally(opportunity))
for _grant in 6:
system.standing.grant_standing(3.0, opportunity.get_interested_npc_id())
assert_true(
system.can_request_personally(opportunity),
"Known Hand standing plus personal gratitude should make the NPC ask you directly"
)
var quest := system.consider_opportunity_opened(opportunity, null, 10, true)
assert_not_null(quest)
assert_eq(quest.get_requester_npc_id(), opportunity.get_interested_npc_id())
func test_no_personal_request_below_trust_threshold() -> void:
var system := PlayerQuestSystemScript.new()
var opportunity := _open_opportunity()
system.standing.grant_standing(16.0, opportunity.get_interested_npc_id())
system.standing.data["gratitude"][opportunity.get_interested_npc_id()] = 0.4
assert_false(system.can_request_personally(opportunity))
func test_standing_tier_progression() -> void:
var standing := PlayerStandingRecord.create()
assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_STRANGER)
standing.grant_standing(14.0, 3)
assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_STRANGER)
standing.grant_standing(2.0, 3)
assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_KNOWN_HAND)
assert_eq(standing.get_tier_name(), "Known Hand")
standing.grant_standing(20.0, 3)
assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_TRUSTED)
func test_standing_serialization_round_trip() -> void:
var standing := PlayerStandingRecord.create()
standing.grant_standing(35.0, 3)
var restored := PlayerStandingRecord.from_dictionary(standing.to_dictionary())
assert_not_null(restored)
assert_eq(restored.get_standing(), 35.0)
assert_eq(restored.get_tier(), PlayerStandingRecord.TIER_TRUSTED)
assert_eq(restored.get_gratitude(3), 0.1)
func _open_opportunity() -> OpportunityStateRecord:
return OpportunityStateRecord.create(
0,
10,
5,
3,
1.0,
SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY,
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD
)
func _pantry() -> StorageStateRecord:
return StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_PANTRY, {SimulationIds.RESOURCE_FOOD: 0.0}, 100.0
)
@@ -0,0 +1 @@
uid://b2jmmtiyvaqll
@@ -0,0 +1,168 @@
extends GutTest
const VillagerOpportunityNoteScript := preload("res://player/VillagerOpportunityNote.gd")
func test_no_opportunity_produces_no_need() -> void:
var note := VillagerOpportunityNote.derive(null, null, null, "", null, "", "")
assert_not_null(note)
assert_false(note.has_need)
assert_eq(note.help_kind, &"")
func test_player_route_wins_over_helper_when_derivable() -> void:
var opportunity := _open_pantry_opportunity()
var response := _player_response()
var helper := _helper(7)
var pantry := _pantry(0.0, 100.0)
var note := VillagerOpportunityNote.derive(
opportunity, response, helper, "Tarik", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_PLAYER_ROUTE)
assert_eq(note.need_text, "The Village Pantry needs food")
assert_true(note.help_text.contains("You can help"))
assert_true(note.help_text.contains("Village Pantry"))
func test_helper_is_named_when_no_player_route() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(0.0, 100.0)
var note := VillagerOpportunityNote.derive(
opportunity, null, _helper(7), "Tarik", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_HELPER)
assert_true(note.help_text.contains("Tarik"))
assert_true(note.help_text.contains("food"))
func test_unavailable_reports_no_room_when_storage_is_full() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(0.0, 0.5)
var note := VillagerOpportunityNote.derive(
opportunity, null, null, "", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE)
assert_true(note.help_text.contains("no room"))
func test_unavailable_reports_need_already_met() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(1.0, 100.0)
var note := VillagerOpportunityNote.derive(
opportunity, null, null, "", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE)
assert_true(note.help_text.contains("already has enough"))
func test_unavailable_reports_no_stocked_source_when_storage_has_room() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(0.0, 100.0)
var note := VillagerOpportunityNote.derive(
opportunity, null, null, "", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE)
assert_true(note.help_text.contains("No stocked source"))
func test_unavailable_reports_missing_storage() -> void:
var opportunity := _open_pantry_opportunity()
var note := VillagerOpportunityNote.derive(
opportunity, null, null, "", null, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE)
assert_true(note.help_text.contains("No storage"))
func test_cache_key_uses_need_and_help_state() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(0.0, 100.0)
var helper_note := VillagerOpportunityNote.derive(
opportunity, null, _helper(7), "Tarik", pantry, "Village Pantry", "food"
)
var unavailable_note := VillagerOpportunityNote.derive(
opportunity, null, null, "", pantry, "Village Pantry", "food"
)
var no_need := VillagerOpportunityNote.derive(null, null, null, "", null, "", "")
assert_ne(helper_note.cache_key(), unavailable_note.cache_key())
assert_ne(no_need.cache_key(), helper_note.cache_key())
func _open_pantry_opportunity() -> OpportunityStateRecord:
return OpportunityStateRecord.create(
0,
10,
5,
3,
1.0,
SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY,
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD
)
func _player_response() -> OpportunityPlayerResponseResult:
return (
OpportunityPlayerResponseResult
. new(
{
"opportunity_id": 0,
"trigger_event_id": 5,
"action_id": SimulationIds.ACTION_GATHER_FOOD,
"resource_id": SimulationIds.RESOURCE_FOOD,
"target_id": SimulationIds.STORAGE_VILLAGE_PANTRY,
"available_source_count": 2,
"reason":
"No capable helper; 2 player-usable finite Food sources can supply village_pantry",
}
)
)
func _helper(helper_id: int) -> OpportunityHelperResult:
return (
OpportunityHelperResult
. new(
{
"opportunity_id": 0,
"helper_npc_id": helper_id,
"action_id": SimulationIds.ACTION_GATHER_FOOD,
"source_id": "",
"resource_id": SimulationIds.RESOURCE_FOOD,
"trigger_event_id": 5,
"trust": 0.8,
"familiarity": 0.5,
"uses_inventory": false,
"available_source_count": 1,
"profession_match": true,
"reason":
"Knows the need; trust 0.80 toward Amina; has 1 available finite Food source",
}
)
)
func _pantry(amount: float, capacity: float) -> StorageStateRecord:
return StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_PANTRY, {SimulationIds.RESOURCE_FOOD: amount}, capacity
)
@@ -0,0 +1 @@
uid://1chfro4iw1o5
+172
View File
@@ -0,0 +1,172 @@
class_name QuestJournalHud
extends Control
const ENTER_OFFSET := 8.0
const THANKS_SECONDS := 3.4
@export var simulation_manager: Node
@onready var accent: ColorRect = $Accent
@onready var copy: VBoxContainer = $Copy
@onready var standing_label: Label = $Copy/Standing
@onready var quest_label: Label = $Copy/Quest
@onready var quest_kicker: Label = $Copy/QuestKicker
@onready var thanks_label: Label = $Copy/Thanks
var resting_position: Vector2
var feedback_version := 0
var feedback_tween: Tween
var journal_tween: Tween
func _ready() -> void:
resting_position = position
visible = false
modulate.a = 0.0
if simulation_manager == null:
push_error("QuestJournalHud: simulation_manager missing")
return
if simulation_manager.has_signal("player_quest_opened"):
simulation_manager.player_quest_opened.connect(_on_quest_opened)
if simulation_manager.has_signal("player_quest_completed"):
simulation_manager.player_quest_completed.connect(_on_quest_completed)
if simulation_manager.has_signal("player_quest_expired"):
simulation_manager.player_quest_expired.connect(_on_quest_expired)
if simulation_manager.has_signal("player_standing_changed"):
simulation_manager.player_standing_changed.connect(_on_standing_changed)
if simulation_manager.has_signal("player_tier_advanced"):
simulation_manager.player_tier_advanced.connect(_on_tier_advanced)
if simulation_manager.has_signal("opportunity_resolved"):
simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved)
if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_state_restored)
_refresh()
func _on_quest_opened(_quest: PlayerQuestRecord) -> void:
_refresh()
func _on_quest_expired(_quest: PlayerQuestRecord) -> void:
_refresh()
func _on_standing_changed(_standing: PlayerStandingRecord) -> void:
_refresh()
func _on_tier_advanced(_tier: int, tier_name: String) -> void:
_refresh()
_show_thanks("Standing grows", "The village now calls you a %s." % tier_name)
func _on_opportunity_resolved(
_opportunity: OpportunityStateRecord, _cause_event: EconomicEventRecord
) -> void:
_refresh()
func _on_state_restored() -> void:
_refresh()
func _refresh() -> void:
if simulation_manager == null:
return
var standing: PlayerStandingRecord = simulation_manager.get_player_standing()
if standing == null:
return
var standing_text := (
"Standing · %s · %.0f/100" % [standing.get_tier_name(), standing.get_standing()]
)
standing_label.text = standing_text
var quest: PlayerQuestRecord = simulation_manager.get_active_player_quest()
if quest == null:
quest_kicker.text = ""
quest_label.text = ""
elif quest.has_animal_target():
var requester_name := _get_npc_name(quest.get_requester_npc_id())
var animal_name := _get_animal_name(quest.get_animal_id())
quest_kicker.text = "ACTIVE QUEST"
quest_label.text = (
"%s · bring %.0f %s to %s"
% [
requester_name,
quest.get_target_amount(),
String(quest.get_resource_id()).replace("_", " "),
animal_name,
]
)
else:
var requester_name := _get_npc_name(quest.get_requester_npc_id())
quest_kicker.text = "ACTIVE QUEST"
quest_label.text = (
"%s · bring %.0f %s to the %s"
% [
requester_name,
quest.get_target_amount(),
String(quest.get_resource_id()).replace("_", " "),
String(quest.get_target_id()).replace("_", " "),
]
)
var has_content := not quest_kicker.text.is_empty() or not standing_text.is_empty()
if not has_content:
return
if not visible:
visible = true
modulate.a = 0.0
position = resting_position + Vector2(0.0, ENTER_OFFSET)
if journal_tween != null and journal_tween.is_valid():
journal_tween.kill()
journal_tween = create_tween().set_parallel(true)
journal_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
journal_tween.tween_property(self, "position", resting_position, 0.3)
journal_tween.tween_property(self, "modulate:a", 1.0, 0.22)
func _on_quest_completed(quest: PlayerQuestRecord) -> void:
_refresh()
var requester_name := _get_npc_name(quest.get_requester_npc_id())
_show_thanks(
"Gratitude",
(
"%s thanks you for your help. +%.0f Standing"
% [requester_name, quest.get_standing_reward()]
)
)
func _show_thanks(heading: String, message: String) -> void:
feedback_version += 1
var version := feedback_version
if feedback_tween != null and feedback_tween.is_valid():
feedback_tween.kill()
thanks_label.text = "%s\n%s" % [heading, message]
thanks_label.visible = true
thanks_label.modulate.a = 0.0
feedback_tween = create_tween().set_parallel(true)
feedback_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
feedback_tween.tween_property(thanks_label, "modulate:a", 1.0, 0.3)
feedback_tween.chain().tween_interval(THANKS_SECONDS)
feedback_tween.chain().tween_property(thanks_label, "modulate:a", 0.0, 0.5)
feedback_tween.chain().tween_callback(
func() -> void:
if version == feedback_version:
thanks_label.visible = false
)
func _get_npc_name(npc_id: int) -> String:
for npc in simulation_manager.npcs:
if npc.id == npc_id:
return npc.npc_name
return "Someone"
func _get_animal_name(animal_id: StringName) -> String:
if (
simulation_manager.has_method("get_animal_display_name")
and simulation_manager.get_animal_display_name(animal_id) != ""
):
return simulation_manager.get_animal_display_name(animal_id)
return String(animal_id).replace("_", " ").capitalize()
+1
View File
@@ -0,0 +1 @@
uid://bj0u6l60v2o5f
+35 -1
View File
@@ -86,6 +86,8 @@ func _on_village_changed(village: SimVillage) -> void:
if rates["wood_per_day"] > 0.01: if rates["wood_per_day"] > 0.01:
wood_rate = " (%.0f/d)" % rates["wood_per_day"] wood_rate = " (%.0f/d)" % rates["wood_per_day"]
var opportunity_text := _build_active_opportunity_display() var opportunity_text := _build_active_opportunity_display()
var standing_text := _build_standing_display()
var player_needs_text := _build_player_needs_display()
village_stats_label.text = ( village_stats_label.text = (
""" """
Village [%s] Village [%s]
@@ -99,7 +101,7 @@ func _on_village_changed(village: SimVillage) -> void:
Food Mod: %.2f Food Mod: %.2f
Safety Mod: %.2f Safety Mod: %.2f
Knowledge Mod: %.2f Knowledge Mod: %.2f
%s%s %s%s%s%s
""" """
% [ % [
speed_text, speed_text,
@@ -113,12 +115,44 @@ func _on_village_changed(village: SimVillage) -> void:
village.food_modifier, village.food_modifier,
village.safety_modifier, village.safety_modifier,
village.knowledge_modifier, village.knowledge_modifier,
player_needs_text,
standing_text,
opportunity_text, opportunity_text,
event_text, event_text,
] ]
) )
func _build_player_needs_display() -> String:
if not simulation_manager.has_method("get_player_state"):
return ""
var state: PlayerStateRecord = simulation_manager.get_player_state()
if state == null:
return ""
var status := "dead" if state.is_dead() else ("starving" if state.is_starving() else "ok")
return (
"\n\nYou\nHunger: %.0f Energy: %.0f (%s)"
% [state.get_hunger(), state.get_energy(), status]
)
func _build_standing_display() -> String:
if not simulation_manager.has_method("get_player_standing"):
return ""
var standing: PlayerStandingRecord = simulation_manager.get_player_standing()
if standing == null:
return ""
return (
"\n\nStanding\n%s · %.0f/100 · %d need%s met"
% [
standing.get_tier_name(),
standing.get_standing(),
standing.get_resolved_needs(),
"" if standing.get_resolved_needs() == 1 else "s",
]
)
func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -> void: func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -> void:
_refresh_npc_inspector() _refresh_npc_inspector()
+11
View File
@@ -14,6 +14,7 @@ const EXIT_OFFSET := 4.0
@onready var target_label: Label = $Copy/Target @onready var target_label: Label = $Copy/Target
@onready var carrying_label: Label = $Copy/Carrying @onready var carrying_label: Label = $Copy/Carrying
@onready var reason_label: Label = $Copy/Reason @onready var reason_label: Label = $Copy/Reason
@onready var need_label: Label = $Copy/Need
var copy_resting_position: Vector2 var copy_resting_position: Vector2
var current_context_key := "" var current_context_key := ""
@@ -64,6 +65,7 @@ func _present(context: VillagerInspectionResult, animate: bool) -> void:
target_label.text = "Target · %s" % context.target_name target_label.text = "Target · %s" % context.target_name
carrying_label.text = "Carrying · %s" % context.carrying_text() carrying_label.text = "Carrying · %s" % context.carrying_text()
reason_label.text = "Why · %s" % context.decision_reason reason_label.text = "Why · %s" % context.decision_reason
_present_opportunity_note(context.opportunity_note)
visible = true visible = true
accent.pivot_offset = accent.size * 0.5 accent.pivot_offset = accent.size * 0.5
if not animate: if not animate:
@@ -81,6 +83,15 @@ func _present(context: VillagerInspectionResult, animate: bool) -> void:
motion_tween.tween_property(accent, "scale:y", 1.0, 0.26) motion_tween.tween_property(accent, "scale:y", 1.0, 0.26)
func _present_opportunity_note(note: VillagerOpportunityNote) -> void:
if note == null or not note.has_need:
need_label.visible = false
need_label.text = "Need · "
return
need_label.visible = true
need_label.text = "Need · %s\n%s" % [note.need_text, note.help_text]
func _hide_note() -> void: func _hide_note() -> void:
if not visible or is_hiding: if not visible or is_hiding:
return return