feat(economy): add vanity shop and harden economy money-safety
Vanity shop (/vanity): cosmetic badges/titles as a pure whale coin sink - purchases burn coins (not credited to the house) and equip a badge shown on /profile. New vanity_owned/vanity_active fields, schema sync, and tests. Money-safety and robustness fixes from a codebase review: - _parse_amount now rejects negative amounts. Every bet/give/request flows through it, so a negative value can no longer mint coins on a loss/transfer path that trusts the caller's sign (all call sites already guarded <= 0; this closes the source). - do_blackjack_payout no longer raises on a DB failure. The stake was already deducted in do_blackjack_bet, so it now logs critical with the owed amount (for admin reconciliation) and returns db_error; all payout call sites render a clear "payout failed" notice instead of crashing the interaction. - Instant "kohv" consumable now cancels the pending reminder DMs for the cooldowns it wipes (via new INSTANT_RESET_COMMANDS), so no stale/duplicate reminders fire. - Renamed the misleadingly-named _refund_user_safe -> _debit_house_safe (it debits the house) and dropped its ignored first arg. - Added __all__ to vanity.py and consumables.py so `import *` no longer leaks incidental imports into the economy namespace. - Documented Kõrvaklapid's +25 coin daily bonus in README and DEV_NOTES. Tests: blackjack payout DB-failure safety and INSTANT_RESET_COMMANDS lockstep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
This commit is contained in:
@@ -245,7 +245,7 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it
|
|||||||
|
|
||||||
| Command | Cooldown | Base payout | Notes |
|
| Command | Cooldown | Base payout | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h and adds +25 ⬡. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
||||||
| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
||||||
| `/beg` | 5min | 10–40 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
|
| `/beg` | 5min | 10–40 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
|
||||||
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. |
|
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. |
|
||||||
@@ -424,7 +424,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
|
|||||||
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
||||||
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
|
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
|
||||||
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
|
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
|
||||||
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
|
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h and +25 ⬡ bonus |
|
||||||
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
||||||
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
||||||
|
|
||||||
|
|||||||
11
bot.py
11
bot.py
@@ -688,8 +688,10 @@ register_prestige_commands(
|
|||||||
|
|
||||||
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
||||||
"""Parse an amount string; 'all' resolves to the user's full balance.
|
"""Parse an amount string; 'all' resolves to the user's full balance.
|
||||||
Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000).
|
Accepts plain non-negative integers and valid thousand-separated numbers
|
||||||
Rejects decimals and ambiguous inputs like 1,1 or 1.5.
|
(1,000 / 1.000 / 1 000). Rejects decimals, ambiguous inputs like 1,1 or 1.5,
|
||||||
|
and negative amounts (a negative bet/give would mint coins on loss/transfer
|
||||||
|
paths that trust the caller's sign).
|
||||||
Returns (amount, None) on success or (None, error_msg) on failure."""
|
Returns (amount, None) on success or (None, error_msg) on failure."""
|
||||||
v = value.strip()
|
v = value.strip()
|
||||||
if v.lower() == "all":
|
if v.lower() == "all":
|
||||||
@@ -698,9 +700,12 @@ def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
|||||||
if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v):
|
if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v):
|
||||||
v = re.sub(r'[,. ]', '', v)
|
v = re.sub(r'[,. ]', '', v)
|
||||||
try:
|
try:
|
||||||
return int(v), None
|
amount = int(v)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None, S.ERR["invalid_amount"]
|
return None, S.ERR["invalid_amount"]
|
||||||
|
if amount < 0:
|
||||||
|
return None, S.ERR["invalid_amount"]
|
||||||
|
return amount, None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -926,13 +926,17 @@ def register_economy_games_commands(
|
|||||||
embed = self._cur_embed(game_over=True, hand_results=hand_results)
|
embed = self._cur_embed(game_over=True, hand_results=hand_results)
|
||||||
embed.title = S.TITLE[title_key]
|
embed.title = S.TITLE[title_key]
|
||||||
embed.color = color
|
embed.color = color
|
||||||
|
if res.get("ok"):
|
||||||
|
result_line = result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"]))
|
||||||
|
else:
|
||||||
|
result_line = result_str + "\n" + S.ERR["payout_failed"]
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=S.BJ["result_field"],
|
name=S.BJ["result_field"],
|
||||||
value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])),
|
value=result_line,
|
||||||
inline=False,
|
inline=False,
|
||||||
)
|
)
|
||||||
await self.message.edit(embed=embed, view=self)
|
await self.message.edit(embed=embed, view=self)
|
||||||
if total_payout > total_invested:
|
if res.get("ok") and total_payout > total_invested:
|
||||||
asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested)))
|
asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested)))
|
||||||
|
|
||||||
async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None:
|
async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None:
|
||||||
@@ -1150,32 +1154,37 @@ def register_economy_games_commands(
|
|||||||
await asyncio.sleep(_BJ_DEAL_DELAY)
|
await asyncio.sleep(_BJ_DEAL_DELAY)
|
||||||
if _bj_is_blackjack(dealer_hand):
|
if _bj_is_blackjack(dealer_hand):
|
||||||
push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet)
|
push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet)
|
||||||
|
push_line = (
|
||||||
|
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"]))
|
||||||
|
if push_res.get("ok")
|
||||||
|
else S.BJ["push_result"] + "\n" + S.ERR["payout_failed"]
|
||||||
|
)
|
||||||
embed = _bj_embed(
|
embed = _bj_embed(
|
||||||
player_hand,
|
player_hand,
|
||||||
dealer_hand,
|
dealer_hand,
|
||||||
S.TITLE["blackjack_push"],
|
S.TITLE["blackjack_push"],
|
||||||
0x99AAB5,
|
0x99AAB5,
|
||||||
hide_dealer=False,
|
hide_dealer=False,
|
||||||
result_field=(
|
result_field=(S.BJ["result_field"], push_line),
|
||||||
S.BJ["result_field"],
|
|
||||||
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
payout = bet + int(bet * 1.5)
|
payout = bet + int(bet * 1.5)
|
||||||
bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet)
|
bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet)
|
||||||
|
bj_line = (
|
||||||
|
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"]))
|
||||||
|
if bj_res.get("ok")
|
||||||
|
else f"+{coin(payout)}" + "\n" + S.ERR["payout_failed"]
|
||||||
|
)
|
||||||
embed = _bj_embed(
|
embed = _bj_embed(
|
||||||
player_hand,
|
player_hand,
|
||||||
dealer_hand,
|
dealer_hand,
|
||||||
S.TITLE["blackjack_bj"],
|
S.TITLE["blackjack_bj"],
|
||||||
0xF4C430,
|
0xF4C430,
|
||||||
hide_dealer=False,
|
hide_dealer=False,
|
||||||
result_field=(
|
result_field=(S.BJ["result_field"], bj_line),
|
||||||
S.BJ["result_field"],
|
|
||||||
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet)))
|
if bj_res.get("ok"):
|
||||||
|
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet)))
|
||||||
active_games.discard(interaction.user.id)
|
active_games.discard(interaction.user.id)
|
||||||
await msg.edit(embed=embed)
|
await msg.edit(embed=embed)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -29,10 +29,15 @@ def register_economy_profile_commands(
|
|||||||
pct = progress / needed if needed > 0 else 1.0
|
pct = progress / needed if needed > 0 else 1.0
|
||||||
filled = int(pct * 12)
|
filled = int(pct * 12)
|
||||||
bar = "█" * filled + "░" * (12 - filled)
|
bar = "█" * filled + "░" * (12 - filled)
|
||||||
embed = discord.Embed(
|
badge = economy.vanity_badge(data)
|
||||||
title=S.PROFILE_UI["main_title"].format(name=target.display_name),
|
title = (
|
||||||
color=0xF4C430,
|
f"{badge[0]} {target.display_name}"
|
||||||
|
if badge
|
||||||
|
else S.PROFILE_UI["main_title"].format(name=target.display_name)
|
||||||
)
|
)
|
||||||
|
embed = discord.Embed(title=title, color=0xF4C430)
|
||||||
|
if badge:
|
||||||
|
embed.set_author(name=badge[1])
|
||||||
embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True)
|
embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=S.PROFILE_UI["f_level"],
|
name=S.PROFILE_UI["f_level"],
|
||||||
|
|||||||
@@ -224,6 +224,10 @@ def register_economy_support_commands(
|
|||||||
|
|
||||||
cons = res["consumable"]
|
cons = res["consumable"]
|
||||||
if res["instant"]:
|
if res["instant"]:
|
||||||
|
# Kohv wiped these cooldowns, so any reminder DM already scheduled for
|
||||||
|
# them is now stale - cancel it (the next command run reschedules).
|
||||||
|
for cmd in economy.INSTANT_RESET_COMMANDS:
|
||||||
|
cancel_reminder_task(interaction.user.id, cmd)
|
||||||
desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"]))
|
desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"]))
|
||||||
else:
|
else:
|
||||||
key = "bought_extended" if res["extended"] else "bought_buff"
|
key = "bought_extended" if res["extended"] else "bought_buff"
|
||||||
@@ -238,6 +242,75 @@ def register_economy_support_commands(
|
|||||||
)
|
)
|
||||||
await interaction.response.send_message(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
|
|
||||||
|
# -- /vanity ------------------------------------------------------------
|
||||||
|
def _vanity_embed(user_data: dict) -> discord.Embed:
|
||||||
|
owned = set(user_data.get("vanity_owned") or [])
|
||||||
|
active = user_data.get("vanity_active")
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.VANITY_UI["title"],
|
||||||
|
description=S.VANITY_UI["desc"].format(bal=coin(user_data.get("balance", 0))),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
for vid, v in economy.VANITY.items():
|
||||||
|
if vid == active:
|
||||||
|
status = S.VANITY_UI["line_active"]
|
||||||
|
elif vid in owned:
|
||||||
|
status = S.VANITY_UI["line_owned"]
|
||||||
|
else:
|
||||||
|
status = f"{v['cost']} {economy.COIN}"
|
||||||
|
embed.add_field(
|
||||||
|
name=f"{v['emoji']} {v['name']} · {status}",
|
||||||
|
value=S.VANITY_UI["entry_title"].format(title=v["title"]),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
embed.set_footer(text=S.VANITY_UI["footer"])
|
||||||
|
return embed
|
||||||
|
|
||||||
|
@tree.command(name="vanity", description=S.CMD["vanity"])
|
||||||
|
@app_commands.describe(ese=S.OPT["vanity_ese"])
|
||||||
|
@app_commands.choices(
|
||||||
|
ese=[
|
||||||
|
app_commands.Choice(name=f"{v['emoji']} {v['title']} ({v['cost']} TipiCOINi)", value=vid)
|
||||||
|
for vid, v in economy.VANITY.items()
|
||||||
|
]
|
||||||
|
+ [app_commands.Choice(name=S.VANITY_UI["none_choice"], value=economy.vanity.NONE_ID)]
|
||||||
|
)
|
||||||
|
async def cmd_vanity(
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
ese: app_commands.Choice[str] | None = None,
|
||||||
|
):
|
||||||
|
if ese is None:
|
||||||
|
data = await economy.get_user(interaction.user.id)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
embed=_vanity_embed(data), ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
res = await economy.do_vanity_select(interaction.user.id, ese.value)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
elif res["reason"] == "insufficient":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
action = res["action"]
|
||||||
|
if action == "unequipped":
|
||||||
|
await interaction.response.send_message(S.VANITY_UI["unequipped"], ephemeral=True)
|
||||||
|
return
|
||||||
|
v = res["vanity"]
|
||||||
|
if action == "bought":
|
||||||
|
msg = S.VANITY_UI["bought"].format(
|
||||||
|
emoji=v["emoji"], title=v["title"], balance=coin(res["balance"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"])
|
||||||
|
await interaction.response.send_message(msg)
|
||||||
|
|
||||||
class RemindersSelect(discord.ui.Select):
|
class RemindersSelect(discord.ui.Select):
|
||||||
def __init__(self, user_id: int, current: list[str]):
|
def __init__(self, user_id: int, current: list[str]):
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .house import _credit_house, _house_record_id
|
|||||||
from .levels import *
|
from .levels import *
|
||||||
from .shop import *
|
from .shop import *
|
||||||
from .consumables import *
|
from .consumables import *
|
||||||
|
from .vanity import *
|
||||||
from .fishing import *
|
from .fishing import *
|
||||||
from .quests import *
|
from .quests import *
|
||||||
from .quests import _ensure_quests, _pick_quests, _quest_view
|
from .quests import _ensure_quests, _pick_quests, _quest_view
|
||||||
@@ -32,5 +33,5 @@ from .admin import *
|
|||||||
|
|
||||||
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
||||||
admin, consumables, fishing, gambling, heist, house, income, jail,
|
admin, consumables, fishing, gambling, heist, house, income, jail,
|
||||||
leaderboards, levels, prestige, quests, shop, store,
|
leaderboards, levels, prestige, quests, shop, store, vanity,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ from ..pb_client import DatabaseError
|
|||||||
from ..emoji import EMOJI as E
|
from ..emoji import EMOJI as E
|
||||||
from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user
|
from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Consumable",
|
||||||
|
"CONSUMABLES",
|
||||||
|
"INSTANT_RESET_COMMANDS",
|
||||||
|
"active_buffs",
|
||||||
|
"buff_remaining",
|
||||||
|
"earn_mult",
|
||||||
|
"exp_buff_mult",
|
||||||
|
"do_buy_consumable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Consumable(TypedDict):
|
class Consumable(TypedDict):
|
||||||
name: str
|
name: str
|
||||||
@@ -66,6 +77,11 @@ _BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0}
|
|||||||
# Cooldown timestamps an instant "kohv" clears.
|
# Cooldown timestamps an instant "kohv" clears.
|
||||||
_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish")
|
_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish")
|
||||||
|
|
||||||
|
# The slash-command names whose cooldowns an instant "kohv" clears. The Discord
|
||||||
|
# layer uses these to cancel any pending reminder DMs, since the cooldowns they
|
||||||
|
# were scheduled for no longer exist.
|
||||||
|
INSTANT_RESET_COMMANDS = tuple(f.removeprefix("last_") for f in _COOLDOWN_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Buff inspection helpers (pure - safe to call while holding a user lock)
|
# Buff inspection helpers (pure - safe to call while holding a user lock)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import random
|
|||||||
|
|
||||||
from ..pb_client import DatabaseError
|
from ..pb_client import DatabaseError
|
||||||
from ..emoji import EMOJI as E
|
from ..emoji import EMOJI as E
|
||||||
from .store import _commit, _is_jailed, _locked_by, _txn, get_user
|
from .store import _commit, _is_jailed, _locked_by, _log, _txn, get_user
|
||||||
from .house import _credit_house
|
from .house import _credit_house
|
||||||
|
|
||||||
|
|
||||||
@@ -254,8 +254,15 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
|||||||
|
|
||||||
@_locked_by(0)
|
@_locked_by(0)
|
||||||
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
|
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
|
||||||
"""Credit the net payout. House receives the difference when payout < total_invested."""
|
"""Credit the net payout. House receives the difference when payout < total_invested.
|
||||||
user = await get_user(user_id)
|
|
||||||
|
The stake was already deducted in do_blackjack_bet, so a DB failure here must
|
||||||
|
not raise through the interaction handler and swallow the player's winnings:
|
||||||
|
report db_error like every other mutation so the caller can surface it."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
user["balance"] += payout
|
user["balance"] += payout
|
||||||
user["balance"] = max(0, user["balance"])
|
user["balance"] = max(0, user["balance"])
|
||||||
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
|
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
|
||||||
@@ -267,7 +274,17 @@ async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0
|
|||||||
elif net < 0:
|
elif net < 0:
|
||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
|
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
|
||||||
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
|
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
|
||||||
await _commit(user_id, user)
|
try:
|
||||||
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
# The stake is already gone (deducted in do_blackjack_bet) and this credit
|
||||||
|
# did not persist. Nothing to roll back locally - log the owed amount so an
|
||||||
|
# admin can reconcile with /admincoins.
|
||||||
|
_log.critical(
|
||||||
|
"blackjack payout commit failed for %s: owed payout=%s (invested=%s)",
|
||||||
|
user_id, payout, total_invested,
|
||||||
|
)
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
house_gain = total_invested - payout
|
house_gain = total_invested - payout
|
||||||
if house_gain > 0:
|
if house_gain > 0:
|
||||||
await _credit_house(house_gain)
|
await _credit_house(house_gain)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from .. import pb_client
|
|||||||
from ..pb_client import DatabaseError
|
from ..pb_client import DatabaseError
|
||||||
from . import house
|
from . import house
|
||||||
from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user
|
from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user
|
||||||
from .house import _credit_house, _refund_house_safe, _refund_user_safe
|
from .house import _credit_house, _refund_house_safe, _debit_house_safe
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -98,6 +98,6 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
|||||||
if success and payout_each > 0:
|
if success and payout_each > 0:
|
||||||
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||||
elif not success and fine_credited:
|
elif not success and fine_credited:
|
||||||
await _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
await _debit_house_safe(fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||||
|
|
||||||
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
|
async def _debit_house_safe(amount: int, context: str, uid: int) -> None:
|
||||||
"""Best-effort atomic debit of `amount` from the house (compensates a failed
|
"""Best-effort atomic debit of `amount` from the house. Compensates a fine
|
||||||
user fine). Logs critical if it fails."""
|
that was credited to the house but whose matching user debit failed to persist
|
||||||
|
(the coins must be pulled back out of the house). Logs critical if it fails."""
|
||||||
if HOUSE_ID is None or amount <= 0:
|
if HOUSE_ID is None or amount <= 0:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ class UserData(TypedDict, total=False):
|
|||||||
items: list[str]
|
items: list[str]
|
||||||
item_uses: dict # {item_id: remaining_uses} for consumables
|
item_uses: dict # {item_id: remaining_uses} for consumables
|
||||||
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts
|
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts
|
||||||
|
vanity_owned: list[str] # cosmetic badge ids the user has purchased
|
||||||
|
vanity_active: str | None # currently-equipped vanity badge id (shown on /profile)
|
||||||
jailed_until: str | None # ISO datetime or None
|
jailed_until: str | None # ISO datetime or None
|
||||||
jailbreak_used: bool
|
jailbreak_used: bool
|
||||||
reminders: list[str] # command names user wants DM reminders for
|
reminders: list[str] # command names user wants DM reminders for
|
||||||
@@ -161,6 +163,8 @@ def _default_user() -> UserData:
|
|||||||
"items": [],
|
"items": [],
|
||||||
"item_uses": {},
|
"item_uses": {},
|
||||||
"active_buffs": {},
|
"active_buffs": {},
|
||||||
|
"vanity_owned": [],
|
||||||
|
"vanity_active": None,
|
||||||
"jailed_until": None,
|
"jailed_until": None,
|
||||||
"jailbreak_used": False,
|
"jailbreak_used": False,
|
||||||
"reminders": ["daily", "work", "beg", "crime", "rob"],
|
"reminders": ["daily", "work", "beg", "crime", "rob"],
|
||||||
|
|||||||
98
core/economy/vanity.py
Normal file
98
core/economy/vanity.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Vanity shop: cosmetic badges/titles - a pure status sink for the wealthy.
|
||||||
|
|
||||||
|
No gameplay effect whatsoever. Buying burns the coins (they are NOT credited
|
||||||
|
to the house, so they leave circulation for good) and unlocks a badge the
|
||||||
|
player can equip; the equipped badge shows on /profile. Prices scale steeply
|
||||||
|
on purpose - this is where the richest players dump coins for bragging rights,
|
||||||
|
draining the top end of the economy where inflation hurts most.
|
||||||
|
|
||||||
|
A single entry point, do_vanity_select, handles buy / equip / unequip so the
|
||||||
|
whole feature fits one slash command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from ..pb_client import DatabaseError
|
||||||
|
from .store import _commit, _locked_by, _txn, get_user
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Vanity",
|
||||||
|
"VANITY",
|
||||||
|
"NONE_ID",
|
||||||
|
"vanity_badge",
|
||||||
|
"do_vanity_select",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Vanity(TypedDict):
|
||||||
|
name: str # short shop name
|
||||||
|
emoji: str # the badge shown next to the player
|
||||||
|
title: str # the flavour title shown on /profile
|
||||||
|
cost: int
|
||||||
|
|
||||||
|
|
||||||
|
# Ordered cheapest -> priciest: a LAN/gaming status ladder from casual couch
|
||||||
|
# gamer to LAN legend. The top rungs are the deliberate whale sink.
|
||||||
|
VANITY: dict[str, Vanity] = {
|
||||||
|
"couch": {"name": "Sohvapadi", "emoji": "🎮", "title": "Sohvasõdur", "cost": 5_000},
|
||||||
|
"cables": {"name": "Võrgukaabel", "emoji": "🔌", "title": "Kaablihaldur", "cost": 8_000},
|
||||||
|
"discord": {"name": "Peakomplekt", "emoji": "🎧", "title": "Discordi Admin", "cost": 12_000},
|
||||||
|
"aimbot": {"name": "Kahtlane Hiir", "emoji": "🖱️", "title": "Aimbot Kahtlusalune", "cost": 18_000},
|
||||||
|
"sniper": {"name": "360Hz Monitor", "emoji": "🎯", "title": "Snaipripüss", "cost": 25_000},
|
||||||
|
"champ": {"name": "Meistrikarikas", "emoji": "🏆", "title": "Turniirivõitja", "cost": 40_000},
|
||||||
|
"legend": {"name": "Mängurijaam", "emoji": "🖥️", "title": "LAN Legend", "cost": 75_000},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sentinel choice value that unequips the active badge.
|
||||||
|
NONE_ID = "none"
|
||||||
|
|
||||||
|
|
||||||
|
def vanity_badge(user) -> tuple[str, str] | None:
|
||||||
|
"""Return (emoji, title) for the user's equipped badge, or None."""
|
||||||
|
vid = user.get("vanity_active")
|
||||||
|
vanity = VANITY.get(vid) if vid else None
|
||||||
|
return (vanity["emoji"], vanity["title"]) if vanity else None
|
||||||
|
|
||||||
|
|
||||||
|
@_locked_by(0)
|
||||||
|
async def do_vanity_select(user_id: int, vanity_id: str) -> dict:
|
||||||
|
"""Buy (if unowned), equip (if owned), or unequip (NONE_ID) a badge."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
if user.get("eco_banned"):
|
||||||
|
return {"ok": False, "reason": "banned"}
|
||||||
|
|
||||||
|
if vanity_id == NONE_ID:
|
||||||
|
user["vanity_active"] = None
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn("VANITY_UNEQUIP", user=user_id)
|
||||||
|
return {"ok": True, "action": "unequipped"}
|
||||||
|
|
||||||
|
if vanity_id not in VANITY:
|
||||||
|
return {"ok": False, "reason": "not_found"}
|
||||||
|
|
||||||
|
owned = list(user.get("vanity_owned") or [])
|
||||||
|
vanity = VANITY[vanity_id]
|
||||||
|
|
||||||
|
# Already owned -> just equip it (free).
|
||||||
|
if vanity_id in owned:
|
||||||
|
user["vanity_active"] = vanity_id
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn("VANITY_EQUIP", user=user_id, item=vanity_id)
|
||||||
|
return {"ok": True, "action": "equipped", "vanity": vanity}
|
||||||
|
|
||||||
|
# Not owned -> purchase (burns the coins) and auto-equip.
|
||||||
|
if user["balance"] < vanity["cost"]:
|
||||||
|
return {"ok": False, "reason": "insufficient", "need": vanity["cost"] - user["balance"]}
|
||||||
|
|
||||||
|
user["balance"] -= vanity["cost"] # burned: not credited to the house
|
||||||
|
owned.append(vanity_id)
|
||||||
|
user["vanity_owned"] = owned
|
||||||
|
user["vanity_active"] = vanity_id
|
||||||
|
await _commit(user_id, user)
|
||||||
|
_txn("VANITY_BUY", user=user_id, item=vanity_id, cost=f"-{vanity['cost']}", bal=user["balance"])
|
||||||
|
return {"ok": True, "action": "bought", "vanity": vanity, "balance": user["balance"]}
|
||||||
@@ -116,7 +116,7 @@ All economy state is stored in **PocketBase** (`economy_users` collection). `cor
|
|||||||
|
|
||||||
| Command | Cooldown | Base Earn | Notes |
|
| Command | Cooldown | Base Earn | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop |
|
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +25⬡ w/ korvaklapid, +5% interest w/ gaming_laptop |
|
||||||
| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook |
|
| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook |
|
||||||
| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur |
|
| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur |
|
||||||
| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail |
|
| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail |
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ from core import economy # noqa: E402
|
|||||||
PB_URL = config.PB_URL
|
PB_URL = config.PB_URL
|
||||||
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
||||||
|
|
||||||
# _default_user() keys whose default is None, all ISO date/datetime strings
|
# _default_user() keys with a None/str default that PocketBase stores as text
|
||||||
|
# (ISO date/datetime strings, plus the vanity badge id).
|
||||||
_TEXT_FIELDS = {
|
_TEXT_FIELDS = {
|
||||||
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
||||||
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
||||||
|
"vanity_active",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from .economy import (
|
|||||||
ITEM_DESCRIPTIONS,
|
ITEM_DESCRIPTIONS,
|
||||||
CONSUMABLES_UI,
|
CONSUMABLES_UI,
|
||||||
CONSUMABLE_DESCRIPTIONS,
|
CONSUMABLE_DESCRIPTIONS,
|
||||||
|
VANITY_UI,
|
||||||
JAILED_UI,
|
JAILED_UI,
|
||||||
SHOP_BTN,
|
SHOP_BTN,
|
||||||
DAILY_UI,
|
DAILY_UI,
|
||||||
@@ -150,6 +151,7 @@ __all__ = [
|
|||||||
'ITEM_DESCRIPTIONS',
|
'ITEM_DESCRIPTIONS',
|
||||||
'CONSUMABLES_UI',
|
'CONSUMABLES_UI',
|
||||||
'CONSUMABLE_DESCRIPTIONS',
|
'CONSUMABLE_DESCRIPTIONS',
|
||||||
|
'VANITY_UI',
|
||||||
'JAILED_UI',
|
'JAILED_UI',
|
||||||
'SHOP_BTN',
|
'SHOP_BTN',
|
||||||
'DAILY_UI',
|
'DAILY_UI',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ CMD: dict[str, str] = {
|
|||||||
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
|
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
|
||||||
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
|
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
|
||||||
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
|
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
|
||||||
|
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -92,6 +93,7 @@ OPT: dict[str, str] = {
|
|||||||
"give_summa": "Kui palju annad? ('all' = kogu saldo)",
|
"give_summa": "Kui palju annad? ('all' = kogu saldo)",
|
||||||
"buy_ese": "Eseme nimi (vaata /shop)",
|
"buy_ese": "Eseme nimi (vaata /shop)",
|
||||||
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
|
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
|
||||||
|
"vanity_ese": "Tiitel, mida osta või kanda (tühjaks jättes näeb poodi)",
|
||||||
"rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
|
"rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
|
||||||
"rps_vastane": "Väljakutse teisele mängijale (PvP)",
|
"rps_vastane": "Väljakutse teisele mängijale (PvP)",
|
||||||
"slots_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
"slots_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ ERR: dict[str, str] = {
|
|||||||
"guild_only": "Seda käsku saab kasutada ainult serveris.",
|
"guild_only": "Seda käsku saab kasutada ainult serveris.",
|
||||||
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
||||||
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
|
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
|
||||||
|
"payout_failed": "⚠️ Tehniline viga võidu väljamaksmisel - see on logitud ja admin taastab su TipiCOINid. Vabandame!",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ __all__ = [
|
|||||||
'ITEM_DESCRIPTIONS',
|
'ITEM_DESCRIPTIONS',
|
||||||
'CONSUMABLES_UI',
|
'CONSUMABLES_UI',
|
||||||
'CONSUMABLE_DESCRIPTIONS',
|
'CONSUMABLE_DESCRIPTIONS',
|
||||||
|
'VANITY_UI',
|
||||||
'JAILED_UI',
|
'JAILED_UI',
|
||||||
'SHOP_BTN',
|
'SHOP_BTN',
|
||||||
'DAILY_UI',
|
'DAILY_UI',
|
||||||
@@ -240,6 +241,22 @@ CONSUMABLES_UI: dict[str, str] = {
|
|||||||
"bought_instant": "☕ Kõik ooteajad nullitud!\nUus saldo: {balance}",
|
"bought_instant": "☕ Kõik ooteajad nullitud!\nUus saldo: {balance}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Vanity shop (cosmetic badges/titles - a pure status sink, no gameplay effect)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
VANITY_UI: dict[str, str] = {
|
||||||
|
"title": "👑 Staatusepood",
|
||||||
|
"desc": "Puhtalt uhkuse pärast - märgid ja tiitlid ilma igasuguse mänguefektita. Kantud märk paistab su `/profile`-l.\nSaldo: {bal}",
|
||||||
|
"line_owned": "✅ Olemas",
|
||||||
|
"line_active": "⭐ Kantud",
|
||||||
|
"entry_title": "„{title}“",
|
||||||
|
"footer": "Osta või kanna: /vanity <märk> · „Eemalda märk“ võtab tiitli maha",
|
||||||
|
"none_choice": "❌ Eemalda märk",
|
||||||
|
"bought": "{emoji} Ostsid tiitli **{title}** ja panid selle kohe kandma!\nUus saldo: {balance}",
|
||||||
|
"equipped": "{emoji} Kannad nüüd tiitlit **{title}**.",
|
||||||
|
"unequipped": "Märk eemaldatud - su profiil on jälle tavaline.",
|
||||||
|
}
|
||||||
|
|
||||||
JAILED_UI: dict[str, str] = {
|
JAILED_UI: dict[str, str] = {
|
||||||
"title": "🔒 Praegu vanglas",
|
"title": "🔒 Praegu vanglas",
|
||||||
"empty": "Kõik on vabad! Vanglas pole kedagi.",
|
"empty": "Kõik on vabad! Vanglas pole kedagi.",
|
||||||
|
|||||||
@@ -100,3 +100,10 @@ class TestEffects:
|
|||||||
user = run(economy.get_user(UID))
|
user = run(economy.get_user(UID))
|
||||||
assert user["last_work"] is None # cooldown wiped -> /work is ready
|
assert user["last_work"] is None # cooldown wiped -> /work is ready
|
||||||
assert economy.store._cooldown_remaining(user, "work") is None
|
assert economy.store._cooldown_remaining(user, "work") is None
|
||||||
|
|
||||||
|
def test_instant_reset_commands_match_cleared_cooldowns(self):
|
||||||
|
# The Discord layer cancels reminder DMs for exactly these commands after a
|
||||||
|
# kohv, so the list must stay in lockstep with the cooldown fields it wipes.
|
||||||
|
expected = tuple(f.removeprefix("last_") for f in economy.consumables._COOLDOWN_FIELDS)
|
||||||
|
assert economy.INSTANT_RESET_COMMANDS == expected
|
||||||
|
assert economy.INSTANT_RESET_COMMANDS == ("work", "beg", "crime", "rob", "fish")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Covers the two pure-logic fixes:
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from core import economy
|
from core import economy
|
||||||
|
from core.pb_client import DatabaseError
|
||||||
|
|
||||||
from conftest import run
|
from conftest import run
|
||||||
|
|
||||||
@@ -89,3 +90,26 @@ class TestBailIdempotency:
|
|||||||
self._jail(fake_pb, now, balance=1000)
|
self._jail(fake_pb, now, balance=1000)
|
||||||
res = run(economy.do_bail(UID))
|
res = run(economy.do_bail(UID))
|
||||||
assert res["ok"] and res["fine"] >= economy.MIN_BAIL
|
assert res["ok"] and res["fine"] >= economy.MIN_BAIL
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlackjackPayoutSafety:
|
||||||
|
"""do_blackjack_payout must not raise on a DB failure - the stake was already
|
||||||
|
deducted in do_blackjack_bet, so a raised exception would blow up the
|
||||||
|
interaction handler and swallow the outcome. It reports db_error instead."""
|
||||||
|
|
||||||
|
async def _boom(self, *args, **kwargs):
|
||||||
|
raise DatabaseError("simulated PocketBase outage")
|
||||||
|
|
||||||
|
def test_payout_returns_db_error_when_read_fails(self, fake_pb, monkeypatch):
|
||||||
|
monkeypatch.setattr(economy.gambling, "get_user", self._boom)
|
||||||
|
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||||
|
assert res == {"ok": False, "reason": "db_error"}
|
||||||
|
|
||||||
|
def test_payout_returns_db_error_when_commit_fails(self, fake_pb, monkeypatch):
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
fake_pb.record_for(UID)["balance"] = 500
|
||||||
|
monkeypatch.setattr(economy.gambling, "_commit", self._boom)
|
||||||
|
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||||
|
assert res == {"ok": False, "reason": "db_error"}
|
||||||
|
# The failed credit did not persist; balance is untouched (no partial win).
|
||||||
|
assert fake_pb.record_for(UID)["balance"] == 500
|
||||||
|
|||||||
78
tests/test_vanity.py
Normal file
78
tests/test_vanity.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for the vanity shop (cosmetic status sink, no gameplay effect)."""
|
||||||
|
|
||||||
|
from core import economy
|
||||||
|
|
||||||
|
from conftest import run
|
||||||
|
|
||||||
|
UID = 7777
|
||||||
|
|
||||||
|
|
||||||
|
def _fund(fake_pb, amount: int) -> None:
|
||||||
|
run(economy.get_user(UID))
|
||||||
|
fake_pb.record_for(UID)["balance"] = amount
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuy:
|
||||||
|
def test_buy_burns_coins_owns_and_equips(self, fake_pb):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
cost = economy.VANITY["couch"]["cost"]
|
||||||
|
res = run(economy.do_vanity_select(UID, "couch"))
|
||||||
|
assert res["ok"] and res["action"] == "bought"
|
||||||
|
assert res["balance"] == 10_000 - cost
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert "couch" in user["vanity_owned"]
|
||||||
|
assert user["vanity_active"] == "couch"
|
||||||
|
assert economy.vanity_badge(user) == ("🎮", "Sohvasõdur")
|
||||||
|
|
||||||
|
def test_coins_are_destroyed_not_sent_to_house(self, fake_pb, monkeypatch):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
credited = []
|
||||||
|
monkeypatch.setattr(economy.house, "_credit_house", lambda amt: credited.append(amt))
|
||||||
|
run(economy.do_vanity_select(UID, "couch"))
|
||||||
|
assert credited == [] # nothing recirculated to the house
|
||||||
|
|
||||||
|
def test_insufficient_funds_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, 100)
|
||||||
|
res = run(economy.do_vanity_select(UID, "legend"))
|
||||||
|
assert not res["ok"] and res["reason"] == "insufficient"
|
||||||
|
assert res["need"] == economy.VANITY["legend"]["cost"] - 100
|
||||||
|
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
|
||||||
|
|
||||||
|
def test_banned_rejected(self, fake_pb):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
fake_pb.record_for(UID)["eco_banned"] = True
|
||||||
|
res = run(economy.do_vanity_select(UID, "couch"))
|
||||||
|
assert not res["ok"] and res["reason"] == "banned"
|
||||||
|
|
||||||
|
def test_unknown_badge(self, fake_pb):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
res = run(economy.do_vanity_select(UID, "nope"))
|
||||||
|
assert not res["ok"] and res["reason"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEquip:
|
||||||
|
def test_equip_owned_is_free(self, fake_pb):
|
||||||
|
_fund(fake_pb, 20_000)
|
||||||
|
run(economy.do_vanity_select(UID, "couch")) # buy + equip couch
|
||||||
|
run(economy.do_vanity_select(UID, "cables")) # buy + equip cables
|
||||||
|
bal_before = run(economy.get_user(UID))["balance"]
|
||||||
|
res = run(economy.do_vanity_select(UID, "couch")) # re-equip owned couch
|
||||||
|
assert res["ok"] and res["action"] == "equipped"
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert user["vanity_active"] == "couch"
|
||||||
|
assert user["balance"] == bal_before # no re-charge
|
||||||
|
|
||||||
|
def test_unequip_clears_badge(self, fake_pb):
|
||||||
|
_fund(fake_pb, 10_000)
|
||||||
|
run(economy.do_vanity_select(UID, "couch"))
|
||||||
|
res = run(economy.do_vanity_select(UID, economy.vanity.NONE_ID))
|
||||||
|
assert res["ok"] and res["action"] == "unequipped"
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert user["vanity_active"] is None
|
||||||
|
assert economy.vanity_badge(user) is None
|
||||||
|
assert "couch" in user["vanity_owned"] # still owned, just not worn
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_badge_by_default(fake_pb):
|
||||||
|
user = run(economy.get_user(UID))
|
||||||
|
assert economy.vanity_badge(user) is None
|
||||||
Reference in New Issue
Block a user