From 07861391c7c9e39900473a3677e7edc4c3121d75 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 18 Jun 2026 09:29:16 +0900 Subject: [PATCH] Add finite prebattle shop stock --- README.md | 2 +- data/scenarios/002_sishui_gate.json | 8 +++- docs/ARCHITECTURE.md | 2 +- docs/DATA_MODEL.md | 12 +++--- docs/GODOT_4_6_MIGRATION.md | 1 + docs/ROADMAP.md | 2 +- scripts/core/battle_state.gd | 57 +++++++++++++++++++++++------ scripts/core/campaign_state.gd | 30 +++++++++++++-- scripts/scenes/battle_scene.gd | 28 +++++++++++--- tools/validate_data.ps1 | 43 ++++++++++++++++++++++ 10 files changed, 155 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1d8890b..60a2605 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - MP tactics with low/high damage options, healing options, ally support buffs, enemy debuffs, a skill list menu, previews, and target overlays. - Campaign inventory consumables with an item menu, HP/MP recovery preview, and target overlay. - Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD. -- Scenario-specific pre-battle shop purchases and 50% sell-back save immediately and sync into the upcoming battle inventory. +- 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 Armory equipment changes 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/002_sishui_gate.json b/data/scenarios/002_sishui_gate.json index 606ee49..d251434 100644 --- a/data/scenarios/002_sishui_gate.json +++ b/data/scenarios/002_sishui_gate.json @@ -27,7 +27,13 @@ "leather_armor", "short_bow", "training_spear" - ] + ], + "stock": { + "cloth_robe": 1, + "leather_armor": 1, + "short_bow": 1, + "training_spear": 1 + } }, "roster": { "max_units": 3, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6b5812e..498b1d3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -56,7 +56,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. 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. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory 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. 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. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items 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. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, save writes, or current-scenario advancement; 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. 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. 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. 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. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items 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. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, 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. 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 and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, support and debuff effects use distinct floating feedback colors, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, 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 bb0832d..429b666 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -287,7 +287,7 @@ Weapon `range` also uses `[min, max]`. When class and equipment both provide ran Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP and `heal_mp` restores MP to a valid target; `Bean` and `Wine` are the first examples. Item use consumes the acting unit's action and only commits to the save file on victory. Weapons, armor, and accessories can be granted through `rewards.items`; they are shown in inventory, can be equipped through the Equip menu before the unit moves or acts, and are not usable from the battle item menu. -Scenario `shop.items` controls which positive-price items appear in the current pre-battle shop. `shop.conditional_items` can append extra stock when saved campaign flags match. Purchases spend campaign gold, increment the campaign inventory count, save immediately, and then refresh the battle inventory copy if a scenario briefing is already loaded. The shop can also sell unequipped inventory stock back for 50% of base `price`, saving immediately and refreshing the loaded battle inventory copy. Later campaign data should add finite stock counts and unlock flags. +Scenario `shop.items` controls which positive-price items appear in the current pre-battle shop. `shop.conditional_items` can append extra stock when saved campaign flags match. Entries can be plain item ids or objects with `id` and optional non-negative `stock`; `shop.stock` and conditional block `stock` objects can also set finite limits by item id. Items omitted from stock data are unlimited. Finite stock is tracked as per-scenario purchase counts in the campaign save, so buying an item decrements that scenario's remaining stock and manual checkpoint saves preserve it. Purchases spend campaign gold, increment the campaign inventory count, save immediately, and then refresh the battle inventory copy if a scenario briefing is already loaded. The shop can also sell unequipped inventory stock back for 50% of base `price`, saving immediately and refreshing the loaded battle inventory copy. Later campaign data should add broader unlock flags. The pre-battle Armory operates on the currently loaded player deployments and the copied battle inventory, then saves the changed roster entries and inventory counts back into `CampaignState`. Unlike in-battle equipment swaps, Armory changes persist immediately and survive defeat or restart. Completed-scenario replays keep Shop and Armory disabled so replay setup cannot alter the campaign save. @@ -327,11 +327,13 @@ Scenario deployments may override `move_type` for special units. For example, a ] }, "shop": { - "items": ["bean"], + "items": ["bean", { "id": "cloth_robe", "stock": 1 }], + "stock": { "bean": 3 }, "conditional_items": [ { "campaign_flags": { "regrouped_after_sishui": true }, - "items": ["leather_armor"] + "items": ["training_spear", "leather_armor"], + "stock": { "leather_armor": 1 } } ] }, @@ -475,7 +477,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`, and battle-only `status_effects` so the battle resolver can handle counterattacks, MP tactics, support buffs, enemy debuffs, EXP, level-ups, and enemy tactic AI 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, 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, or branch decisions. 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, 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. 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. @@ -485,7 +487,7 @@ During a battle, item consumption and equipment swaps affect `BattleState.battle 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 and inventory 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. `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 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. `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. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index b18bb92..051f90e 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -43,6 +43,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Current campaign save loads or a new campaign starts cleanly. - Briefing opens and can enter battle. - Chapters, Shop, Armory, Roster, Formation, and Save open and close; completed or current Chapter entries can open a battle briefing. +- Finite-stock Shop entries show remaining counts and disable once sold out. - Manual checkpoint save/load restores the expected current briefing without script errors. - A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear. - Enemy turn advances and AI acts. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3a1add5..7361301 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -56,7 +56,7 @@ - Linear sixty-seven-scenario campaign order exists with chapter ranges for story arcs. - Save reset exists. - Victory rewards, consumable counts, equipment stock, and equipped gear persist. -- A basic pre-battle shop with scenario-specific stock and 50% sell-back exists. Richer item management is still planned. +- A basic pre-battle shop with scenario-specific stock, optional finite item limits, and 50% sell-back exists. Richer item management beyond stock limits is still planned. - A basic pre-battle Armory exists for saved equipment changes before combat. - A basic pre-battle Roster exists for required officers, required scenario units, optional officers, and sortie limits. - A basic pre-battle Formation screen exists for scenario-defined starting cells. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 53d074a..18dc179 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -248,34 +248,57 @@ func _is_condition_shape_valid(condition) -> bool: func _normalized_shop(source) -> Dictionary: var item_ids := [] var seen := {} + var stock := {} if typeof(source) != TYPE_DICTIONARY: - return {"items": item_ids} + return {"items": item_ids, "stock": stock} for entry in source.get("items", []): - var item_id := "" - if typeof(entry) == TYPE_DICTIONARY: - item_id = str(entry.get("id", "")) - else: - item_id = str(entry) + var item_id := _shop_item_id_from_entry(entry) if item_id.is_empty() or seen.has(item_id): continue seen[item_id] = true item_ids.append(item_id) + _apply_shop_stock_entry(stock, item_id, entry) + _apply_shop_stock_block(stock, source.get("stock", {}), seen) for block in source.get("conditional_items", []): if typeof(block) != TYPE_DICTIONARY: continue if not _campaign_flags_match(block.get("campaign_flags", {})): continue for entry in block.get("items", []): - var item_id := "" - if typeof(entry) == TYPE_DICTIONARY: - item_id = str(entry.get("id", "")) - else: - item_id = str(entry) + var item_id := _shop_item_id_from_entry(entry) if item_id.is_empty() or seen.has(item_id): continue seen[item_id] = true item_ids.append(item_id) - return {"items": item_ids} + _apply_shop_stock_entry(stock, item_id, entry) + _apply_shop_stock_block(stock, block.get("stock", {}), seen) + return {"items": item_ids, "stock": stock} + + +func _shop_item_id_from_entry(entry) -> String: + if typeof(entry) == TYPE_DICTIONARY: + return str(entry.get("id", "")) + return str(entry) + + +func _apply_shop_stock_entry(stock: Dictionary, item_id: String, entry) -> void: + if item_id.is_empty() or typeof(entry) != TYPE_DICTIONARY or not entry.has("stock"): + return + var count := int(entry.get("stock", -1)) + if count >= 0: + stock[item_id] = count + + +func _apply_shop_stock_block(stock: Dictionary, block, seen: Dictionary) -> void: + if typeof(block) != TYPE_DICTIONARY: + return + for item_id in block.keys(): + var normalized_id := str(item_id) + if normalized_id.is_empty() or not seen.has(normalized_id): + continue + var count := int(block[item_id]) + if count >= 0: + stock[normalized_id] = count func _normalized_deployment_rules(source) -> Dictionary: @@ -1155,6 +1178,16 @@ func get_shop_item_ids() -> Array: return result +func get_shop_stock_limit(item_id: String) -> int: + var normalized := str(item_id) + if normalized.is_empty(): + return -1 + var stock: Dictionary = shop.get("stock", {}) + if not stock.has(normalized): + return -1 + return int(stock.get(normalized, -1)) + + func get_formation_cells() -> Array[Vector2i]: return formation_cells.duplicate() diff --git a/scripts/core/campaign_state.gd b/scripts/core/campaign_state.gd index ffd9e48..0fce299 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 := 3 +const SAVE_VERSION := 4 var save_version := SAVE_VERSION var campaign_id := "" @@ -26,6 +26,7 @@ var flags := {} var joined_officers: Array[String] = [] var applied_post_battle_choices := {} var pending_post_battle_choice_scenario_id := "" +var shop_purchases := {} func load_campaign(path: String) -> bool: @@ -136,6 +137,7 @@ func start_new(scenario_id: String) -> void: joined_officers.clear() applied_post_battle_choices.clear() pending_post_battle_choice_scenario_id = "" + shop_purchases.clear() for officer_id in initial_joined_officers: joined_officers.append(officer_id) @@ -341,12 +343,31 @@ func get_inventory_snapshot() -> Dictionary: return inventory.duplicate(true) -func try_buy_item(item_id: String, price: int) -> bool: +func get_shop_purchase_count(scenario_id: String, item_id: String) -> int: + var scenario_key := str(scenario_id) + var item_key := str(item_id) + if scenario_key.is_empty() or item_key.is_empty(): + return 0 + if not shop_purchases.has(scenario_key) or typeof(shop_purchases[scenario_key]) != TYPE_DICTIONARY: + return 0 + return int(shop_purchases[scenario_key].get(item_key, 0)) + + +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 + var scenario_key := str(scenario_id) + if stock_limit >= 0 and not scenario_key.is_empty() and get_shop_purchase_count(scenario_key, item_id) >= stock_limit: + return false var previous_count := int(inventory.get(item_id, 0)) + var previous_shop_purchases: Dictionary = shop_purchases.duplicate(true) gold -= price inventory[item_id] = previous_count + 1 + if stock_limit >= 0 and not scenario_key.is_empty(): + if not shop_purchases.has(scenario_key) or typeof(shop_purchases[scenario_key]) != TYPE_DICTIONARY: + shop_purchases[scenario_key] = {} + var scenario_purchases: Dictionary = shop_purchases[scenario_key] + scenario_purchases[item_id] = int(scenario_purchases.get(item_id, 0)) + 1 if save_game(): return true gold += price @@ -354,6 +375,7 @@ func try_buy_item(item_id: String, price: int) -> bool: inventory[item_id] = previous_count else: inventory.erase(item_id) + shop_purchases = previous_shop_purchases return false @@ -795,7 +817,8 @@ func to_dict() -> Dictionary: "flags": flags.duplicate(true), "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 + "pending_post_battle_choice_scenario_id": pending_post_battle_choice_scenario_id, + "shop_purchases": shop_purchases.duplicate(true) } @@ -811,6 +834,7 @@ func _apply_save_data(parsed: Dictionary) -> void: flags = _copy_dictionary(parsed.get("flags", {})) 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", {})) 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 f4a4e52..980065e 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -2662,9 +2662,12 @@ func _rebuild_shop_buy_list() -> void: var item := state.get_item_def(normalized_id) var price := int(item.get("price", 0)) var owned := int(inventory.get(normalized_id, 0)) + var stock_limit := state.get_shop_stock_limit(normalized_id) + var purchased := campaign_state.get_shop_purchase_count(active_scenario_id, normalized_id) + var remaining := stock_limit - purchased if stock_limit >= 0 else -1 var buy_button := Button.new() - buy_button.text = _format_shop_item_button_text(normalized_id, item, price, owned) - buy_button.disabled = campaign_state.gold < price + buy_button.text = _format_shop_item_button_text(normalized_id, item, price, owned, stock_limit, remaining) + buy_button.disabled = campaign_state.gold < price or (stock_limit >= 0 and remaining <= 0) buy_button.pressed.connect(_on_shop_item_pressed.bind(normalized_id)) shop_list.add_child(buy_button) @@ -2695,15 +2698,21 @@ func _clear_shop_list() -> void: child.queue_free() -func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int) -> String: +func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String: var unavailable := "" - if campaign_state.gold < price: + if stock_limit >= 0 and remaining <= 0: + unavailable = " - sold out" + elif campaign_state.gold < price: unavailable = " - need %dG" % (price - campaign_state.gold) - return "%s %dG %s Owned x%d%s" % [ + var stock_text := "" + if stock_limit >= 0: + stock_text = " Stock %d/%d" % [max(0, remaining), stock_limit] + return "%s %dG %s Owned x%d%s%s" % [ _item_display_name(item_id), price, _format_shop_item_effect_text(item), owned, + stock_text, unavailable ] @@ -2743,7 +2752,14 @@ func _on_shop_item_pressed(item_id: String) -> void: var item_name := _item_display_name(item_id) if item.is_empty() or price <= 0: return - if campaign_state.try_buy_item(item_id, price): + var stock_limit := state.get_shop_stock_limit(item_id) + var purchased := campaign_state.get_shop_purchase_count(active_scenario_id, item_id) + if stock_limit >= 0 and purchased >= stock_limit: + _play_ui_cancel() + _on_log_added("%s is sold out." % item_name) + _rebuild_shop_menu() + return + if campaign_state.try_buy_item(item_id, price, active_scenario_id, stock_limit): state.set_inventory_snapshot(campaign_state.get_inventory_snapshot()) _on_log_added("Bought %s for %d gold." % [item_name, price]) else: diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index dc683fc..747ec43 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -802,6 +802,41 @@ function Resolve-Shop-Item-Id($Entry, [string]$Context) { Fail "$Context has a malformed shop item entry." } +function Check-Shop-Stock-Value($Value, [string]$Context) { + $isInteger = ( + $Value -is [byte] -or + $Value -is [int16] -or + $Value -is [int] -or + $Value -is [long] + ) + if (-not $isInteger -or [long]$Value -lt 0) { + Fail "$Context stock must be a non-negative integer." + } +} + +function Check-Shop-Entry-Stock($Entry, [string]$ItemId, [string]$Context) { + if (-not (Has-Prop $Entry "stock")) { + return + } + Check-Shop-Stock-Value (Get-Prop $Entry "stock" $null) "$Context item $ItemId" +} + +function Check-Shop-Stock-Block($Stock, $AllowedItemIds, [string]$Context) { + if ($null -eq $Stock -or $Stock -is [System.Array] -or $Stock -is [string] -or $Stock -is [ValueType]) { + Fail "$Context stock must be an object." + } + foreach ($prop in @($Stock.PSObject.Properties)) { + $itemId = [string]$prop.Name + if ([string]::IsNullOrWhiteSpace($itemId)) { + Fail "$Context stock has an empty item id." + } + if (-not $AllowedItemIds.Contains($itemId)) { + Fail "$Context stock references item outside that shop branch: $itemId" + } + Check-Shop-Stock-Value $prop.Value "$Context stock $itemId" + } +} + function Check-Shop($Shop, [string]$ScenarioId) { if ($null -eq $Shop) { return @@ -831,11 +866,15 @@ function Check-Shop($Shop, [string]$ScenarioId) { if (-not $itemIds.Contains($itemId)) { Fail "Scenario $ScenarioId shop references unknown item: $itemId" } + Check-Shop-Entry-Stock $entry $itemId "Scenario $ScenarioId shop" $item = Find-Item-Def $itemId if ([int](Get-Prop $item "price" 0) -le 0) { Fail "Scenario $ScenarioId shop item $itemId has no positive price." } } + if (Has-Prop $Shop "stock") { + Check-Shop-Stock-Block $Shop.stock $seenShopItems "Scenario $ScenarioId shop" + } $conditionalItems = Get-Prop $Shop "conditional_items" @() foreach ($block in @($conditionalItems)) { @@ -866,11 +905,15 @@ function Check-Shop($Shop, [string]$ScenarioId) { if (-not $itemIds.Contains($itemId)) { Fail "Scenario $ScenarioId shop conditional_items references unknown item: $itemId" } + Check-Shop-Entry-Stock $entry $itemId "Scenario $ScenarioId shop conditional_items" $item = Find-Item-Def $itemId if ([int](Get-Prop $item "price" 0) -le 0) { Fail "Scenario $ScenarioId shop conditional item $itemId has no positive price." } } + if (Has-Prop $block "stock") { + Check-Shop-Stock-Block $block.stock $seenConditionalItems "Scenario $ScenarioId shop conditional_items" + } } }