Phase 8 — Beds, sleep need, thoughts, mood, Sulking soft-break
Three gdscript-refactor agents in parallel; Opus integrated and verified
the sleep+wake cycle via MCP runtime.
Bed entity (Agent A, scenes/entities/bed.{gd,tscn} + world.gd, ~280 lines):
- class Bed extends Node2D — bottom-anchored 3/4 perspective like Wall/Workbench
- BuildJob interface (is_buildable / on_build_tick / _complete) — same pattern
as Wall / Crate / Workbench. blocks_pathing_when_complete=false (walkable).
- Quality-tinted sheet colours by Item.Quality tier (drab grey → blue →
gold-brown → regal pink); white pillow + dark frame constant across tiers.
- claim(pawn) / release() / is_available() — atomic occupancy; claim re-checks
is_available() inside to avoid race conditions during pawn walk-to-bed.
- World.beds registry + register_bed / unregister_bed (mirrors workbench pattern)
Sleep need + SleepProvider + KIND_SLEEP toil (Agent B, ~220 lines):
- Pawn.sleep: float 0..100. SLEEP_DECAY_PER_TICK=0.015 (~6667 ticks / 5.5 min
at 1× / 1 min at Ultra to fully tire). Slower than hunger.
- is_tired() at <30; is_exhausted() at <5 (Phase 9 status interrupt hook)
- SleepProvider priority=8 (highest — sleep beats eat=7 when both urgent)
- Toil.KIND_SLEEP + Toil.sleep_in_bed(NodePath) factory
- JobRunner._tick_sleep: first-tick bed claim (with race-loss → floor fallback),
per-tick recovery (bed=0.5/tick, floor=0.25/tick), wake-when-full at ≥99,
emergency ceiling SLEEP_TICKS_MAX=2000 prevents stuck-asleep loops
Thoughts + mood + Sulking (Agent C, ~290 lines):
- scenes/ai/thought.gd: class Thought (RefCounted) with id, modifier, lifetime
(PERSISTENT/EVENT), stacks, ticks_remaining; MAX_STACKS_PER_THOUGHT=5 locked
- scenes/ai/thought_catalog.gd: ThoughtCatalog with 5 Phase 8 thoughts —
hungry(-6, PERSISTENT) / tired(-4, PERSISTENT) / well_rested(+5, EVENT 1200t)
/ slept_on_floor(-5, EVENT 1200t) / ate_meal(+3, EVENT 800t, stacks up to 3)
- Pawn extended: thoughts: Array, mood: float (base 50), sulking: bool,
_sulk_low_ticks. add_thought (stack-merge by id), remove_thought_by_id,
has_thought, is_sulking. _process_thoughts in sim_tick decays EVENT thoughts,
syncs PERSISTENT thoughts to state (hungry/tired), recomputes mood, checks
sulking transition: mood < 25 for MOOD_SULK_SUSTAIN_TICKS=600 ticks → SULKING;
mood >= 35 → recover.
- Decision Layer 1 extended: pawn.is_sulking() → return null (sulking pawns
refuse all work; Phase 17 may add Wandering variant)
- EventBus.pawn_mood_changed signal
- JobRunner._tick_eat: fires ate_meal thought when consuming MEAL/BREAD
- JobRunner._tick_sleep: fires well_rested or slept_on_floor on wake
Opus integration:
- world.tscn: SleepProvider node added (9 providers total)
- world.gd registers in priority order:
sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4 ≈ crafting=4 > haul=3 > rest=0
- Demo seed: 3 beds along cabin's north row at (45/47/49, 24), pre-built
so pawns can sleep immediately when tired
Acceptance — MCP-verified end-to-end:
- Pre-tired Bram at sleep=25 → SleepProvider issued 'Sleep at (45, 24)' job
- Bram walked to bed, claimed, slept 200 ticks, woke at sleep≥99
- Bed released back to available; well_rested thought fired (+5 mood)
- After ~12000 ticks total: all 3 pawns slept (sleep recovered to 67/86/51),
thoughts active (1-2 per pawn — well_rested + ate_meal from Phase 7 cooked
bread consumption), beds all back to available, no claim leaks
- Mood compute working (base 50 + thought modifiers); sulking transition
ready but didn't fire — would need misery accumulation (Phase 9 Cold +
Bleeding statuses) to drive mood < 25 sustained
Phase 8 followups for later phases:
- Sulking returns null (stand still); Phase 17 may add Wandering soft-break
that issues a random-walk job
- Bed ownership (_owner_pawn) reserved but not used in Phase 8 — Phase 17
may add 'bedrooms' where each pawn claims a specific bed
- _tick_sleep's using_bed local-var reset pattern is correct but fragile;
cleanup pass when status interrupts (Phase 9) wire into the eat/sleep
cancellation path
Delegation report this phase:
- Agent A: Bed entity (buildable, quality-tinted, claim/release)
- Agent B: Pawn.sleep + SleepProvider + KIND_SLEEP toil + JobRunner._tick_sleep
- Agent C: Thought + ThoughtCatalog + Pawn mood/sulking + Decision Layer 1
+ JobRunner thought hooks in _tick_eat / _tick_sleep
- Opus: scene wiring + 3 beds in demo seed + MCP runtime verification
~75% of Phase 8 GDScript was subagent-authored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
61dcf6760b
commit
43e52ffe75
18 changed files with 920 additions and 6 deletions
|
|
@ -19,10 +19,15 @@ class_name Decision
|
|||
## `pawn` is duck-typed: must expose .pawn_name, .forced_job, and
|
||||
## has_method("is_incapacitated").
|
||||
static func pick_next_job(pawn, work_providers: Array) -> Job:
|
||||
# ── Layer 1: Incapacitation ──────────────────────────────────────────────
|
||||
# has_method probe so this doesn't break before Phase 9 adds the method.
|
||||
# ── Layer 1: Incapacitation / sulking ───────────────────────────────────
|
||||
# has_method probes so these don't break before Phase 9 adds is_incapacitated.
|
||||
if pawn.has_method("is_incapacitated") and pawn.is_incapacitated():
|
||||
return null
|
||||
# Sulking pawns refuse all jobs until mood recovers to MOOD_SULK_RECOVERY.
|
||||
# Phase 17 may replace this with a Wandering soft-break variant that moves
|
||||
# the pawn to a quiet tile instead of standing still.
|
||||
if pawn.has_method("is_sulking") and pawn.is_sulking():
|
||||
return null
|
||||
|
||||
# ── Layer 2: Forced job ──────────────────────────────────────────────────
|
||||
if pawn.forced_job != null:
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ func tick() -> void:
|
|||
_tick_craft(t)
|
||||
Toil.KIND_EAT:
|
||||
_tick_eat(t)
|
||||
Toil.KIND_SLEEP:
|
||||
_tick_sleep(t)
|
||||
|
||||
if t.done:
|
||||
job.advance()
|
||||
|
|
@ -490,6 +492,12 @@ func _tick_eat(t) -> void:
|
|||
nutrition = 25
|
||||
|
||||
pawn.set_hunger(pawn.hunger + float(nutrition))
|
||||
# Phase 8 — fire ate_meal thought for cooked food (MEAL or BREAD).
|
||||
# Raw food (VEGETABLE, GRAIN) gives no mood bonus — player incentive to
|
||||
# build a cooking hearth per design.md "Thought list".
|
||||
if item_type == Item.TYPE_MEAL or item_type == Item.TYPE_BREAD:
|
||||
if pawn.has_method("add_thought"):
|
||||
pawn.add_thought(ThoughtCatalog.ate_meal())
|
||||
pawn.carried_item.queue_free()
|
||||
pawn.carried_item = null
|
||||
|
||||
|
|
@ -500,6 +508,119 @@ func _tick_eat(t) -> void:
|
|||
t.done = true
|
||||
|
||||
|
||||
## Execute one tick of a SLEEP toil.
|
||||
##
|
||||
## Sleep recovery rates (per sim tick):
|
||||
## In a bed : 0.5 / tick (fast, restful; pawn wakes when sleep ≥ SLEEP_MAX - 1)
|
||||
## On floor : 0.25 / tick (slow, poor rest — mood thought fires via Mood system)
|
||||
##
|
||||
## First tick (started=false):
|
||||
## - Resolves the Bed node from t.data["bed"] (empty string → floor sleep).
|
||||
## - If bed path given but node invalid/freed: log + fall back to floor sleep.
|
||||
## - If valid bed: call bed.claim(pawn). If claim() returns false (race lost):
|
||||
## log + fall back to floor sleep.
|
||||
## - Reset ticks_slept = 0, mark started = true.
|
||||
## - Log sleep start (bed vs floor).
|
||||
##
|
||||
## Every tick (including the first after validation):
|
||||
## - Increment ticks_slept.
|
||||
## - Restore pawn.sleep by the appropriate recovery rate.
|
||||
## - When sleep ≥ SLEEP_MAX - 1 (essentially full): release bed, log, done.
|
||||
## - Emergency ceiling SLEEP_TICKS_MAX: prevents stuck-asleep loops.
|
||||
const SLEEP_RECOVERY_BED: float = 0.5 # /tick in bed
|
||||
const SLEEP_RECOVERY_FLOOR: float = 0.25 # /tick on floor
|
||||
const SLEEP_TICKS_MAX: int = 2000 # emergency wake ceiling (~100 s at 1×)
|
||||
|
||||
func _tick_sleep(t) -> void:
|
||||
var using_bed: bool = false # resolved on first tick; re-read from data each tick
|
||||
|
||||
if not t.data.get("started", false):
|
||||
# ── first-tick: resolve and claim bed ─────────────────────────────────
|
||||
var bed_path_str: String = t.data.get("bed", "")
|
||||
var bed = null
|
||||
|
||||
if bed_path_str != "":
|
||||
var bed_path := NodePath(bed_path_str)
|
||||
bed = get_tree().get_root().get_node_or_null(bed_path)
|
||||
|
||||
if bed == null or not is_instance_valid(bed):
|
||||
# Bed gone between job creation and arrival.
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s bed gone at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
|
||||
)
|
||||
t.data["bed"] = ""
|
||||
bed = null
|
||||
elif not bed.claim(pawn):
|
||||
# Bed claimed by another pawn during the walk — fall back.
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s bed claim failed at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
|
||||
)
|
||||
t.data["bed"] = ""
|
||||
bed = null
|
||||
|
||||
t.data["started"] = true
|
||||
t.data["ticks_slept"] = 0
|
||||
|
||||
var has_bed: bool = (bed != null)
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s sleep start: %s" % [pawn.pawn_name, "bed" if has_bed else "floor"]
|
||||
)
|
||||
|
||||
# ── per-tick recovery ──────────────────────────────────────────────────────
|
||||
t.data["ticks_slept"] = int(t.data.get("ticks_slept", 0)) + 1
|
||||
var ticks_slept: int = int(t.data["ticks_slept"])
|
||||
|
||||
# Determine which rate to use this tick based on whether a bed is still live.
|
||||
var current_bed = null
|
||||
var bed_path_str: String = t.data.get("bed", "")
|
||||
if bed_path_str != "":
|
||||
current_bed = get_tree().get_root().get_node_or_null(NodePath(bed_path_str))
|
||||
if current_bed != null and not is_instance_valid(current_bed):
|
||||
current_bed = null
|
||||
|
||||
using_bed = (current_bed != null)
|
||||
var rate: float = SLEEP_RECOVERY_BED if using_bed else SLEEP_RECOVERY_FLOOR
|
||||
pawn.set_sleep(pawn.sleep + rate)
|
||||
|
||||
# Wake when sleep is essentially full. Compare against the literal 99.0
|
||||
# (SLEEP_MAX - 1) rather than pawn.SLEEP_MAX because pawn is duck-typed
|
||||
# and const access on an untyped var fails at runtime in GDScript.
|
||||
if pawn.sleep >= 99.0:
|
||||
if current_bed != null:
|
||||
current_bed.release()
|
||||
# Phase 8 — fire sleep-quality thought on natural wakeup.
|
||||
if pawn.has_method("add_thought"):
|
||||
if using_bed:
|
||||
pawn.add_thought(ThoughtCatalog.well_rested())
|
||||
else:
|
||||
pawn.add_thought(ThoughtCatalog.slept_on_floor())
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s woke (slept %d ticks; sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
|
||||
)
|
||||
t.done = true
|
||||
return
|
||||
|
||||
# Emergency ceiling — prevent stuck-asleep loops.
|
||||
if ticks_slept > SLEEP_TICKS_MAX:
|
||||
if current_bed != null:
|
||||
current_bed.release()
|
||||
# Phase 8 — fire sleep-quality thought on emergency wake too.
|
||||
if pawn.has_method("add_thought"):
|
||||
if using_bed:
|
||||
pawn.add_thought(ThoughtCatalog.well_rested())
|
||||
else:
|
||||
pawn.add_thought(ThoughtCatalog.slept_on_floor())
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s emergency wake after %d ticks (sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
|
||||
)
|
||||
t.done = true
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
## Emit job_completed, log, and clear the job reference.
|
||||
|
|
|
|||
84
scenes/ai/sleep_provider.gd
Normal file
84
scenes/ai/sleep_provider.gd
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
class_name SleepProvider extends WorkProvider
|
||||
## WorkProvider for the Sleep work category. Slots into the 5-layer pawn AI
|
||||
## (Decision → WorkProvider → Job + JobRunner) at priority 8 — above eating (7)
|
||||
## so a maximally tired AND hungry pawn prioritises rest.
|
||||
##
|
||||
## When a pawn is tired and not carrying an item, scans World.beds for the
|
||||
## nearest available bed and builds a 2-toil sleep job: walk → sleep.
|
||||
## If no bed is available, the pawn sleeps on the floor at its current tile
|
||||
## (walk toil omitted since it's already there).
|
||||
##
|
||||
## Bed claim race: the bed is NOT claimed here. Claiming in find_best_for()
|
||||
## would leak a held claim if the job is later cancelled by a player override
|
||||
## (Layer 5) before execution starts. Instead _tick_sleep in JobRunner claims
|
||||
## on first tick, when the pawn has actually arrived and is ready to sleep.
|
||||
## If the claim fails at that point (another pawn beat us) the fallback is
|
||||
## floor sleep — no deadlock, no leaked claim.
|
||||
##
|
||||
## Pawn and Bed are intentionally duck-typed to avoid the class_name
|
||||
## registration-order trap documented in Phase 2/3.
|
||||
##
|
||||
## See docs/architecture.md "Pawn AI 5-layer pipeline" and
|
||||
## docs/design.md "Sleep mood" gradient.
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
category = &"sleep"
|
||||
# Priority 8 > eat (7) > construction (6) > hauling (3) > rest (0).
|
||||
# Phase 9 status interrupts will preempt from Decision layer 3 once the
|
||||
# Status registry lands — for now sleep rides at the WorkProvider level.
|
||||
priority = 8
|
||||
|
||||
|
||||
# ── WorkProvider override ─────────────────────────────────────────────────────
|
||||
|
||||
## Returns a sleep Job for `pawn`, or null if the pawn is not tired.
|
||||
## The job has 1 or 2 toils:
|
||||
## - walk_to(target_tile) [omitted when target == pawn.tile]
|
||||
## - sleep_in_bed(bed_path) [bed_path is empty string for floor sleep]
|
||||
##
|
||||
## `pawn` is duck-typed: must expose .is_tired(), .carried_item, .tile (Vector2i).
|
||||
func find_best_for(pawn) -> Job:
|
||||
if not pawn.is_tired():
|
||||
return null
|
||||
# Don't interrupt an ongoing carry — finish the carry before going to sleep.
|
||||
if pawn.carried_item != null:
|
||||
return null
|
||||
|
||||
# Scan World.beds for the nearest available bed (duck-typed — Bed exposes
|
||||
# .is_available() -> bool and .tile -> Vector2i).
|
||||
var best_bed = null
|
||||
var best_dist: int = 999999
|
||||
for bed in World.beds:
|
||||
if not bed.is_available():
|
||||
continue
|
||||
var d: int = abs(bed.tile.x - pawn.tile.x) + abs(bed.tile.y - pawn.tile.y)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
best_bed = bed
|
||||
|
||||
# Resolve target tile and bed NodePath. An empty NodePath means floor sleep.
|
||||
var target_tile: Vector2i
|
||||
var bed_path: NodePath
|
||||
if best_bed != null:
|
||||
target_tile = best_bed.tile
|
||||
bed_path = best_bed.get_path()
|
||||
else:
|
||||
# Phase 8 fallback — sleep on the floor at the current tile.
|
||||
# The Mood system (Agent C, Phase 8) will fire a "slept_on_floor" thought
|
||||
# via the thought registry; analogous to EatProvider's "ate raw food".
|
||||
target_tile = pawn.tile
|
||||
bed_path = NodePath("")
|
||||
|
||||
var j := Job.new()
|
||||
if best_bed != null:
|
||||
j.label = "Sleep at %s" % target_tile
|
||||
else:
|
||||
j.label = "Sleep on the floor at %s" % target_tile
|
||||
|
||||
# Only prepend a walk if the pawn is not already at the target.
|
||||
if pawn.tile != target_tile:
|
||||
j.toils.append(Toil.walk_to(target_tile))
|
||||
|
||||
j.toils.append(Toil.sleep_in_bed(bed_path))
|
||||
return j
|
||||
1
scenes/ai/sleep_provider.gd.uid
Normal file
1
scenes/ai/sleep_provider.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bu0ekfg0w7q3c
|
||||
79
scenes/ai/thought.gd
Normal file
79
scenes/ai/thought.gd
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
class_name Thought extends RefCounted
|
||||
## A single mood modifier entry, either state-driven (PERSISTENT) or
|
||||
## one-shot event-driven (EVENT).
|
||||
##
|
||||
## PERSISTENT thoughts are added / removed by Pawn._refresh_persistent_thoughts
|
||||
## each sim tick based on the pawn's current state (e.g. is_hungry()).
|
||||
## They have no ticks_remaining — they exist for exactly as long as the
|
||||
## triggering state is true.
|
||||
##
|
||||
## EVENT thoughts fire once on a game event (eating a meal, sleeping in a bed)
|
||||
## and decay after ticks_remaining reaches zero. They stack up to max_stacks —
|
||||
## for example, eating multiple meals in a row adds stacks to &"ate_meal"
|
||||
## rather than duplicating entries.
|
||||
##
|
||||
## Save/load contract:
|
||||
## var t2 := Thought.from_dict(t.to_dict())
|
||||
## assert(t2.id == t.id and t2.stacks == t.stacks)
|
||||
##
|
||||
## docs/architecture.md "MoodSystem" + MAX_STACKS_PER_THOUGHT = 5 (locked).
|
||||
|
||||
enum Lifetime {
|
||||
PERSISTENT, ## Present while a triggering state is true; Pawn manages add/remove.
|
||||
EVENT, ## Fires once on a transition; decays after ticks_remaining ticks.
|
||||
}
|
||||
|
||||
## Hard cap per thought type (docs/architecture.md "MoodSystem" — locked).
|
||||
const MAX_STACKS_PER_THOUGHT: int = 5
|
||||
|
||||
## Unique identifier, e.g. &"hungry", &"well_rested". Used as the merge key.
|
||||
var id: StringName = &""
|
||||
|
||||
## Human-readable label for Audit logs and future pawn-detail UI.
|
||||
## Not i18n'd here; call Strings.t("thought." + id) for player-visible text.
|
||||
var label: String = ""
|
||||
|
||||
## Mood delta per stack. Negative = bad, positive = good.
|
||||
## Typical range -8..+8. Capped at max_stacks when computing mood.
|
||||
var modifier: int = 0
|
||||
|
||||
## Whether this thought decays over time (EVENT) or tracks live state (PERSISTENT).
|
||||
var lifetime: Lifetime = Lifetime.EVENT
|
||||
|
||||
## Per-thought stack ceiling. Usually MAX_STACKS_PER_THOUGHT; some thoughts are
|
||||
## capped lower (e.g. hungry is max_stacks=1 because it's binary present/absent).
|
||||
var max_stacks: int = MAX_STACKS_PER_THOUGHT
|
||||
|
||||
# ── Runtime state (owned by the Pawn after add_thought()) ────────────────────
|
||||
|
||||
## Number of times this thought is currently stacked (1..max_stacks).
|
||||
var stacks: int = 1
|
||||
|
||||
## Remaining sim ticks before this EVENT thought expires. PERSISTENT ignores this.
|
||||
var ticks_remaining: int = 0
|
||||
|
||||
|
||||
# ── save / load ───────────────────────────────────────────────────────────────
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"id": String(id),
|
||||
"label": label,
|
||||
"modifier": modifier,
|
||||
"lifetime": lifetime,
|
||||
"max_stacks": max_stacks,
|
||||
"stacks": stacks,
|
||||
"ticks_remaining": ticks_remaining,
|
||||
}
|
||||
|
||||
|
||||
static func from_dict(d: Dictionary) -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = StringName(d.get("id", ""))
|
||||
t.label = str(d.get("label", ""))
|
||||
t.modifier = int(d.get("modifier", 0))
|
||||
t.lifetime = int(d.get("lifetime", Lifetime.EVENT)) as Lifetime
|
||||
t.max_stacks = int(d.get("max_stacks", MAX_STACKS_PER_THOUGHT))
|
||||
t.stacks = int(d.get("stacks", 1))
|
||||
t.ticks_remaining = int(d.get("ticks_remaining", 0))
|
||||
return t
|
||||
1
scenes/ai/thought.gd.uid
Normal file
1
scenes/ai/thought.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dola0cuqx2pt
|
||||
92
scenes/ai/thought_catalog.gd
Normal file
92
scenes/ai/thought_catalog.gd
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
class_name ThoughtCatalog
|
||||
## Static factory registry for named thoughts.
|
||||
##
|
||||
## Phase 8 ships 5 thoughts (hungry, tired, well_rested, slept_on_floor,
|
||||
## ate_meal). Phase 17 expands with: slept_in_good_bed (quality tiers),
|
||||
## ate_raw_food, witnessed_corpse, in_darkness, cramped_quarters,
|
||||
## beautiful_room, ugly_room, damp, soaked, cold.
|
||||
##
|
||||
## Usage pattern:
|
||||
## pawn.add_thought(ThoughtCatalog.ate_meal())
|
||||
##
|
||||
## Each factory returns a fresh Thought with all fields set to correct defaults
|
||||
## for that thought type. Callers must not mutate the returned object before
|
||||
## passing it to add_thought() — add_thought() handles stack merging.
|
||||
##
|
||||
## docs/architecture.md "MoodSystem"; docs/design.md "Thought list (~13)".
|
||||
|
||||
|
||||
# ── PERSISTENT thoughts ───────────────────────────────────────────────────────
|
||||
# Pawn._refresh_persistent_thoughts adds / removes these based on live state.
|
||||
# max_stacks=1 because each is binary (either hungry or not).
|
||||
|
||||
## Mood penalty while pawn.is_hungry() is true.
|
||||
## modifier=-6, max_stacks=1, PERSISTENT.
|
||||
static func hungry() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"hungry"
|
||||
t.label = "Hungry"
|
||||
t.modifier = -6
|
||||
t.lifetime = Thought.Lifetime.PERSISTENT
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Mood penalty while pawn.is_tired() is true.
|
||||
## modifier=-4, max_stacks=1, PERSISTENT.
|
||||
static func tired() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"tired"
|
||||
t.label = "Tired"
|
||||
t.modifier = -4
|
||||
t.lifetime = Thought.Lifetime.PERSISTENT
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
# ── EVENT thoughts ────────────────────────────────────────────────────────────
|
||||
# Fire on a transition and decay after ticks_remaining reaches zero.
|
||||
# ticks_remaining is in sim ticks at 1× speed (20 Hz).
|
||||
# ~10 in-game min at 1× = 1200 ticks (20 ticks/s × 60 s/min × 10 min).
|
||||
|
||||
## Positive mood boost after waking from a full bed-sleep.
|
||||
## Fires in _tick_sleep (Agent B) when had_bed=true.
|
||||
## modifier=+5, max_stacks=1, EVENT, ~10 in-game min at 1×.
|
||||
static func well_rested() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"well_rested"
|
||||
t.label = "Well rested"
|
||||
t.modifier = 5
|
||||
t.lifetime = Thought.Lifetime.EVENT
|
||||
t.ticks_remaining = 1200
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Mood penalty after sleeping without a bed.
|
||||
## Fires in _tick_sleep (Agent B) when had_bed=false.
|
||||
## modifier=-5, max_stacks=1, EVENT, ~10 in-game min at 1×.
|
||||
static func slept_on_floor() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"slept_on_floor"
|
||||
t.label = "Slept on the floor"
|
||||
t.modifier = -5
|
||||
t.lifetime = Thought.Lifetime.EVENT
|
||||
t.ticks_remaining = 1200
|
||||
t.max_stacks = 1
|
||||
return t
|
||||
|
||||
|
||||
## Small mood boost after eating a cooked meal or bread.
|
||||
## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_BREAD.
|
||||
## Stacks up to 3 (multiple good meals compound, but cap at 3).
|
||||
## modifier=+3, max_stacks=3, EVENT, ~800 ticks (~40 in-game sec at 1×).
|
||||
static func ate_meal() -> Thought:
|
||||
var t := Thought.new()
|
||||
t.id = &"ate_meal"
|
||||
t.label = "Ate a meal"
|
||||
t.modifier = 3
|
||||
t.lifetime = Thought.Lifetime.EVENT
|
||||
t.ticks_remaining = 800
|
||||
t.max_stacks = 3
|
||||
return t
|
||||
1
scenes/ai/thought_catalog.gd.uid
Normal file
1
scenes/ai/thought_catalog.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cwhkbh8cm37uf
|
||||
|
|
@ -19,6 +19,7 @@ const KIND_DEPOSIT: StringName = &"deposit" # Place pawn.carried_item at paw
|
|||
const KIND_BUILD: StringName = &"build" # Timed construction on a Wall / Floor / Door entity
|
||||
const KIND_CRAFT: StringName = &"craft" # Timed crafting at a Workbench driven by a Bill
|
||||
const KIND_EAT: StringName = &"eat" # Consume pawn.carried_item and restore hunger
|
||||
const KIND_SLEEP: StringName = &"sleep" # Sleep in a Bed (or on the floor) until pawn.sleep is full
|
||||
|
||||
var kind: StringName = KIND_IDLE
|
||||
## Toil-specific params — all values must be int, float, bool, String, Dict, or Array.
|
||||
|
|
@ -115,6 +116,25 @@ static func eat() -> Toil:
|
|||
return t
|
||||
|
||||
|
||||
## Sleep in the given Bed until pawn.sleep is full.
|
||||
## `bed_path` is the NodePath of the Bed entity, stored as String for JSON safety.
|
||||
## An empty string means "sleep on the floor" — no bed is claimed.
|
||||
##
|
||||
## data keys:
|
||||
## "bed" — String(bed_path); empty means floor sleep.
|
||||
## "started" — bool; false on first tick, true after claim resolved.
|
||||
## "ticks_slept" — int; incremented each tick for audit logging + emergency wake.
|
||||
static func sleep_in_bed(bed_path: NodePath) -> Toil:
|
||||
var t := Toil.new()
|
||||
t.kind = KIND_SLEEP
|
||||
t.data = {
|
||||
"bed": String(bed_path),
|
||||
"started": false,
|
||||
"ticks_slept": 0,
|
||||
}
|
||||
return t
|
||||
|
||||
|
||||
## Timed crafting action at a Workbench.
|
||||
## `workbench_path` is the NodePath of the Workbench entity (stored as String for JSON safety).
|
||||
## `bill_index` is the index into workbench.bills that this toil should run.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue