From 87e75ea0183c51335e1d89a80e746bf3c5783f5c Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 18:06:20 +0900 Subject: [PATCH] Add camp talk supply claims --- README.md | 2 +- data/scenarios/001_yellow_turbans.json | 3 + docs/ARCHITECTURE.md | 2 +- docs/DATA_MODEL.md | 11 +- docs/ROADMAP.md | 2 +- scripts/core/battle_state.gd | 24 ++- scripts/core/campaign_state.gd | 59 ++++++- scripts/scenes/battle_scene.gd | 74 ++++++++- tools/smoke_camp_conversation_rewards.gd | 195 +++++++++++++++++++++++ tools/smoke_visual_assets.gd | 20 +++ tools/validate_data.ps1 | 23 +++ 11 files changed, 404 insertions(+), 11 deletions(-) create mode 100644 tools/smoke_camp_conversation_rewards.gd diff --git a/README.md b/README.md index 5255f23..2699adf 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Weapon, armor, and accessory rewards can be stored, displayed, compared, equipped, and unequipped from the battle HUD; some weapons deal bonus physical damage against matching move types. - First named equipment rewards exist in the northern campaign arc and show a `[Named]` or `Named` tag in reward, inventory, shop, Armory, and equipment menus. - Scenario-specific pre-battle shop purchases, optional finite shop stock, and 50% sell-back save immediately and sync into the upcoming battle inventory. -- Pre-battle Talk can list scenario camp conversations by officer or topic, and shop stock can include merchant flavor lines before a battle. +- Pre-battle Talk can list scenario camp conversations by officer or topic, grant one-time pre-battle supplies, and shop stock can include merchant flavor lines before a battle. - Pre-battle Armory equipment changes, including unequipping gear back into stock, save immediately and sync roster equipment plus inventory stock. - Pre-battle Roster can move optional officers between sortie and reserve within scenario limits. - Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells. diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index f4f31de..0aa19c8 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -56,6 +56,9 @@ "lines": [ { "speaker": "Cao Cao", "side": "left", "text": "The merchant's rumor may be useful. If the line holds, send a scout through the northern woods." }, { "speaker": "Xiahou Dun", "side": "right", "text": "A hidden cache in rebel country? Then we take it before they remember where they left it." } + ], + "effects": [ + { "type": "grant_item", "item_id": "bean", "count": 1, "text": "The scout detail adds a Bean to the field supplies." } ] } ] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae53251..b7cc5d1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -57,7 +57,7 @@ Joined officers gate whether `requires_joined` player deployments are loaded at all. Rewards and post-battle choices can add or remove joined officers without deleting their saved roster snapshot, so a later rejoin can preserve progression. When a save loads, `CampaignState` scans completed scenarios in campaign order and reapplies only officer join/leave transitions from rewards and the saved `applied_post_battle_choices` ledger, allowing added recruitment rewards to reach older saves without replaying gold or items. Old saves without the ledger can backfill a choice id only when exactly one completed scenario choice matches saved flags. Roster entries are keyed by `officer_id` when available, so future scenarios can deploy the same joined officer under different scenario-specific `unit_id` values. Saved progression overlays final battle stats for now; a later equipment refactor should split base stats from equipment bonuses before adding item leveling or stat recalculation. New battles currently start player units healed to their saved maximum HP/MP. Skill access is carried in roster snapshots, with class defaults still applied for old saves that do not include skills. Promoted class ids are saved in the roster, and the next battle rehydrates class-derived movement, growth, skills, and range from the saved class id before equipment range is recalculated. -Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. Briefings preview first-clear rewards, can expose `briefing.camp_conversations` through a selectable Talk menu with `briefing.camp_dialogue` as a fallback, and show completed replay rewards as already claimed. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps, and `shop.merchant` lines are presentation-only camp flavor. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes and pickup events fire on entry rather than from units already standing on a marker; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, event gold pickups, save writes, current-scenario advancement, or finite-stock purchases; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. +Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. Briefings preview first-clear rewards, can expose `briefing.camp_conversations` through a selectable Talk menu with `briefing.camp_dialogue` as a fallback, can claim saved one-time camp conversation supply effects through `CampaignState` and refresh the loaded battle inventory copy, and show completed replay rewards as already claimed. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps, and `shop.merchant` lines are presentation-only camp flavor. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Camp conversation supply claims are also campaign transactions: they save a per-scenario conversation claim ledger and inventory immediately, then refresh the already-loaded battle inventory. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes and pickup events fire on entry rather than from units already standing on a marker; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, event gold pickups, save writes, current-scenario advancement, finite-stock purchases, or camp conversation supply claims; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, dialogue lines can show optional cached portrait textures or speaker-initial fallback panels in an expanded visual-novel-style panel, can place the portrait on the left or right side of that panel, and expose progress plus previous-line controls while a sequence is open, the side HUD reuses cached officer portrait textures or class sprites for the selected unit or hovered unit and formats focus text with role, movement type, current terrain move cost, defense/avoid bonuses, core stats, status effects, and gear, board maps can draw a scenario background texture under translucent terrain overlays, connected road segments, terrain detail patterns, and shoreline/castle/forest/hill edge blends, board units render class sprite tokens, team rings, class badges, generic-enemy marks, officer pins, low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, item buttons can show optional cached item icons, hover previews are rendered as target badges from existing `BattleState` forecast APIs, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, objective updates, AI target-focus changes, and item/gold pickup effects use distinct feedback, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, `BattleState.unit_action_motion_requested` keeps its rules-facing signal shape while the scene maps attacker class ids to infantry, archer, cavalry, command, and heavy attack presentation profiles, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index a4814e5..f03faff 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -401,6 +401,9 @@ Scenario deployments may override `move_type` for special units. For example, a "summary": "Follow up on the merchant's rumor.", "lines": [ { "speaker": "Cao Cao", "side": "left", "text": "Send a scout through the northern woods if the line holds." } + ], + "effects": [ + { "type": "grant_item", "item_id": "bean", "count": 1, "text": "The scouts add a Bean to the field supplies." } ] } ], @@ -485,7 +488,7 @@ Scenario deployments may override `move_type` for special units. For example, a `objectives` are display text. `conditions` drive the actual victory and defeat checks. Defeat is evaluated before victory, matching Cao Cao Zhuan-style commander-loss rules. If `conditions` is omitted, battles fall back to defeating all enemies for victory and losing all player units for defeat. -`briefing.camp_conversations` drives the pre-battle Talk menu. Each entry needs a stable lowercase `id`, a non-empty `label`, a `group` of `officer` or `topic`, and non-empty dialogue `lines`. Officer conversations also need an `officer_id` that exists in `officers.json`; `BattleState` normalizes their dialogue lines so speaker portraits can be filled from officer defaults. If `camp_conversations` is absent, the older `briefing.camp_dialogue` array is still exposed as one fallback War Council conversation. +`briefing.camp_conversations` drives the pre-battle Talk menu. Each entry needs a stable lowercase `id`, a non-empty `label`, a `group` of `officer` or `topic`, and non-empty dialogue `lines`. Officer conversations also need an `officer_id` that exists in `officers.json`; `BattleState` normalizes their dialogue lines so speaker portraits can be filled from officer defaults. Optional `effects` can currently include `grant_item` entries with an `item_id`, optional positive `count`, and optional non-empty `text`. Camp conversation supplies are claimed once per scenario/conversation, saved immediately to campaign inventory through `CampaignState`, and copied into the already-loaded battle inventory; completed-scenario replays cannot claim them. If `camp_conversations` is absent, the older `briefing.camp_dialogue` array is still exposed as one fallback War Council conversation. `BattleState` also exposes condition progress and defeat-risk text for presentation. The active HUD and briefing panels split objective, progress, and risk details while showing defeated enemy counts, destination reach status, named commander or protected-unit safety, and turns remaining from the same `conditions` data. Conditions gated by `after_event` stay marked as pending; when the referenced event is a `unit_reaches_tile` or `turn_start` trigger, the tracker can surface that prerequisite as the next visible objective, and `unit_reaches_tile` prerequisites can also draw objective markers while the gated condition is still locked. @@ -637,7 +640,7 @@ Event-spawned units use the same compact deployment shape as scenario starting u `scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, `growth_bonus`, portrait, equipment, and battle-only `status_effects` so the battle resolver and presentation layer can handle counterattacks, MP tactics, support buffs, enemy debuffs, movement modifiers, accuracy/evasion modifiers, damage-over-time statuses, action-lock statuses, EXP, level-ups, weapon-aware physical attack AI, enemy tactic AI, and officer portrait thumbnails without changing scenario files. -`scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, per-scenario shop purchase counts, and player roster progression. The same snapshot can be copied to the one-slot manual checkpoint at `user://campaign_manual_save.json` from the pre-battle Save menu. Loading that checkpoint restores it into the active automatic save and then reloads the pending choice panel, campaign completion state, or current scenario briefing. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, and post-battle choices are guarded by completed scenario id so restarting a finished battle does not duplicate gold, items, membership changes, branch decisions, or finite shop purchases. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves. +`scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, per-scenario shop purchase counts, per-scenario camp conversation claim ledgers, and player roster progression. The same snapshot can be copied to the one-slot manual checkpoint at `user://campaign_manual_save.json` from the pre-battle Save menu. Loading that checkpoint restores it into the active automatic save and then reloads the pending choice panel, campaign completion state, or current scenario briefing. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, post-battle choices, finite shop purchases, and camp conversation supply claims are guarded by scenario/conversation ledgers so restarting a finished battle does not duplicate gold, items, membership changes, branch decisions, or finite shop purchases. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves. After a first-time victory, `CampaignState` advances `current_scenario_id` to the next entry in `data/campaign/campaign.json`. Completed-scenario replays return a result summary without rewriting the save or moving `current_scenario_id`. The battle scene passes `CampaignState.get_roster_overrides()`, `CampaignState.get_inventory_snapshot()`, `CampaignState.get_flags_snapshot()`, and `CampaignState.get_joined_officers_snapshot()` into `BattleState.load_battle()`, so officers can keep level/EXP/stat progression, equipped gear, consumable/equipment stock counts, recruitment gates, and branch flags into the next scenario. @@ -647,8 +650,8 @@ During a battle, item consumption, event item pickups, equipment swaps, and uneq Physical attacks grant normal attack/counter EXP only on hit after the attack/counter exchange resolves. A missed physical attack or counter grants a small miss EXP value, while defeat bonuses still require the attack to hit and defeat the target. -Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()` and sell unequipped inventory through `CampaignState.try_sell_item()`. Successful shop transactions update campaign gold, inventory, and finite-stock purchase counts in the save file immediately, then call `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Roster and Formation choices are battle setup state, so the manual checkpoint stores campaign progression and saved loadout state but not unsaved sortie or starting-cell edits. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. During battle, item previews can show HP/MP recovery, status cures, and debuff cleanses, and no-effect item use is rejected without consuming stock. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails. +Before a battle starts, `BattleScene` can buy priced items through `CampaignState.try_buy_item()` and sell unequipped inventory through `CampaignState.try_sell_item()`. Successful shop transactions update campaign gold, inventory, and finite-stock purchase counts in the save file immediately, then call `BattleState.set_inventory_snapshot()` so the upcoming battle sees the new stock. Pre-battle Talk can claim `briefing.camp_conversations[].effects` through `CampaignState.try_claim_camp_conversation_effects()`, which saves the supply reward and conversation ledger immediately before refreshing the loaded battle inventory copy. Pre-battle Armory changes call `CampaignState.try_save_prebattle_loadout()`, which merges the currently deployed roster snapshot into the saved roster and saves the adjusted inventory in the same operation. Pre-battle Roster calls `BattleState.try_set_unit_deployed()` to toggle optional player units while enforcing required officers and sortie limits. Pre-battle Formation calls `BattleState.try_set_prebattle_formation()`, which can move a player unit to an open formation cell or swap two player units inside the formation area. Roster and Formation choices are battle setup state, so the manual checkpoint stores campaign progression and saved loadout state but not unsaved sortie or starting-cell edits. Matching `briefing.conditional_lines` and `shop.conditional_items` are merged while the scenario loads, before the briefing panel opens. During battle, item previews can show HP/MP recovery, status cures, and debuff cleanses, and no-effect item use is rejected without consuming stock. `post_battle_dialogue` is rendered by the battle scene after victory and before rewards/choices are shown in the result panel. Post-battle choices call `CampaignState.try_apply_post_battle_choice()`, which saves selected flags and rolls back flags, gold, and inventory if the save write fails. When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the automatic save file and resets `current_scenario_id` to `start_scenario`; the manual checkpoint is left alone until overwritten from the Save menu. -Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names, cure statuses, debuff cleanse effects, class sprites, item icons, map backgrounds, officer and deployment portraits/sprites, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event trigger unit/officer filters, event item/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` coordinates, campaign flag references, joined-officer gates, briefing line blocks, camp dialogue and camp conversations, deployment ids, deployment bounds, impassable spawn cells, shop stock, shop merchant lines, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. +Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names, cure statuses, debuff cleanse effects, class sprites, item icons, map backgrounds, officer and deployment portraits/sprites, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event trigger unit/officer filters, event item/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` coordinates, campaign flag references, joined-officer gates, briefing line blocks, camp dialogue, camp conversations and camp conversation effects, deployment ids, deployment bounds, impassable spawn cells, shop stock, shop merchant lines, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ce675b1..518956c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,7 +46,7 @@ - Mid-battle map/action events have a first action system with movement ambushes and scripted unit withdrawals. - Opening battle dialogue exists through event actions. - Post-battle dialogue exists before the victory result panel. -- Pre-battle camp conversations can be selected from the briefing Talk menu by officer or topic, and shops can show merchant flavor lines. +- Pre-battle camp conversations can be selected from the briefing Talk menu by officer or topic, can grant one-time pre-battle supplies, and shops can show merchant flavor lines. - Reinforcements, scripted unit withdrawals, objective changes with HUD/log feedback, and item/gold pickup events exist in a basic form. - Post-battle reward save, campaign choice flags, first flag-driven briefing/shop/event branches, sectioned result summaries, and choice reward previews exist in a basic form. Richer reward screens and wider branch support are still planned. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 13d0421..5fe8c95 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -274,11 +274,33 @@ func _normalized_camp_conversations(source) -> Array: "label": label, "speaker": str(entry.get("speaker", "")).strip_edges(), "summary": str(entry.get("summary", "")).strip_edges(), - "lines": lines + "lines": lines, + "effects": _normalized_camp_conversation_effects(entry.get("effects", [])) }) return result +func _normalized_camp_conversation_effects(source) -> Array: + var result := [] + if typeof(source) != TYPE_ARRAY: + return result + for effect in source: + if typeof(effect) != TYPE_DICTIONARY: + continue + var effect_type := str(effect.get("type", "")).strip_edges() + if effect_type == "grant_item": + var item_id := str(effect.get("item_id", effect.get("id", ""))).strip_edges() + if item_id.is_empty(): + continue + result.append({ + "type": "grant_item", + "item_id": item_id, + "count": maxi(1, int(effect.get("count", 1))), + "text": str(effect.get("text", "")).strip_edges() + }) + return result + + func _is_condition_shape_valid(condition) -> bool: return typeof(condition) == TYPE_DICTIONARY or typeof(condition) == TYPE_ARRAY diff --git a/scripts/core/campaign_state.gd b/scripts/core/campaign_state.gd index f5df694..44abc08 100644 --- a/scripts/core/campaign_state.gd +++ b/scripts/core/campaign_state.gd @@ -3,7 +3,7 @@ class_name CampaignState const SAVE_PATH := "user://campaign_save.json" const MANUAL_SAVE_PATH := "user://campaign_manual_save.json" -const SAVE_VERSION := 4 +const SAVE_VERSION := 5 var save_version := SAVE_VERSION var campaign_id := "" @@ -27,6 +27,7 @@ var joined_officers: Array[String] = [] var applied_post_battle_choices := {} var pending_post_battle_choice_scenario_id := "" var shop_purchases := {} +var claimed_camp_conversation_effects := {} func load_campaign(path: String) -> bool: @@ -138,6 +139,7 @@ func start_new(scenario_id: String) -> void: applied_post_battle_choices.clear() pending_post_battle_choice_scenario_id = "" shop_purchases.clear() + claimed_camp_conversation_effects.clear() for officer_id in initial_joined_officers: joined_officers.append(officer_id) @@ -353,6 +355,57 @@ func get_shop_purchase_count(scenario_id: String, item_id: String) -> int: return int(shop_purchases[scenario_key].get(item_key, 0)) +func has_claimed_camp_conversation_effects(scenario_id: String, conversation_id: String) -> bool: + var scenario_key := scenario_id.strip_edges() + var conversation_key := conversation_id.strip_edges() + if scenario_key.is_empty() or conversation_key.is_empty(): + return false + if not claimed_camp_conversation_effects.has(scenario_key): + return false + if typeof(claimed_camp_conversation_effects[scenario_key]) != TYPE_DICTIONARY: + return false + return bool((claimed_camp_conversation_effects[scenario_key] as Dictionary).get(conversation_key, false)) + + +func try_claim_camp_conversation_effects(scenario_id: String, conversation: Dictionary) -> bool: + var scenario_key := scenario_id.strip_edges() + var conversation_key := str(conversation.get("id", "")).strip_edges() + if scenario_key.is_empty() or conversation_key.is_empty(): + return false + if completed_scenarios.has(scenario_key): + return false + if has_claimed_camp_conversation_effects(scenario_key, conversation_key): + return false + var effects_value = conversation.get("effects", []) + if typeof(effects_value) != TYPE_ARRAY: + return false + var previous_inventory := inventory.duplicate(true) + var previous_claimed_effects := claimed_camp_conversation_effects.duplicate(true) + var applied := false + for effect in effects_value: + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")).strip_edges() != "grant_item": + continue + var item_key := str(effect.get("item_id", effect.get("id", ""))).strip_edges() + if item_key.is_empty(): + continue + var count := maxi(1, int(effect.get("count", 1))) + inventory[item_key] = int(inventory.get(item_key, 0)) + count + applied = true + if not applied: + return false + if not claimed_camp_conversation_effects.has(scenario_key) or typeof(claimed_camp_conversation_effects[scenario_key]) != TYPE_DICTIONARY: + claimed_camp_conversation_effects[scenario_key] = {} + var scenario_claims: Dictionary = claimed_camp_conversation_effects[scenario_key] + scenario_claims[conversation_key] = true + if save_game(): + return true + inventory = previous_inventory + claimed_camp_conversation_effects = previous_claimed_effects + return false + + func try_buy_item(item_id: String, price: int, scenario_id: String = "", stock_limit: int = -1) -> bool: if item_id.is_empty() or price <= 0 or gold < price: return false @@ -818,7 +871,8 @@ func to_dict() -> Dictionary: "joined_officers": joined_officers.duplicate(), "applied_post_battle_choices": applied_post_battle_choices.duplicate(true), "pending_post_battle_choice_scenario_id": pending_post_battle_choice_scenario_id, - "shop_purchases": shop_purchases.duplicate(true) + "shop_purchases": shop_purchases.duplicate(true), + "claimed_camp_conversation_effects": claimed_camp_conversation_effects.duplicate(true) } @@ -835,6 +889,7 @@ func _apply_save_data(parsed: Dictionary) -> void: applied_post_battle_choices = _copy_dictionary(parsed.get("applied_post_battle_choices", {})) pending_post_battle_choice_scenario_id = str(parsed.get("pending_post_battle_choice_scenario_id", "")) shop_purchases = _copy_dictionary(parsed.get("shop_purchases", {})) + claimed_camp_conversation_effects = _copy_dictionary(parsed.get("claimed_camp_conversation_effects", {})) joined_officers.clear() var saved_joined = parsed.get("joined_officers", initial_joined_officers) for officer_id in saved_joined: diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 741cc14..011e2c1 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -3457,6 +3457,9 @@ func _format_camp_conversation_button_text(conversation: Dictionary) -> String: parts.append(speaker) if not summary.is_empty(): parts.append(summary) + var effects_text := _format_camp_conversation_effects_text(conversation) + if not effects_text.is_empty(): + parts.append(effects_text) return _join_strings(parts, " - ") @@ -3468,9 +3471,78 @@ func _on_camp_conversation_pressed(conversation_id: String) -> void: _play_ui_confirm() _hide_talk_menu() _show_camp_dialogue(conversation.get("lines", [])) + _apply_camp_conversation_effects(conversation) _update_hud() +func _format_camp_conversation_effects_text(conversation: Dictionary) -> String: + var effects: Array = conversation.get("effects", []) + if effects.is_empty(): + return "" + var parts := [] + for effect in effects: + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")) != "grant_item": + continue + var item_id := str(effect.get("item_id", "")) + var item := state.get_item_def(item_id) + if item.is_empty(): + continue + var count := maxi(1, int(effect.get("count", 1))) + var count_text := "" if count == 1 else " x%d" % count + parts.append("Supply %s%s" % [str(item.get("name", item_id)), count_text]) + if parts.is_empty(): + return "" + var conversation_id := str(conversation.get("id", "")) + var claimed_text := "claimed" if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id) else "available" + return "%s (%s)" % [_join_strings(parts, ", "), claimed_text] + + +func _apply_camp_conversation_effects(conversation: Dictionary) -> void: + if battle_started or _is_prebattle_prep_locked(): + return + var conversation_id := str(conversation.get("id", "")) + if conversation_id.is_empty(): + return + if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id): + return + var effects: Array = conversation.get("effects", []) + if effects.is_empty(): + return + if campaign_state.try_claim_camp_conversation_effects(active_scenario_id, conversation): + state.set_inventory_snapshot(campaign_state.get_inventory_snapshot()) + _on_log_added(_format_camp_conversation_effect_claim_text(conversation)) + if talk_menu != null and talk_menu.visible: + _rebuild_talk_menu() + return + _play_ui_cancel() + _on_log_added("Could not claim camp supplies.") + + +func _format_camp_conversation_effect_claim_text(conversation: Dictionary) -> String: + var messages := [] + var effects: Array = conversation.get("effects", []) + for effect in effects: + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")) != "grant_item": + continue + var message := str(effect.get("text", "")).strip_edges() + if not message.is_empty(): + messages.append(message) + continue + var item_id := str(effect.get("item_id", "")) + if item_id.is_empty(): + continue + var count := maxi(1, int(effect.get("count", 1))) + var count_text := "" if count == 1 else " x%d" % count + messages.append("Camp supplies received: %s%s." % [_item_display_name(item_id), count_text]) + if messages.is_empty(): + return "Camp supplies received." + return _join_strings(messages, " ") + + func _camp_conversation_by_id(conversation_id: String) -> Dictionary: for conversation in _camp_conversation_entries(): if str(conversation.get("id", "")) == conversation_id: @@ -4132,7 +4204,7 @@ func _apply_item_button_icon(button: Button, item: Dictionary) -> void: return button.icon = icon button.expand_icon = true - button.icon_max_width = 30 + button.add_theme_constant_override("icon_max_width", 30) func _on_shop_item_pressed(item_id: String) -> void: diff --git a/tools/smoke_camp_conversation_rewards.gd b/tools/smoke_camp_conversation_rewards.gd new file mode 100644 index 0000000..27c75e6 --- /dev/null +++ b/tools/smoke_camp_conversation_rewards.gd @@ -0,0 +1,195 @@ +extends SceneTree + +const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") + +const CAMPAIGN_PATH := "res://data/campaign/campaign.json" +const SCENARIO_ID := "001_yellow_turbans" +const SCENARIO_PATH := "res://data/scenarios/001_yellow_turbans.json" +const CONVERSATION_ID := "northern_woods_cache" +const SAVE_PATH := "user://campaign_save.json" +const MANUAL_SAVE_PATH := "user://campaign_manual_save.json" + + +func _init() -> void: + var save_backup := _backup_user_file(SAVE_PATH) + var manual_backup := _backup_user_file(MANUAL_SAVE_PATH) + var failures: Array[String] = [] + + _check_claim_updates_campaign_and_battle(failures) + _check_shop_and_armory_preserve_claim(failures) + _check_manual_checkpoint_reverts_claim(failures) + _check_completed_replay_cannot_claim(failures) + + if not _restore_user_file(SAVE_PATH, save_backup): + failures.append("could not restore automatic campaign save") + if not _restore_user_file(MANUAL_SAVE_PATH, manual_backup): + failures.append("could not restore manual campaign save") + + if failures.is_empty(): + print("camp conversation rewards smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_claim_updates_campaign_and_battle(failures: Array[String]) -> void: + var scene = _new_prebattle_scene(failures, "claim update") + if scene == null: + return + var conversation: Dictionary = scene._camp_conversation_by_id(CONVERSATION_ID) + if not _check_cache_conversation(failures, conversation): + scene.free() + return + + var before := int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) + if not scene._format_camp_conversation_button_text(conversation).contains("available"): + failures.append("camp conversation button should show available before claim") + scene._apply_camp_conversation_effects(conversation) + + var campaign_after := int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) + var battle_after := int(scene.state.get_inventory_snapshot().get("bean", 0)) + if campaign_after != before + 1: + failures.append("camp conversation should add one Bean to campaign inventory") + if battle_after != before + 1: + failures.append("camp conversation should refresh loaded battle inventory") + if not scene.campaign_state.has_claimed_camp_conversation_effects(SCENARIO_ID, CONVERSATION_ID): + failures.append("camp conversation claim should be saved in campaign ledger") + if not scene._format_camp_conversation_button_text(conversation).contains("claimed"): + failures.append("camp conversation button should show claimed after claim") + + scene._apply_camp_conversation_effects(conversation) + var duplicate_after := int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) + if duplicate_after != campaign_after: + failures.append("camp conversation should not duplicate rewards") + scene.free() + + +func _check_shop_and_armory_preserve_claim(failures: Array[String]) -> void: + var scene = _new_prebattle_scene(failures, "shop and armory preservation") + if scene == null: + return + var conversation: Dictionary = scene._camp_conversation_by_id(CONVERSATION_ID) + if not _check_cache_conversation(failures, conversation): + scene.free() + return + scene._apply_camp_conversation_effects(conversation) + var claimed_beans := int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) + + scene.campaign_state.gold = 1000 + var antidote: Dictionary = scene.state.get_item_def("antidote") + var price := int(antidote.get("price", 0)) + if price <= 0 or not scene.campaign_state.try_buy_item("antidote", price, SCENARIO_ID, scene.state.get_shop_stock_limit("antidote")): + failures.append("shop purchase after camp claim should save") + else: + scene.state.set_inventory_snapshot(scene.campaign_state.get_inventory_snapshot()) + var after_buy := int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) + if after_buy != claimed_beans: + failures.append("shop purchase should preserve camp-claimed Bean") + + if not scene.campaign_state.try_save_prebattle_loadout(scene.state.get_player_roster_snapshot(), scene.state.get_inventory_snapshot()): + failures.append("armory save after camp claim should save") + else: + var after_armory := int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) + if after_armory != claimed_beans: + failures.append("armory save should preserve camp-claimed Bean") + if not scene.campaign_state.has_claimed_camp_conversation_effects(SCENARIO_ID, CONVERSATION_ID): + failures.append("armory save should preserve camp claim ledger") + scene.free() + + +func _check_manual_checkpoint_reverts_claim(failures: Array[String]) -> void: + var scene = _new_prebattle_scene(failures, "manual checkpoint") + if scene == null: + return + if not scene.campaign_state.save_manual_game(): + failures.append("manual checkpoint before camp claim should save") + scene.free() + return + var conversation: Dictionary = scene._camp_conversation_by_id(CONVERSATION_ID) + scene._apply_camp_conversation_effects(conversation) + if int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) != 1: + failures.append("camp claim should add Bean before manual restore") + if not scene.campaign_state.load_manual_game(): + failures.append("manual checkpoint should load after camp claim") + else: + if int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) != 0: + failures.append("loading older manual checkpoint should revert camp reward inventory") + if scene.campaign_state.has_claimed_camp_conversation_effects(SCENARIO_ID, CONVERSATION_ID): + failures.append("loading older manual checkpoint should revert camp claim ledger") + scene.free() + + +func _check_completed_replay_cannot_claim(failures: Array[String]) -> void: + var scene = _new_prebattle_scene(failures, "completed replay") + if scene == null: + return + scene.campaign_state.completed_scenarios.append(SCENARIO_ID) + var conversation: Dictionary = scene._camp_conversation_by_id(CONVERSATION_ID) + scene._apply_camp_conversation_effects(conversation) + if int(scene.campaign_state.get_inventory_snapshot().get("bean", 0)) != 0: + failures.append("completed replay should not claim camp supplies") + if scene.campaign_state.has_claimed_camp_conversation_effects(SCENARIO_ID, CONVERSATION_ID): + failures.append("completed replay should not write camp claim ledger") + scene.free() + + +func _new_prebattle_scene(failures: Array[String], label: String): + var scene = BattleSceneScript.new() + if not scene.campaign_state.load_campaign(CAMPAIGN_PATH): + failures.append("%s could not load campaign data" % label) + scene.free() + return null + scene.campaign_state.start_new(SCENARIO_ID) + scene.active_scenario_id = SCENARIO_ID + if not scene.state.load_battle( + SCENARIO_PATH, + scene.campaign_state.get_roster_overrides(), + scene.campaign_state.get_inventory_snapshot(), + scene.campaign_state.get_flags_snapshot(), + scene.campaign_state.get_joined_officers_snapshot() + ): + failures.append("%s could not load scenario" % label) + scene.free() + return null + return scene + + +func _check_cache_conversation(failures: Array[String], conversation: Dictionary) -> bool: + if conversation.is_empty(): + failures.append("could not find cache camp conversation") + return false + var effects: Array = conversation.get("effects", []) + if effects.is_empty(): + failures.append("cache camp conversation should expose effects") + return false + var effect: Dictionary = effects[0] + if str(effect.get("type", "")) != "grant_item": + failures.append("cache camp conversation effect should be grant_item") + if str(effect.get("item_id", "")) != "bean": + failures.append("cache camp conversation should grant Bean") + if int(effect.get("count", 0)) != 1: + failures.append("cache camp conversation should grant one Bean") + return true + + +func _backup_user_file(path: String) -> Dictionary: + var exists := FileAccess.file_exists(path) + return { + "exists": exists, + "content": FileAccess.get_file_as_string(path) if exists else "" + } + + +func _restore_user_file(path: String, backup: Dictionary) -> bool: + if bool(backup.get("exists", false)): + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return false + file.store_string(str(backup.get("content", ""))) + return true + if FileAccess.file_exists(path): + return DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK + return true diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 710dd18..cc1cc85 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -39,6 +39,7 @@ func _check_battle_visual_data(failures: Array[String]) -> void: _check_camp_conversation(failures, conversations[0], "cao_cao_strategy", "officer", "cao_cao") _check_camp_conversation(failures, conversations[1], "xiahou_dun_vanguard", "officer", "xiahou_dun") _check_camp_conversation(failures, conversations[2], "northern_woods_cache", "topic", "") + _check_camp_conversation_effect(failures, conversations[2], "bean", 1) var merchant := state.get_shop_merchant() if merchant.is_empty() or (merchant.get("lines", []) as Array).is_empty(): failures.append("001 shop should expose merchant lines") @@ -88,6 +89,11 @@ func _check_scene_texture_loading(failures: Array[String]) -> void: failures.append("battle scene should find Cao Cao camp conversation by id") elif not scene._format_camp_conversation_button_text(strategy).contains("Cao Cao"): failures.append("camp conversation button text should include label/speaker") + var cache := scene._camp_conversation_by_id("northern_woods_cache") + if cache.is_empty(): + failures.append("battle scene should find cache camp conversation by id") + elif not scene._format_camp_conversation_button_text(cache).contains("Supply Bean"): + failures.append("camp conversation button text should preview supply effect") scene.free() @@ -282,6 +288,20 @@ func _check_attack_motion_signal(failures: Array[String]) -> void: failures.append("attack motion signal missing expected Cao Cao attack: %s" % str(motions)) +func _check_camp_conversation_effect(failures: Array[String], conversation: Dictionary, expected_item_id: String, expected_count: int) -> void: + var effects: Array = conversation.get("effects", []) + if effects.is_empty(): + failures.append("camp conversation %s should expose effects" % str(conversation.get("id", ""))) + return + var effect: Dictionary = effects[0] + if str(effect.get("type", "")) != "grant_item": + failures.append("camp conversation effect should be grant_item: %s" % str(effect)) + if str(effect.get("item_id", "")) != expected_item_id: + failures.append("camp conversation effect item mismatch: %s" % str(effect)) + if int(effect.get("count", 0)) != expected_count: + failures.append("camp conversation effect count mismatch: %s" % str(effect)) + + func _check_camp_conversation(failures: Array[String], conversation: Dictionary, expected_id: String, expected_group: String, expected_officer_id: String) -> void: if str(conversation.get("id", "")) != expected_id: failures.append("camp conversation id mismatch: expected %s got %s" % [expected_id, str(conversation.get("id", ""))]) diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index 80a615b..9fbf175 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -106,6 +106,7 @@ $validSkillEffectTypes = @("stat_bonus", "damage_over_time", "action_lock") $validSkillAreaShapes = @("single", "diamond") $validDialogueSides = @("left", "right") $validCampConversationGroups = @("officer", "topic") +$validCampConversationEffectTypes = @("grant_item") $validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$' $minimumPortraitDimension = 512 $portraitImageInspectorReady = $false @@ -1769,6 +1770,28 @@ function Check-Camp-Conversations($Conversations, [string]$ScenarioId) { foreach ($conversationLine in @($conversationLines)) { Check-Dialogue-Line $conversationLine "Scenario $ScenarioId briefing camp_conversations $conversationId" } + if (Has-Prop $conversation "effects") { + $rawEffects = Get-Prop $conversation "effects" $null + $effects = @($rawEffects) + if ($null -eq $rawEffects -or $effects.Count -le 0) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId effects must be an array." + } + foreach ($effect in $effects) { + if ($effect -is [string] -or $effect -is [System.Array]) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId has malformed effect." + } + $effectType = [string](Get-Prop $effect "type" "") + if (-not $validCampConversationEffectTypes.Contains($effectType)) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId has unknown effect: $effectType" + } + if ($effectType -eq "grant_item") { + Check-Event-Grant-Item $effect "Scenario $ScenarioId briefing camp_conversations $conversationId grant_item" + } + if ((Has-Prop $effect "text") -and [string]::IsNullOrWhiteSpace([string](Get-Prop $effect "text" ""))) { + Fail "Scenario $ScenarioId briefing camp_conversations $conversationId effect text must not be empty." + } + } + } } }