Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scripts/game_state.gd
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ func validate_save_schema(data: Dictionary) -> Dictionary:
if not events is Array:
return {"valid": false, "reason": "'events' must be an array"}

# Validate 'active_rewards' is an array (if present)
if data.has("active_rewards"):
var active_rewards = data.get("active_rewards", [])
if not active_rewards is Array:
return {"valid": false, "reason": "'active_rewards' must be an array"}

return {"valid": true, "reason": ""}

func migrate_save(data: Dictionary) -> Dictionary:
Expand Down
176 changes: 176 additions & 0 deletions scripts/goal_reward.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
class_name GoalReward
# Goal reward system — applies small temporary bonuses on goal completion.
# Rewards only affect existing systems; no new resources are introduced.
# See misospace/windowstead#134.

const REWARD_RESOURCE_TRICKLE := "resource_trickle"
const REWARD_GATHER_SPEED := "gather_speed"
const REWARD_HAUL_SPEED := "haul_speed"
const REWARD_BUILD_SPEED := "build_speed"
const REWARD_AMBIENT_IMPROVE := "ambient_improve"
const REWARD_RECRUIT_DISCOUNT := "recruit_discount"

# Default durations (in ticks) for temporary rewards.
const DURATION_GATHER_SPEED := 30
const DURATION_HAUL_SPEED := 30
const DURATION_BUILD_SPEED := 40
const DURATION_RESOURCE_TRICKLE := 50
const TRICKLE_INTERVAL := 10 # ticks between trickle payouts

# Reward catalog: maps goal IDs to reward definitions.
# Each entry is a dictionary with keys:
# "type": String — REWARD_* constant
# "resource": String (optional) — resource affected by trickle
# "duration": int — how many ticks the reward lasts (0 = one-time)
# "label": String — short human-readable label for event log
const REWARD_CATALOG := {
"gather_wood": {"type": REWARD_RESOURCE_TRICKLE, "resource": "food", "duration": DURATION_RESOURCE_TRICKLE, "label": "+1 food trickle"},
"gather_stone": {"type": REWARD_RESOURCE_TRICKLE, "resource": "food", "duration": DURATION_RESOURCE_TRICKLE, "label": "+1 food trickle"},
"gather_food": {"type": REWARD_RESOURCE_TRICKLE, "resource": "food", "duration": DURATION_RESOURCE_TRICKLE, "label": "+1 food trickle"},
"build_hut": {"type": REWARD_HAUL_SPEED, "duration": DURATION_HAUL_SPEED, "label": "haul speed +10%"},
"build_workshop": {"type": REWARD_RECRUIT_DISCOUNT, "duration": 0, "label": "next recruit -1 food"},
"build_garden": {"type": REWARD_AMBIENT_IMPROVE, "duration": 0, "label": "ambient event improves"},
"any_build": {"type": REWARD_RESOURCE_TRICKLE, "resource": "food", "duration": DURATION_RESOURCE_TRICKLE, "label": "+1 food trickle"},
}


# ── Reward state ─────────────────────────────────────────────────────────────
# An active reward is a dictionary:
# {
# "type": String, # REWARD_* constant
# "resource": String, # (optional) resource for trickle
# "remaining": int, # ticks remaining
# "duration": int, # original duration (for display)
# "trickle_ticks": int, # accumulator for resource_trickle payouts
# "label": String, # human-readable label
# }


# ── Apply reward from goal completion ────────────────────────────────────────
# Returns a new active reward dictionary, or empty dict if no reward defined.
static func apply_reward(goal_id: String) -> Dictionary:
var entry: Dictionary = REWARD_CATALOG.get(goal_id, {})
if entry.is_empty():
return {}

var reward := {
"type": entry["type"],
"remaining": entry.get("duration", 0),
"duration": entry.get("duration", 0),
"trickle_ticks": 0,
"label": entry.get("label", ""),
}

if entry.has("resource"):
reward["resource"] = entry["resource"]

return reward


# ── Get reward label for a goal (preview) ────────────────────────────────────
static func get_reward_label(goal_id: String) -> String:
var entry: Dictionary = REWARD_CATALOG.get(goal_id, {})
if entry.is_empty():
return ""
return entry.get("label", "")


# ── Tick all active rewards ──────────────────────────────────────────────────
# Decrements remaining ticks. Returns list of expired reward labels.
# Also applies resource trickle payouts when interval is reached.
# Modifies state.resources for trickle payouts.
static func tick_rewards(active_rewards: Array, game_state: Dictionary) -> Dictionary:
# {expired: Array[String], events: Array[String], new_rewards: Array[Dictionary]}
var result := {"expired": [], "events": [], "new_rewards": active_rewards.duplicate(true)}

var surviving := []
for reward in active_rewards:
var rtype := String(reward.get("type", ""))

if rtype == REWARD_RESOURCE_TRICKLE:
# Accumulate and pay out at interval
reward["trickle_ticks"] = reward.get("trickle_ticks", 0) + 1
if reward["trickle_ticks"] >= TRICKLE_INTERVAL:
reward["trickle_ticks"] = 0
var res := String(reward.get("resource", "food"))
if game_state.has("resources"):
game_state.resources[res] = int(game_state.resources.get(res, 0)) + 1
result["events"].append("+1 %s (goal reward)" % res)

if reward["remaining"] <= 0:
result["expired"].append(reward.get("label", rtype))
continue

reward["remaining"] -= 1
surviving.append(reward)

result["new_rewards"] = surviving
return result


# ── Check if a specific reward type is active ────────────────────────────────
static func has_active_reward(active_rewards: Array, reward_type: String) -> bool:
for reward in active_rewards:
if String(reward.get("type", "")) == reward_type:
return true
return false


# ── Get gather speed multiplier from active rewards ──────────────────────────
static func get_gather_speed_multiplier(active_rewards: Array) -> float:
if has_active_reward(active_rewards, REWARD_GATHER_SPEED):
return 1.5
return 1.0


# ── Get haul speed multiplier from active rewards ────────────────────────────
static func get_haul_speed_multiplier(active_rewards: Array) -> float:
if has_active_reward(active_rewards, REWARD_HAUL_SPEED):
return 1.5
return 1.0


# ── Get build speed bonus from active rewards ────────────────────────────────
static func get_build_speed_bonus(active_rewards: Array) -> float:
if has_active_reward(active_rewards, REWARD_BUILD_SPEED):
return 0.16
return 0.0


# ── Check if ambient event should be improved ────────────────────────────────
# Returns true and consumes the one-time reward if active.
static func consume_ambient_improve(active_rewards: Array) -> bool:
for i in range(active_rewards.size()):
var reward = active_rewards[i]
if String(reward.get("type", "")) == REWARD_AMBIENT_IMPROVE:
active_rewards.remove_at(i)
return true
return false


# ── Check if recruit discount is active ──────────────────────────────────────
# Returns true and consumes the one-time reward if active.
static func consume_recruit_discount(active_rewards: Array) -> bool:
for i in range(active_rewards.size()):
var reward = active_rewards[i]
if String(reward.get("type", "")) == REWARD_RECRUIT_DISCOUNT:
active_rewards.remove_at(i)
return true
return false


# ── Format active rewards for UI display ─────────────────────────────────────
# Returns a short string summarizing active rewards.
static func format_active_rewards(active_rewards: Array) -> String:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (docs): format_active_rewards is implemented but never called. Either wire it into main.gd's UI or remove as dead code. If active rewards display is planned, this is fine to leave as-is.

Automated finding from AI PR review.

if active_rewards.is_empty():
return ""

var parts := []
for reward in active_rewards:
var label := String(reward.get("label", ""))
var remaining := int(reward.get("remaining", 0))
if label.is_empty():
label = reward.get("type", "?")
parts.append("%s (%d)" % [label, remaining])

return " | ".join(parts)
30 changes: 30 additions & 0 deletions scripts/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const LayoutMath := preload("res://scripts/layout_math.gd")
const BUILD_UNLOCKS := Constants.BUILD_UNLOCKS
const RotatingGoal := preload("res://scripts/rotating_goal.gd")
const GoalProgression := preload("res://scripts/goal_progression.gd")
const GoalReward := preload("res://scripts/goal_reward.gd")
const RESOURCE_TRENDS := Constants.RESOURCE_TRENDS
const ColonyStance := preload("res://scripts/colony_stance.gd")

Expand Down Expand Up @@ -95,6 +96,7 @@ var bottom_button_row: HBoxContainer
var game_active := false
var active_goal: Dictionary = {}
var completed_goal_ids: Array = []
var active_rewards: Array = []
var event_drawer_visible := false

func make_panel_style(bg: Color, border: Color, corner_radius: int = 12) -> StyleBoxFlat:
Expand Down Expand Up @@ -759,6 +761,7 @@ func bootstrap_state() -> void:
# Initialize active goal
active_goal = GoalProgression.init_goals(completed_goal_ids)
completed_goal_ids = []
active_rewards = []
_mark_dirty()
persist()
apply_orientation_lock_ui()
Expand Down Expand Up @@ -904,6 +907,8 @@ func load_saved_game() -> void:
active_goal = state["active_goal"]
if state.has("completed_goal_ids"):
completed_goal_ids = state["completed_goal_ids"]
if state.has("active_rewards"):
active_rewards = state["active_rewards"]
colony_stance = String(state.get("colony_stance", ColonyStance.STANCE_BALANCED))
apply_priority_order()
apply_orientation_lock_ui()
Expand Down Expand Up @@ -1091,6 +1096,11 @@ func recruit_worker() -> void:
}
state["workers"].append(new_worker)

# Apply recruit discount reward if active (gives +1 food)
if GoalReward.consume_recruit_discount(active_rewards):
state.resources["food"] = int(state.resources.get("food", 0)) + 1
_mark_dirty()

_mark_dirty()

# Update food info text for the new worker count
Expand Down Expand Up @@ -1380,6 +1390,18 @@ func _on_tick() -> void:
completed_goal_ids = result["completed_ids"]
if result["was_completed"]:
push_event("Goal completed: %s. The colony moves on." % result["goal_id"])
# Apply goal completion reward
var new_reward = GoalReward.apply_reward(result["goal_id"])
if not new_reward.is_empty():
active_rewards.append(new_reward)
push_event("Reward: %s" % new_reward["label"])
# Tick active rewards (expiration + trickle payouts)
var reward_result = GoalReward.tick_rewards(active_rewards, state)
active_rewards = reward_result["new_rewards"]
for evt in reward_result["events"]:
push_event(evt)
for expired_label in reward_result["expired"]:
push_event("Reward ended: %s" % expired_label)
persist()
state.workers = state.workers
render_all()
Expand Down Expand Up @@ -1728,6 +1750,10 @@ func maybe_fire_event() -> void:
if tick % EVENT_INTERVAL_TICKS != 0:
return
var event_roll := rng.randi_range(0, 2)
# If ambient_improve reward is active, convert negative events to positive
if event_roll == 1 and GoalReward.consume_ambient_improve(active_rewards):
event_roll = 0
push_event("A goal reward smooths things over.")
match event_roll:
0:
state.resources.food = int(state.resources.get("food", 0)) + 2
Expand Down Expand Up @@ -1774,6 +1800,8 @@ func structure_build_speed(kind: String) -> float:
speed += 0.16
# Apply food-based slowdown (issue #147)
speed *= get_food_slowdown_factor()
# Apply goal reward build speed bonus
speed += GoalReward.get_build_speed_bonus(active_rewards)
return speed

func render_all() -> void:
Expand Down Expand Up @@ -2423,6 +2451,8 @@ func persist() -> void:
if not active_goal.is_empty():
state["active_goal"] = active_goal.duplicate(true)
state["completed_goal_ids"] = completed_goal_ids.duplicate()
# Persist active rewards for goal reward system
state["active_rewards"] = active_rewards.duplicate(true)
GameState.save_game(state)

func get_tile(pos: Vector2i) -> Dictionary:
Expand Down
Loading