diff --git a/scripts/game_state.gd b/scripts/game_state.gd index 2386847..f038a64 100644 --- a/scripts/game_state.gd +++ b/scripts/game_state.gd @@ -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: diff --git a/scripts/goal_reward.gd b/scripts/goal_reward.gd new file mode 100644 index 0000000..d011765 --- /dev/null +++ b/scripts/goal_reward.gd @@ -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: + 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) diff --git a/scripts/main.gd b/scripts/main.gd index f497faa..1e515c6 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -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") @@ -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: @@ -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() @@ -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() @@ -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 @@ -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() @@ -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 @@ -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: @@ -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: diff --git a/tests/test_goal_reward.gd b/tests/test_goal_reward.gd new file mode 100644 index 0000000..ac841f6 --- /dev/null +++ b/tests/test_goal_reward.gd @@ -0,0 +1,213 @@ +extends SceneTree +# Tests for goal_reward.gd — misospace/windowstead#134 +# Run with: godot --headless --no-window --script tests/test_goal_reward.gd + +var test_pass := 0 +var test_fail := 0 + + +func assert_true(condition: bool, msg: String) -> void: + if not condition: + test_fail += 1 + print("FAIL: %s" % msg) + else: + test_pass += 1 + + +func assert_eq(actual, expected, msg: String) -> void: + if actual != expected: + test_fail += 1 + print("FAIL: %s (got %s, expected %s)" % [msg, str(actual), str(expected)]) + else: + test_pass += 1 + + +func _initialize() -> void: + var reward_script := load("res://scripts/goal_reward.gd") + + test_apply_reward_returns_dict(reward_script) + test_apply_reward_returns_empty_for_unknown(reward_script) + test_resource_trickle_payouts(reward_script) + test_reward_expiration(reward_script) + test_ambient_improve_consumption(reward_script) + test_recruit_discount_consumption(reward_script) + test_build_speed_bonus(reward_script) + test_gather_speed_multiplier(reward_script) + test_haul_speed_multiplier(reward_script) + test_format_active_rewards(reward_script) + test_get_reward_label(reward_script) + test_multiple_trickle_rewards(reward_script) + test_tick_returns_surviving(reward_script) + + print("") + print("=== test_goal_reward summary: %d passed, %d failed ===" % [test_pass, test_fail]) + if test_fail > 0: + print("FAILURES DETECTED — CI should fail") + quit(1) + else: + print("test_goal_reward: ok") + quit(0) + + +func test_apply_reward_returns_dict(_script: Script) -> void: + var GoalReward = _script + var reward = GoalReward.apply_reward("gather_wood") + assert_true(not reward.is_empty(), "apply_reward returns dict for known goal") + assert_eq(reward["type"], GoalReward.REWARD_RESOURCE_TRICKLE, "Type is resource_trickle") + assert_eq(reward["resource"], "food", "Resource is food") + assert_true(reward["remaining"] > 0, "Has positive remaining ticks") + + +func test_apply_reward_returns_empty_for_unknown(_script: Script) -> void: + var GoalReward = _script + var reward = GoalReward.apply_reward("nonexistent_goal") + assert_true(reward.is_empty(), "apply_reward returns empty for unknown goal") + + +func test_resource_trickle_payouts(_script: Script) -> void: + var GoalReward = _script + var reward = GoalReward.apply_reward("gather_wood") + var game_state := {"resources": {"food": 10, "wood": 5, "stone": 3}} + + assert_eq(game_state.resources.food, 10, "Initial food is 10") + + var rewards := [reward] + for i in range(9): + var result = GoalReward.tick_rewards(rewards, game_state) + rewards = result["new_rewards"] + + assert_eq(game_state.resources.food, 10, "Food still 10 after 9 ticks") + + var result = GoalReward.tick_rewards(rewards, game_state) + rewards = result["new_rewards"] + assert_eq(game_state.resources.food, 11, "Food is 11 after trickle payout") + assert_eq(result.events.size(), 1, "One event message on payout") + + +func test_reward_expiration(_script: Script) -> void: + var GoalReward = _script + var reward = GoalReward.apply_reward("gather_wood") + reward["remaining"] = 3 + var game_state := {"resources": {"food": 0}} + + var rewards := [reward] + var result + for i in range(5): + result = GoalReward.tick_rewards(rewards, game_state) + rewards = result["new_rewards"] + + assert_true(rewards.is_empty(), "Reward expired after ticks") + assert_true(result.expired.size() > 0, "Has expired label") + + +func test_ambient_improve_consumption(_script: Script) -> void: + var GoalReward = _script + var rewards := [{"type": GoalReward.REWARD_AMBIENT_IMPROVE, "label": "ambient event improves", "remaining": 0}] + + assert_true(GoalReward.has_active_reward(rewards, GoalReward.REWARD_AMBIENT_IMPROVE), "Has ambient improve") + var consumed = GoalReward.consume_ambient_improve(rewards) + assert_true(consumed, "Returns true when consuming") + assert_false(GoalReward.has_active_reward(rewards, GoalReward.REWARD_AMBIENT_IMPROVE), "Gone after consume") + + +func test_recruit_discount_consumption(_script: Script) -> void: + var GoalReward = _script + var rewards := [{"type": GoalReward.REWARD_RECRUIT_DISCOUNT, "label": "next recruit -1 food", "remaining": 0}] + + assert_true(GoalReward.has_active_reward(rewards, GoalReward.REWARD_RECRUIT_DISCOUNT), "Has recruit discount") + var consumed = GoalReward.consume_recruit_discount(rewards) + assert_true(consumed, "Returns true when consuming") + assert_false(GoalReward.has_active_reward(rewards, GoalReward.REWARD_RECRUIT_DISCOUNT), "Gone after consume") + + +func test_build_speed_bonus(_script: Script) -> void: + var GoalReward = _script + var rewards := [] + assert_eq(GoalReward.get_build_speed_bonus(rewards), 0.0, "No bonus without reward") + + rewards.append({"type": GoalReward.REWARD_BUILD_SPEED, "remaining": 20}) + assert_eq(GoalReward.get_build_speed_bonus(rewards), 0.16, "Returns 0.16 bonus") + + +func test_gather_speed_multiplier(_script: Script) -> void: + var GoalReward = _script + var rewards := [] + assert_eq(GoalReward.get_gather_speed_multiplier(rewards), 1.0, "No multiplier without reward") + + rewards.append({"type": GoalReward.REWARD_GATHER_SPEED, "remaining": 20}) + assert_eq(GoalReward.get_gather_speed_multiplier(rewards), 1.5, "Returns 1.5x multiplier") + + +func test_haul_speed_multiplier(_script: Script) -> void: + var GoalReward = _script + var rewards := [] + assert_eq(GoalReward.get_haul_speed_multiplier(rewards), 1.0, "No multiplier without reward") + + rewards.append({"type": GoalReward.REWARD_HAUL_SPEED, "remaining": 20}) + assert_eq(GoalReward.get_haul_speed_multiplier(rewards), 1.5, "Returns 1.5x multiplier") + + +func test_format_active_rewards(_script: Script) -> void: + var GoalReward = _script + var rewards := [ + {"type": GoalReward.REWARD_RESOURCE_TRICKLE, "label": "+1 food trickle", "remaining": 30}, + {"type": GoalReward.REWARD_HAUL_SPEED, "label": "haul speed +10%", "remaining": 15}, + ] + + var text = GoalReward.format_active_rewards(rewards) + assert_true(text.find("+1 food trickle") >= 0, "Contains first reward label") + assert_true(text.find("haul speed +10%") >= 0, "Contains second reward label") + assert_true(text.find("(30)") >= 0, "Contains remaining ticks") + + +func test_get_reward_label(_script: Script) -> void: + var GoalReward = _script + var label = GoalReward.get_reward_label("gather_wood") + assert_true(not label.is_empty(), "Non-empty label for known goal") + assert_true(label.find("food") >= 0, "Label mentions food") + + var empty_label = GoalReward.get_reward_label("nonexistent") + assert_true(empty_label.is_empty(), "Empty string for unknown goal") + + +func test_multiple_trickle_rewards(_script: Script) -> void: + var GoalReward = _script + var reward1 = GoalReward.apply_reward("gather_wood") + var reward2 = GoalReward.apply_reward("gather_stone") + var game_state := {"resources": {"food": 0, "wood": 0, "stone": 0}} + + var rewards := [reward1, reward2] + + for i in range(GoalReward.TRICKLE_INTERVAL): + var result = GoalReward.tick_rewards(rewards, game_state) + rewards = result["new_rewards"] + + assert_eq(game_state.resources.food, 2, "Food is 2 from two trickle rewards") + + +func test_tick_returns_surviving(_script: Script) -> void: + var GoalReward = _script + var reward1 = GoalReward.apply_reward("gather_wood") + reward1["remaining"] = 5 + var reward2 = GoalReward.apply_reward("build_hut") + reward2["remaining"] = 2 + + var game_state := {"resources": {}} + var rewards := [reward1, reward2] + + var result = GoalReward.tick_rewards(rewards, game_state) + assert_eq(result.new_rewards.size(), 2, "Both survive first tick") + + for i in range(2): + result = GoalReward.tick_rewards(result.new_rewards, game_state) + + assert_eq(result.new_rewards.size(), 1, "Only reward1 survives") + assert_true(result.expired.size() > 0, "Has one expired reward") + + +func assert_false(condition: bool, msg: String) -> void: + if condition: + test_fail += 1 + print("FAIL: %s (expected false)" % msg) + else: + test_pass += 1