From f93e99f042a5ee660e9aed466a65f1e2a7243d33 Mon Sep 17 00:00:00 2001 From: daddy Date: Fri, 18 Sep 2026 01:04:36 +0000 Subject: [PATCH] feat(scripting): connect give_item to wallet rewards, add [E] Interact prompt and toast feedback --- backend/app/routers/wallet.py | 35 +++++++++++ backend/tests/test_wallet_api.py | 27 ++++++++ scripts/scripting/script_interpreter.gd | 83 ++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/wallet.py b/backend/app/routers/wallet.py index 1267c0e..02ef527 100644 --- a/backend/app/routers/wallet.py +++ b/backend/app/routers/wallet.py @@ -76,3 +76,38 @@ async def get_wallet_transactions( limit=limit, offset=offset, ) + + +class RewardRequest(BaseModel): + amount: int = Field(..., gt=0, le=10000, description="Amount of Buffalo Chips to reward") + reason: str | None = Field(default="in_game_reward", description="Reward source or reason") + + +class RewardResponse(BaseModel): + success: bool + amount: int + new_balance: int + + +@router.post("/reward", response_model=RewardResponse) +async def reward_chips( + payload: RewardRequest, + current_user: CurrentUser, + db: DbDep, +): + """Credit in-game Buffalo Chips reward directly to the player's wallet.""" + wallet, tx = await wallet_service.credit( + db, + user_id=current_user.id, + amount=payload.amount, + tx_type=TransactionType.admin_adjustment, + reference_type="gameplay_reward", + reference_id=payload.reason or "in_game_reward", + metadata={"reason": payload.reason or "in_game_reward"}, + ) + return RewardResponse( + success=True, + amount=payload.amount, + new_balance=wallet.balance, + ) + diff --git a/backend/tests/test_wallet_api.py b/backend/tests/test_wallet_api.py index 86ae9a3..8168c41 100644 --- a/backend/tests/test_wallet_api.py +++ b/backend/tests/test_wallet_api.py @@ -133,3 +133,30 @@ async def test_wallet_service_credit_and_debit_flow(): # Newest first: debit (-400) first, then credit (+1000) assert tx_data["transactions"][0]["amount"] == -400 assert tx_data["transactions"][1]["amount"] == 1000 + + +def test_wallet_reward_endpoint(): + client = TestClient(app) + uid, headers = _register(client, "wallet_reward_user") + + # Initial balance is 0 + resp = client.get("/api/wallet", headers=headers) + assert resp.status_code == 200 + assert resp.json()["balance"] == 0 + + # Reward 25 Buffalo Chips + reward_resp = client.post( + "/api/wallet/reward", + json={"amount": 25, "reason": "found_chest"}, + headers=headers, + ) + assert reward_resp.status_code == 200 + data = reward_resp.json() + assert data["success"] is True + assert data["amount"] == 25 + assert data["new_balance"] == 25 + + # Check updated wallet + resp = client.get("/api/wallet", headers=headers) + assert resp.json()["balance"] == 25 + diff --git a/scripts/scripting/script_interpreter.gd b/scripts/scripting/script_interpreter.gd index d17bd23..fefe6cc 100644 --- a/scripts/scripting/script_interpreter.gd +++ b/scripts/scripting/script_interpreter.gd @@ -77,6 +77,7 @@ var _timers: Dictionary = {} # name (String) -> {"remaining": float, "active": var _timer_nodes: Array[Timer] = [] var _spawn_catalog: SpawnCatalogClass = null +var _interact_prompt: Label3D = null # --------------------------------------------------------------------------- @@ -106,6 +107,7 @@ func _apply_loaded_behavior() -> void: rule_rejected.emit(msg) _setup_area() _setup_timers() + _setup_prompt() func _setup_area() -> void: @@ -156,6 +158,32 @@ func _setup_timers() -> void: _timer_nodes.append(timer) +func _setup_prompt() -> void: + if _interact_prompt != null and is_instance_valid(_interact_prompt): + _interact_prompt.queue_free() + _interact_prompt = null + if behavior == null: + return + var has_interact_rule := false + for rule in behavior.rules: + if rule.trigger_type == "on_interact": + has_interact_rule = true + break + if has_interact_rule and DisplayServer.get_name() != "headless": + _interact_prompt = Label3D.new() + _interact_prompt.name = "InteractPrompt" + _interact_prompt.text = "[E] Interact" + _interact_prompt.billboard = BaseMaterial3D.BILLBOARD_ENABLED + _interact_prompt.font_size = 32 + _interact_prompt.outline_size = 8 + _interact_prompt.modulate = Color(1.0, 0.88, 0.2) + _interact_prompt.outline_modulate = Color(0.0, 0.0, 0.0, 0.9) + _interact_prompt.no_depth_test = true + _interact_prompt.position = Vector3(0, 1.2, 0) + _interact_prompt.visible = false + add_child(_interact_prompt) + + # --------------------------------------------------------------------------- # Trigger sources # --------------------------------------------------------------------------- @@ -172,6 +200,8 @@ func _on_body_entered(body: Node) -> void: return if not _players_in_range.has(body): _players_in_range.append(body) + if _interact_prompt != null and is_instance_valid(_interact_prompt): + _interact_prompt.visible = true var id := body.get_instance_id() if not _entered_once.has(id): _entered_once[id] = true @@ -181,6 +211,8 @@ func _on_body_entered(body: Node) -> void: func _on_body_exited(body: Node) -> void: _players_in_range.erase(body) _entered_once.erase(body.get_instance_id()) + if _interact_prompt != null and is_instance_valid(_interact_prompt) and _players_in_range.is_empty(): + _interact_prompt.visible = false func _unhandled_input(event: InputEvent) -> void: @@ -356,9 +388,58 @@ func _act_give_item(action: Dictionary, player: Node) -> void: if inv == null: push_warning("ScriptInterpreterNode(%s): give_item has no player Inventory to target" % name) return - inv.give_item(action.get("item_id", ""), int(action.get("count", 0))) + var item_id: String = str(action.get("item_id", "")) + var count: int = int(action.get("count", 0)) + inv.give_item(item_id, count) verb_executed.emit("give_item", action) + _show_give_feedback(item_id, count) + if item_id.to_lower() in ["buffalo_chips", "chips", "credits"]: + _deposit_to_wallet(count) + + +func _show_give_feedback(item_id: String, count: int) -> void: + if DisplayServer.get_name() == "headless": + return + var text := "" + if item_id.to_lower() in ["buffalo_chips", "chips", "credits"]: + text = "+%d Buffalo Chips!" % count + else: + text = "+%d %s" % [count, item_id] + + var toast := Label3D.new() + toast.text = text + toast.billboard = BaseMaterial3D.BILLBOARD_ENABLED + toast.font_size = 40 + toast.outline_size = 10 + toast.modulate = Color(1.0, 0.9, 0.2) + toast.outline_modulate = Color(0.1, 0.05, 0.0, 1.0) + toast.no_depth_test = true + toast.position = Vector3(0, 1.5, 0) + add_child(toast) + + var tween := create_tween() + if tween != null: + tween.tween_property(toast, "position:y", toast.position.y + 1.2, 1.4) + tween.parallel().tween_property(toast, "modulate:a", 0.0, 1.4) + tween.tween_callback(toast.queue_free) + + +func _deposit_to_wallet(count: int) -> void: + if count <= 0: + return + if has_node("/root/ApiClient"): + var api = get_node("/root/ApiClient") + if api != null and api.has_token(): + api.request( + HTTPClient.METHOD_POST, + "/api/wallet/reward", + JSON.stringify({"amount": count, "reason": "in_game_reward"}), + "application/json", + true + ) + + func _act_play_sound(action: Dictionary, _player: Node) -> void: # No real audio asset catalog exists in this repo yet. The verb still