2026-06-22 18:41:53 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.services.game import economy, store
|
|
|
|
|
from devplacepy.services.game.store import GameError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _user(username):
|
|
|
|
|
users = get_table("users")
|
|
|
|
|
row = users.find_one(username=username)
|
|
|
|
|
if not row:
|
|
|
|
|
users.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": f"uid-{username}",
|
|
|
|
|
"username": username,
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
row = users.find_one(username=username)
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 03:36:29 +02:00
|
|
|
def _clear_game_caches():
|
|
|
|
|
from devplacepy.services.game.store import farm as farm_store
|
|
|
|
|
from devplacepy.services.game.store import market as market_store
|
|
|
|
|
|
|
|
|
|
farm_store._leaderboard_cache.clear()
|
|
|
|
|
farm_store._board_cache.clear()
|
|
|
|
|
market_store._saturation_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 18:41:53 +02:00
|
|
|
def _reset(username, coins=100000):
|
|
|
|
|
user = _user(username)
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
if farm:
|
|
|
|
|
get_table("game_plots").delete(farm_uid=farm["uid"])
|
|
|
|
|
get_table("game_quests").delete(farm_uid=farm["uid"])
|
|
|
|
|
get_table("game_farms").delete(uid=farm["uid"])
|
2026-07-04 22:24:59 +02:00
|
|
|
get_table("game_steals").delete(thief_uid=user["uid"])
|
|
|
|
|
get_table("game_steals").delete(owner_uid=user["uid"])
|
2026-07-21 03:36:29 +02:00
|
|
|
get_table("game_cosmetics").delete(user_uid=user["uid"])
|
|
|
|
|
get_table("game_market_ticks").delete()
|
2026-07-23 01:14:10 +02:00
|
|
|
get_table("game_treasury").delete()
|
2026-07-21 03:36:29 +02:00
|
|
|
_clear_game_caches()
|
2026-06-22 18:41:53 +02:00
|
|
|
farm = store.ensure_farm(user["uid"])
|
|
|
|
|
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _set(user, **fields):
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
get_table("game_farms").update({"uid": farm["uid"], **fields}, ["uid"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _warp(user):
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
|
|
|
|
|
for plot in store.get_plots(farm["uid"]):
|
|
|
|
|
if plot.get("crop_key"):
|
|
|
|
|
get_table("game_plots").update(
|
|
|
|
|
{"uid": plot["uid"], "ready_at": past}, ["uid"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _remaining(user, slot=0):
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
data = store.serialize_farm(farm, viewer=user, owner=user)
|
|
|
|
|
return data["plots"][slot]["remaining_seconds"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _coins(user):
|
|
|
|
|
return int(store.get_farm(user["uid"])["coins"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- ensure / starting state -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_farm_starting_state(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=economy.STARTING_COINS)
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert farm["plot_count"] == economy.STARTING_PLOTS
|
|
|
|
|
plots = store.get_plots(farm["uid"])
|
|
|
|
|
assert len(plots) == economy.STARTING_PLOTS
|
|
|
|
|
assert all(not plot["crop_key"] for plot in plots)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_farm_idempotent(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
first = store.ensure_farm(user["uid"])
|
|
|
|
|
second = store.ensure_farm(user["uid"])
|
|
|
|
|
assert first["uid"] == second["uid"]
|
|
|
|
|
assert len(store.get_plots(first["uid"])) == economy.STARTING_PLOTS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- plant -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plant_success_deducts_cost(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
result = store.plant(user, 0, "shell")
|
|
|
|
|
assert result["spent"] == economy.crop_for("shell").cost
|
|
|
|
|
assert _coins(user) == 100000 - economy.crop_for("shell").cost
|
|
|
|
|
plot = store.get_plots(store.get_farm(user["uid"])["uid"])[0]
|
|
|
|
|
assert plot["crop_key"] == "shell"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plant_insufficient_coins(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plant_occupied_plot(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.plant(user, 0, "python")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plant_locked_crop(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.plant(user, 0, "rust")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plant_unknown_crop(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.plant(user, 0, "does_not_exist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plant_nonexistent_plot(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.plant(user, 99, "shell")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_discount_perk_lowers_plant_cost(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, perk_discount=3)
|
|
|
|
|
result = store.plant(user, 0, "python")
|
|
|
|
|
crop = economy.crop_for("python")
|
|
|
|
|
assert result["spent"] == economy.effective_plant_cost(crop, 3)
|
|
|
|
|
assert result["spent"] < crop.cost
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_growth_perk_shortens_build(local_db):
|
|
|
|
|
plain = _reset("unit_a")
|
|
|
|
|
store.plant(plain, 0, "python")
|
|
|
|
|
base = _remaining(plain)
|
|
|
|
|
boosted = _reset("unit_b")
|
|
|
|
|
_set(boosted, perk_growth=8)
|
|
|
|
|
store.plant(boosted, 0, "python")
|
|
|
|
|
assert _remaining(boosted) < base
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- harvest -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_harvest_success_awards_coins_and_xp(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
_warp(user)
|
|
|
|
|
before = _coins(user)
|
|
|
|
|
result = store.harvest(user, 0)
|
|
|
|
|
crop = economy.crop_for("shell")
|
Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.
Fix the fifteen failures this surfaced.
DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.
Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.
Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.
Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.
2881 passed, 1 skipped in 15:13.
2026-07-26 16:02:36 +02:00
|
|
|
expected = economy.realizable_harvest_coins(crop, golden=result["golden"])
|
|
|
|
|
assert result["coins"] == expected
|
2026-06-22 18:41:53 +02:00
|
|
|
assert result["xp"] == crop.reward_xp
|
Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.
Fix the fifteen failures this surfaced.
DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.
Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.
Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.
Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.
2881 passed, 1 skipped in 15:13.
2026-07-26 16:02:36 +02:00
|
|
|
assert _coins(user) == before + expected
|
2026-06-22 18:41:53 +02:00
|
|
|
assert store.get_farm(user["uid"])["total_harvests"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_harvest_empty_plot(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.harvest(user, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_harvest_unripe_build(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
store.plant(user, 0, "python")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.harvest(user, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_yield_perk_and_prestige_boost_harvest_coins(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, perk_yield=4, prestige=1)
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
_warp(user)
|
|
|
|
|
result = store.harvest(user, 0)
|
|
|
|
|
crop = economy.crop_for("shell")
|
2026-07-26 14:57:18 +02:00
|
|
|
assert result["coins"] == economy.realizable_harvest_coins(
|
|
|
|
|
crop, 4, 1, golden=result["golden"]
|
|
|
|
|
)
|
2026-06-22 18:41:53 +02:00
|
|
|
assert result["coins"] > crop.reward_coins
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_xp_perk_boosts_harvest_xp(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, perk_xp=10)
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
_warp(user)
|
|
|
|
|
result = store.harvest(user, 0)
|
|
|
|
|
crop = economy.crop_for("shell")
|
|
|
|
|
assert result["xp"] == economy.effective_reward_xp(crop, 10)
|
|
|
|
|
assert result["xp"] > crop.reward_xp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- water -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_water_rewards_visitor_and_shortens_build(local_db):
|
|
|
|
|
owner = _reset("unit_a")
|
|
|
|
|
visitor = _reset("unit_b")
|
|
|
|
|
store.plant(owner, 0, "python")
|
|
|
|
|
before_remaining = _remaining(owner)
|
|
|
|
|
before_coins = _coins(visitor)
|
|
|
|
|
result = store.water(visitor, owner, 0)
|
|
|
|
|
assert result["reward_coins"] == economy.WATER_REWARD_COINS
|
|
|
|
|
assert _coins(visitor) == before_coins + economy.WATER_REWARD_COINS
|
|
|
|
|
assert _remaining(owner) < before_remaining
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_water_own_build_blocked(local_db):
|
|
|
|
|
owner = _reset("unit_a")
|
|
|
|
|
store.plant(owner, 0, "python")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.water(owner, owner, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_water_twice_blocked(local_db):
|
|
|
|
|
owner = _reset("unit_a")
|
|
|
|
|
visitor = _reset("unit_b")
|
|
|
|
|
store.plant(owner, 0, "python")
|
|
|
|
|
store.water(visitor, owner, 0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.water(visitor, owner, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_water_empty_plot_blocked(local_db):
|
|
|
|
|
owner = _reset("unit_a")
|
|
|
|
|
visitor = _reset("unit_b")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.water(visitor, owner, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_water_maxed_blocked(local_db):
|
|
|
|
|
owner = _reset("unit_a")
|
|
|
|
|
store.plant(owner, 0, "python")
|
|
|
|
|
for index in range(economy.MAX_WATERS_PER_PLOT):
|
|
|
|
|
store.water(_reset(f"unit_w{index}"), owner, 0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.water(_reset("unit_w_extra"), owner, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- buy plot / upgrade CI ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_plot_success(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
result = store.buy_plot(user)
|
|
|
|
|
assert result["plot_count"] == economy.STARTING_PLOTS + 1
|
|
|
|
|
assert len(store.get_plots(store.get_farm(user["uid"])["uid"])) == economy.STARTING_PLOTS + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_plot_at_max_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, plot_count=economy.MAX_PLOTS)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.buy_plot(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_plot_insufficient_coins(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.buy_plot(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_ci_success(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
assert store.upgrade_ci(user)["ci_tier"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_ci_at_top_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, ci_tier=economy.MAX_CI_TIER)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_ci(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_ci_insufficient_coins(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_ci(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- perks -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_perk_success(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
result = store.upgrade_perk(user, "growth")
|
|
|
|
|
assert result["level"] == 1
|
|
|
|
|
assert store.get_farm(user["uid"])["perk_growth"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_perk_unknown(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_perk(user, "telepathy")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_perk_maxed(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, perk_growth=economy.perk_for("growth").max_level)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_perk(user, "growth")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_perk_insufficient_coins(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_perk(user, "growth")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- daily bonus -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_daily_claim_first_time(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
result = store.claim_daily(user)
|
|
|
|
|
assert result["streak"] == 1
|
|
|
|
|
assert result["reward"] == economy.daily_reward(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_daily_double_claim_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
store.claim_daily(user)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_daily(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_daily_streak_increments_consecutive_day(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
|
|
|
|
_set(user, streak=3, last_daily_at=yesterday)
|
|
|
|
|
assert store.claim_daily(user)["streak"] == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_daily_streak_resets_after_gap(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
two_days_ago = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
|
|
|
|
|
_set(user, streak=5, last_daily_at=two_days_ago)
|
|
|
|
|
assert store.claim_daily(user)["streak"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- quests ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insert_quest(user, kind, goal, progress=0, claimed=0, reward_coins=50, reward_xp=5):
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
get_table("game_quests").delete(farm_uid=farm["uid"])
|
|
|
|
|
get_table("game_quests").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": f"q-{user['uid']}-{kind}",
|
|
|
|
|
"farm_uid": farm["uid"],
|
|
|
|
|
"user_uid": user["uid"],
|
|
|
|
|
"day": store._today(),
|
|
|
|
|
"slot_index": 0,
|
2026-07-23 19:09:49 +02:00
|
|
|
"scope": "daily",
|
2026-06-22 18:41:53 +02:00
|
|
|
"kind": kind,
|
|
|
|
|
"label": f"{kind} {goal}",
|
|
|
|
|
"goal": goal,
|
|
|
|
|
"progress": progress,
|
|
|
|
|
"reward_coins": reward_coins,
|
|
|
|
|
"reward_xp": reward_xp,
|
|
|
|
|
"claimed": claimed,
|
|
|
|
|
"created_at": store._today(),
|
|
|
|
|
"updated_at": store._today(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return farm
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_quests_creates_three(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
store.ensure_quests(farm, store._today())
|
|
|
|
|
rows = list(get_table("game_quests").find(farm_uid=farm["uid"], day=store._today()))
|
|
|
|
|
assert len(rows) == economy.DAILY_QUEST_COUNT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_advance_quests_increments_and_caps(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_insert_quest(user, "plant", goal=3)
|
|
|
|
|
store.advance_quests(user["uid"], "plant", 2)
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
row = get_table("game_quests").find_one(farm_uid=farm["uid"], kind="plant")
|
|
|
|
|
assert row["progress"] == 2
|
|
|
|
|
store.advance_quests(user["uid"], "plant", 5)
|
|
|
|
|
row = get_table("game_quests").find_one(farm_uid=farm["uid"], kind="plant")
|
|
|
|
|
assert row["progress"] == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plant_advances_plant_quest(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_insert_quest(user, "plant", goal=5)
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
row = get_table("game_quests").find_one(farm_uid=farm["uid"], kind="plant")
|
|
|
|
|
assert row["progress"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_harvest_advances_earn_quest_by_coins(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_insert_quest(user, "earn", goal=1000)
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
_warp(user)
|
|
|
|
|
result = store.harvest(user, 0)
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
row = get_table("game_quests").find_one(farm_uid=farm["uid"], kind="earn")
|
|
|
|
|
assert row["progress"] == result["coins"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_quest_success(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_insert_quest(user, "plant", goal=2, progress=2, reward_coins=70, reward_xp=8)
|
|
|
|
|
before = _coins(user)
|
|
|
|
|
result = store.claim_quest(user, "plant")
|
|
|
|
|
assert result["reward_coins"] == 70
|
|
|
|
|
assert _coins(user) == before + 70
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_quest_incomplete_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_insert_quest(user, "plant", goal=5, progress=1)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_quest(user, "plant")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_quest_double_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_insert_quest(user, "plant", goal=2, progress=2)
|
|
|
|
|
store.claim_quest(user, "plant")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_quest(user, "plant")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- fertilize ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fertilize_reduces_time_and_costs_coins(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
store.plant(user, 0, "python")
|
|
|
|
|
before_remaining = _remaining(user)
|
|
|
|
|
before_coins = _coins(user)
|
|
|
|
|
result = store.fertilize(user, 0)
|
|
|
|
|
assert _remaining(user) < before_remaining
|
|
|
|
|
assert _coins(user) == before_coins - result["spent"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fertilize_empty_plot_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.fertilize(user, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fertilize_ready_build_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
_warp(user)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.fertilize(user, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- prestige ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prestige_below_level_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.prestige(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prestige_resets_farm_and_increments(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(
|
|
|
|
|
user,
|
|
|
|
|
xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL),
|
|
|
|
|
perk_growth=3,
|
|
|
|
|
ci_tier=3,
|
|
|
|
|
)
|
|
|
|
|
store.buy_plot(user)
|
2026-07-23 01:14:10 +02:00
|
|
|
coins_before = _coins(user)
|
|
|
|
|
fee = economy.refactor_cost(0, coins_before)
|
|
|
|
|
carried = economy.refactor_carryover(coins_before, fee, 0)
|
2026-06-22 18:41:53 +02:00
|
|
|
result = store.prestige(user)
|
|
|
|
|
assert result["prestige"] == 1
|
2026-07-23 01:14:10 +02:00
|
|
|
assert result["fee"] == fee
|
|
|
|
|
assert result["carried"] == carried
|
2026-06-22 18:41:53 +02:00
|
|
|
farm = store.get_farm(user["uid"])
|
2026-07-23 01:14:10 +02:00
|
|
|
assert farm["coins"] == economy.STARTING_COINS + carried
|
2026-06-22 18:41:53 +02:00
|
|
|
assert farm["xp"] == 0
|
|
|
|
|
assert farm["ci_tier"] == 1
|
|
|
|
|
assert farm["perk_growth"] == 0
|
|
|
|
|
assert farm["plot_count"] == economy.STARTING_PLOTS
|
|
|
|
|
assert len(store.get_plots(farm["uid"])) == economy.STARTING_PLOTS
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 01:14:10 +02:00
|
|
|
def test_prestige_insufficient_coins_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=100)
|
|
|
|
|
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.prestige(user)
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert int(farm["prestige"] or 0) == 0
|
|
|
|
|
assert int(farm["coins"]) == 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prestige_fee_funds_treasury(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
|
|
|
|
|
fee = economy.refactor_cost(0, _coins(user))
|
|
|
|
|
store.prestige(user)
|
|
|
|
|
assert store.treasury_balance() == fee
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prestige_carryover_scales_with_legacy(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(
|
|
|
|
|
user,
|
|
|
|
|
xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL),
|
|
|
|
|
legacy_carryover=5,
|
|
|
|
|
)
|
|
|
|
|
coins_before = _coins(user)
|
|
|
|
|
fee = economy.refactor_cost(0, coins_before)
|
|
|
|
|
carried = economy.refactor_carryover(coins_before, fee, 5)
|
|
|
|
|
assert carried > economy.refactor_carryover(coins_before, fee, 0)
|
|
|
|
|
store.prestige(user)
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert farm["coins"] == economy.STARTING_COINS + carried
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prestige_fee_rises_with_prestige_and_wealth(local_db):
|
|
|
|
|
assert economy.refactor_cost(1, 0) > economy.refactor_cost(0, 0)
|
|
|
|
|
assert economy.refactor_cost(0, 1_000_000) > economy.refactor_cost(0, 0)
|
|
|
|
|
|
|
|
|
|
|
feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
# --- legacy upgrades ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_legacy_success_spends_stars(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, stars=100, legacy_multiplier=0)
|
|
|
|
|
cost = economy.legacy_cost(economy.legacy_for("multiplier"), 0)
|
|
|
|
|
result = store.upgrade_legacy(user, "multiplier")
|
|
|
|
|
assert result["level"] == 1
|
|
|
|
|
assert result["spent"] == cost
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert int(farm["legacy_multiplier"]) == 1
|
|
|
|
|
assert int(farm["stars"]) == 100 - cost
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_legacy_unknown_key(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, stars=100)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_legacy(user, "telekinesis")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_legacy_maxed(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
up = economy.legacy_for("multiplier")
|
|
|
|
|
_set(user, stars=100000, legacy_multiplier=up.max_level)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_legacy(user, "multiplier")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_legacy_insufficient_stars(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, stars=0, legacy_multiplier=0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_legacy(user, "multiplier")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- stealing ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ripen_past_grace(user, slot=0):
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
|
|
|
|
plot = store._plot_at(farm["uid"], slot)
|
|
|
|
|
get_table("game_plots").update(
|
|
|
|
|
{"uid": plot["uid"], "planted_at": past, "ready_at": past}, ["uid"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 14:57:18 +02:00
|
|
|
def test_steal_transfers_a_share_and_leaves_the_build(local_db):
|
feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
owner = _reset("unit_owner")
|
|
|
|
|
thief = _reset("unit_thief", coins=0)
|
|
|
|
|
store.plant(owner, 0, "shell")
|
|
|
|
|
_ripen_past_grace(owner)
|
|
|
|
|
result = store.steal(thief, owner, 0)
|
|
|
|
|
assert result["coins"] > 0
|
|
|
|
|
assert _coins(thief) == result["coins"]
|
|
|
|
|
farm = store.get_farm(owner["uid"])
|
2026-07-26 14:57:18 +02:00
|
|
|
plot = store._plot_at(farm["uid"], 0)
|
|
|
|
|
assert (plot.get("crop_key") or "") == "shell"
|
|
|
|
|
assert 0 < float(plot.get("raided_fraction") or 0) <= 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_owner_still_harvests_the_unraided_remainder(local_db):
|
|
|
|
|
owner = _reset("unit_owner")
|
|
|
|
|
thief = _reset("unit_thief", coins=0)
|
|
|
|
|
store.plant(owner, 0, "shell")
|
|
|
|
|
_ripen_past_grace(owner)
|
|
|
|
|
before = _coins(owner)
|
|
|
|
|
stolen = store.steal(thief, owner, 0)["coins"]
|
|
|
|
|
result = store.harvest(owner, 0)
|
|
|
|
|
harvested = result["coins"]
|
|
|
|
|
assert harvested > 0
|
|
|
|
|
assert _coins(owner) == before + harvested
|
|
|
|
|
full = economy.realizable_harvest_coins(
|
|
|
|
|
economy.crop_for("shell"), golden=result["golden"]
|
|
|
|
|
)
|
|
|
|
|
assert harvested + stolen <= full
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_steal_blocked_once_the_build_is_fully_stripped(local_db):
|
|
|
|
|
owner = _reset("unit_owner")
|
|
|
|
|
store.plant(owner, 0, "shell")
|
|
|
|
|
_ripen_past_grace(owner)
|
|
|
|
|
farm = store.get_farm(owner["uid"])
|
|
|
|
|
plot = store._plot_at(farm["uid"], 0)
|
|
|
|
|
get_table("game_plots").update(
|
|
|
|
|
{"uid": plot["uid"], "raided_fraction": 1.0}, ["uid"]
|
|
|
|
|
)
|
|
|
|
|
thief = _reset("unit_thief", coins=0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.steal(thief, owner, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_steal_capped_per_victim_per_day(local_db):
|
|
|
|
|
owner = _reset("unit_owner")
|
|
|
|
|
store.plant(owner, 0, "shell")
|
|
|
|
|
_ripen_past_grace(owner)
|
|
|
|
|
for index in range(economy.STEAL_MAX_PER_VICTIM_PER_DAY):
|
|
|
|
|
thief = _reset(f"unit_thief_{index}", coins=0)
|
|
|
|
|
store.steal(thief, owner, 0)
|
|
|
|
|
farm = store.get_farm(owner["uid"])
|
|
|
|
|
plot = store._plot_at(farm["uid"], 0)
|
|
|
|
|
get_table("game_plots").update(
|
|
|
|
|
{"uid": plot["uid"], "raided_fraction": 0.0}, ["uid"]
|
|
|
|
|
)
|
|
|
|
|
blocked = _reset("unit_thief_last", coins=0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.steal(blocked, owner, 0)
|
feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_steal_protected_within_grace(local_db):
|
|
|
|
|
owner = _reset("unit_owner")
|
|
|
|
|
thief = _reset("unit_thief")
|
|
|
|
|
store.plant(owner, 0, "shell")
|
|
|
|
|
farm = store.get_farm(owner["uid"])
|
|
|
|
|
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
|
|
|
|
|
plot = store._plot_at(farm["uid"], 0)
|
|
|
|
|
get_table("game_plots").update(
|
|
|
|
|
{"uid": plot["uid"], "planted_at": past, "ready_at": past}, ["uid"]
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.steal(thief, owner, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_steal_cooldown_blocks_second_raid(local_db):
|
|
|
|
|
owner = _reset("unit_owner")
|
|
|
|
|
thief = _reset("unit_thief", coins=0)
|
|
|
|
|
store.plant(owner, 0, "shell")
|
|
|
|
|
_ripen_past_grace(owner)
|
|
|
|
|
store.steal(thief, owner, 0)
|
2026-07-26 14:57:18 +02:00
|
|
|
store.harvest(owner, 0)
|
feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
store.plant(owner, 0, "shell")
|
|
|
|
|
_ripen_past_grace(owner)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.steal(thief, owner, 0)
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 18:41:53 +02:00
|
|
|
# --- backwards compatibility -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_serialize_handles_legacy_row_without_new_columns(local_db):
|
|
|
|
|
user = _user("unit_legacy")
|
|
|
|
|
existing = store.get_farm(user["uid"])
|
|
|
|
|
if existing:
|
|
|
|
|
get_table("game_plots").delete(farm_uid=existing["uid"])
|
|
|
|
|
get_table("game_quests").delete(farm_uid=existing["uid"])
|
|
|
|
|
get_table("game_farms").delete(uid=existing["uid"])
|
|
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
get_table("game_farms").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": "legacy-farm",
|
|
|
|
|
"user_uid": user["uid"],
|
|
|
|
|
"coins": 50,
|
|
|
|
|
"xp": 0,
|
|
|
|
|
"level": 1,
|
|
|
|
|
"ci_tier": 1,
|
|
|
|
|
"plot_count": economy.STARTING_PLOTS,
|
|
|
|
|
"total_harvests": 0,
|
|
|
|
|
"created_at": now,
|
|
|
|
|
"updated_at": now,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
farm = get_table("game_farms").find_one(uid="legacy-farm")
|
|
|
|
|
data = store.serialize_farm(farm, viewer=user, owner=user)
|
|
|
|
|
assert data["prestige"] == 0
|
|
|
|
|
assert data["streak"] == 0
|
|
|
|
|
assert data["daily_available"] is True
|
|
|
|
|
assert all(perk["level"] == 0 for perk in data["perks"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- leaderboard -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
feat: add telegram notification channel with outbox service and per-user preferences
Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
2026-06-22 22:52:02 +02:00
|
|
|
def test_leaderboard_orders_by_score(local_db):
|
2026-06-22 18:41:53 +02:00
|
|
|
high = _reset("unit_lb_high")
|
|
|
|
|
low = _reset("unit_lb_low")
|
|
|
|
|
_set(high, level=8, xp=economy.xp_threshold(8))
|
|
|
|
|
_set(low, level=1, xp=0)
|
|
|
|
|
board = store.leaderboard(50)
|
|
|
|
|
names = [entry["username"] for entry in board]
|
|
|
|
|
assert "unit_lb_high" in names and "unit_lb_low" in names
|
|
|
|
|
assert names.index("unit_lb_high") < names.index("unit_lb_low")
|
feat: add telegram notification channel with outbox service and per-user preferences
Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
2026-06-22 22:52:02 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_leaderboard_prestige_lifts_above_lower_level(local_db):
|
|
|
|
|
refactorer = _reset("unit_lb_refactor")
|
|
|
|
|
grinder = _reset("unit_lb_grind")
|
|
|
|
|
_set(refactorer, level=1, xp=200, prestige=1)
|
|
|
|
|
_set(grinder, level=5, xp=economy.xp_threshold(5))
|
|
|
|
|
board = store.leaderboard(50)
|
|
|
|
|
names = [entry["username"] for entry in board]
|
|
|
|
|
assert names.index("unit_lb_refactor") < names.index("unit_lb_grind")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_leaderboard_entry_exposes_score_and_prestige(local_db):
|
|
|
|
|
user = _reset("unit_lb_fields")
|
|
|
|
|
_set(user, prestige=3, total_harvests=12, ci_tier=2)
|
|
|
|
|
entry = next(e for e in store.leaderboard(50) if e["username"] == "unit_lb_fields")
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert entry["prestige"] == 3
|
|
|
|
|
assert entry["score"] == economy.farm_score(farm)
|
|
|
|
|
assert {"rank", "username", "level", "xp", "coins", "total_harvests", "prestige", "score"} <= set(entry)
|
2026-07-21 03:36:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- market saturation ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_record_and_recent_harvests_roundtrip(local_db):
|
|
|
|
|
_reset("unit_a")
|
|
|
|
|
from devplacepy.services.game.store import market as market_store
|
|
|
|
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
market_store.record_harvest_tick("shell", now)
|
|
|
|
|
market_store.record_harvest_tick("shell", now)
|
|
|
|
|
assert store.recent_harvests("shell", economy.MARKET_WINDOW_HOURS) == 2
|
|
|
|
|
assert store.recent_harvests("python", economy.MARKET_WINDOW_HOURS) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_harvest_records_market_tick_and_saturates_reward(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
from devplacepy.services.game.store import market as market_store
|
|
|
|
|
from devplacepy.utils import generate_uid
|
|
|
|
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
2026-07-23 19:09:49 +02:00
|
|
|
crop = economy.crop_for("shell")
|
2026-07-21 03:36:29 +02:00
|
|
|
get_table("game_market_ticks").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": generate_uid(),
|
|
|
|
|
"crop_key": "shell",
|
|
|
|
|
"hour_bucket": market_store._hour_bucket(now),
|
Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.
Fix the fifteen failures this surfaced.
DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.
Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.
Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.
Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.
2881 passed, 1 skipped in 15:13.
2026-07-26 16:02:36 +02:00
|
|
|
"harvests": _worst_tier_harvests("shell"),
|
2026-07-21 03:36:29 +02:00
|
|
|
"updated_at": now.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.
Fix the fifteen failures this surfaced.
DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.
Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.
Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.
Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.
2881 passed, 1 skipped in 15:13.
2026-07-26 16:02:36 +02:00
|
|
|
market_store._saturation_cache.clear()
|
2026-07-21 03:36:29 +02:00
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
_warp(user)
|
|
|
|
|
result = store.harvest(user, 0)
|
Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.
Fix the fifteen failures this surfaced.
DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.
Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.
Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.
Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.
2881 passed, 1 skipped in 15:13.
2026-07-26 16:02:36 +02:00
|
|
|
unsaturated = economy.realizable_harvest_coins(crop, golden=result["golden"])
|
|
|
|
|
assert result["coins"] < unsaturated
|
2026-07-21 03:36:29 +02:00
|
|
|
|
|
|
|
|
|
2026-07-23 19:09:49 +02:00
|
|
|
def _seed_market_ticks(crop_key, harvests):
|
|
|
|
|
from devplacepy.services.game.store import market as market_store
|
|
|
|
|
from devplacepy.utils import generate_uid
|
|
|
|
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
get_table("game_market_ticks").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": generate_uid(),
|
|
|
|
|
"crop_key": crop_key,
|
|
|
|
|
"hour_bucket": market_store._hour_bucket(now),
|
|
|
|
|
"harvests": harvests,
|
|
|
|
|
"updated_at": now.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
market_store._saturation_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _worst_tier_harvests(crop_key):
|
Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.
Fix the fifteen failures this surfaced.
DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.
Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.
Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.
Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.
2881 passed, 1 skipped in 15:13.
2026-07-26 16:02:36 +02:00
|
|
|
from devplacepy.services.game.store import market as market_store
|
|
|
|
|
|
2026-07-23 19:09:49 +02:00
|
|
|
crop = economy.crop_for(crop_key)
|
|
|
|
|
worst_days = economy.MARKET_SATURATION_TIERS[-1][0]
|
Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.
Fix the fifteen failures this surfaced.
DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.
Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.
Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.
Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.
2881 passed, 1 skipped in 15:13.
2026-07-26 16:02:36 +02:00
|
|
|
supply_per_harvest = crop.grow_seconds / 86400.0 / market_store.active_farms()
|
|
|
|
|
return int(worst_days / supply_per_harvest) + 1
|
2026-07-23 19:09:49 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_starter_crop_boosted_when_market_saturated(local_db):
|
|
|
|
|
_reset("unit_a")
|
|
|
|
|
from devplacepy.services.game.store import market as market_store
|
|
|
|
|
|
|
|
|
|
_seed_market_ticks("kernel", _worst_tier_harvests("kernel"))
|
|
|
|
|
assert market_store.market_factor_for("kernel") == economy.MARKET_SATURATION_TIERS[-1][1]
|
|
|
|
|
assert market_store.market_factor_for("shell") == economy.MARKET_BUFF_CAP
|
|
|
|
|
assert market_store.market_factor_for("rust") == 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_own_saturation_beats_boost(local_db):
|
|
|
|
|
_reset("unit_a")
|
|
|
|
|
from devplacepy.services.game.store import market as market_store
|
|
|
|
|
|
|
|
|
|
_seed_market_ticks("kernel", _worst_tier_harvests("kernel"))
|
|
|
|
|
_seed_market_ticks("shell", _worst_tier_harvests("shell"))
|
|
|
|
|
assert market_store.market_factor_for("shell") == economy.MARKET_SATURATION_TIERS[-1][1]
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 03:36:29 +02:00
|
|
|
def test_harvest_records_a_market_tick_for_its_own_crop(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
_warp(user)
|
|
|
|
|
store.harvest(user, 0)
|
|
|
|
|
assert store.recent_harvests("shell", economy.MARKET_WINDOW_HOURS) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- infrastructure --------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_infrastructure_success(local_db):
|
|
|
|
|
cost = economy.infra_for("registry").cost
|
|
|
|
|
user = _reset("unit_a", coins=cost + 100000)
|
|
|
|
|
_set(user, prestige=5)
|
|
|
|
|
result = store.buy_infrastructure(user, "registry")
|
|
|
|
|
assert result["key"] == "registry"
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert int(farm["infra_registry"]) == 1
|
|
|
|
|
assert _coins(user) == 100000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_infrastructure_requires_prestige(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.buy_infrastructure(user, "registry")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_infrastructure_already_owned_blocked(local_db):
|
|
|
|
|
cost = economy.infra_for("registry").cost
|
|
|
|
|
user = _reset("unit_a", coins=cost * 2)
|
|
|
|
|
_set(user, prestige=5)
|
|
|
|
|
store.buy_infrastructure(user, "registry")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.buy_infrastructure(user, "registry")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_infrastructure_unknown_key(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.buy_infrastructure(user, "does_not_exist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- defense -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_defense_success(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
result = store.upgrade_defense(user)
|
|
|
|
|
assert result["defense_level"] == 1
|
|
|
|
|
assert int(store.get_farm(user["uid"])["defense_level"]) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_defense_insufficient_coins(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=0)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_defense(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_charge_upkeep_decays_when_unaffordable(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=0)
|
|
|
|
|
_set(user, defense_level=1, defense_last_upkeep_at=(datetime.now(timezone.utc) - timedelta(days=3)).isoformat())
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
updated = store.charge_upkeep(farm, datetime.now(timezone.utc))
|
|
|
|
|
assert int(updated["defense_level"]) == 0
|
|
|
|
|
assert int(updated["coins"]) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_charge_upkeep_deducts_coins_when_affordable(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=100000)
|
|
|
|
|
_set(user, defense_level=1, defense_last_upkeep_at=(datetime.now(timezone.utc) - timedelta(days=1)).isoformat())
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
updated = store.charge_upkeep(farm, datetime.now(timezone.utc))
|
|
|
|
|
assert int(updated["defense_level"]) == 1
|
|
|
|
|
assert int(updated["coins"]) < 100000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- cosmetics ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_and_equip_cosmetic(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=economy.cosmetic_for("title_architect").cost_coins * 2)
|
|
|
|
|
store.buy_cosmetic(user, "title_architect")
|
|
|
|
|
assert "title_architect" in store.owned_cosmetic_keys(user["uid"])
|
|
|
|
|
result = store.equip_title(user, "title_architect")
|
|
|
|
|
assert result["active_title"] == "title_architect"
|
|
|
|
|
assert store.get_farm(user["uid"])["active_title"] == "title_architect"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_buy_cosmetic_twice_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=economy.cosmetic_for("title_architect").cost_coins * 2)
|
|
|
|
|
store.buy_cosmetic(user, "title_architect")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.buy_cosmetic(user, "title_architect")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_equip_unowned_cosmetic_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.equip_title(user, "title_architect")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_equip_non_title_cosmetic_blocked(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=economy.cosmetic_for("skin_neon").cost_coins * 2)
|
|
|
|
|
store.buy_cosmetic(user, "skin_neon")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.equip_title(user, "skin_neon")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- mastery -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_mastery_success(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, mastery_points=10)
|
|
|
|
|
result = store.upgrade_mastery(user, "autoreplant")
|
|
|
|
|
assert result["level"] == 1
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert int(farm["mastery_autoreplant"]) == 1
|
|
|
|
|
assert int(farm["mastery_points"]) == 10 - economy.mastery_cost(economy.mastery_for("autoreplant"), 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_mastery_insufficient_points(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_mastery(user, "autoreplant")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upgrade_mastery_unknown_key(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, mastery_points=1000)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.upgrade_mastery(user, "does_not_exist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_farm_analytics_hidden_until_unlocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
state = store.serialize_farm(store.get_farm(user["uid"]), viewer=user, owner=user)
|
|
|
|
|
assert state["mastery_analytics_unlocked"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_farm_analytics_tracks_lifetime_totals_once_unlocked(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, mastery_points=1000)
|
|
|
|
|
store.upgrade_mastery(user, "analytics")
|
|
|
|
|
store.plant(user, 0, "shell")
|
|
|
|
|
_warp(user)
|
|
|
|
|
result = store.harvest(user, 0)
|
|
|
|
|
state = store.serialize_farm(store.get_farm(user["uid"]), viewer=user, owner=user)
|
|
|
|
|
assert state["mastery_analytics_unlocked"] is True
|
|
|
|
|
assert state["lifetime_coins_earned"] == result["coins"]
|
|
|
|
|
assert state["lifetime_harvests"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prestige_awards_mastery_at_threshold(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
2026-07-23 01:14:10 +02:00
|
|
|
coins = economy.refactor_cost(49, 0) * 2
|
|
|
|
|
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL), prestige=49, coins=coins)
|
2026-07-21 03:36:29 +02:00
|
|
|
result = store.prestige(user)
|
|
|
|
|
assert result["mastery_awarded"] == 1
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert int(farm["mastery_points"]) == 1
|
|
|
|
|
assert int(farm["mastery_points_earned_total"]) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prestige_below_mastery_threshold_awards_nothing(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL), prestige=0)
|
|
|
|
|
result = store.prestige(user)
|
|
|
|
|
assert result["mastery_awarded"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- eras --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_start_era_resets_visible_counters_not_real_balances(local_db):
|
|
|
|
|
user = _reset("unit_a", coins=12345)
|
|
|
|
|
_set(user, era_coins=999, era_harvests=5, prestige=3, stars=7)
|
|
|
|
|
if store.active_era():
|
|
|
|
|
store.end_era()
|
|
|
|
|
store.start_era("TestEra", 7)
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert int(farm["era_coins"]) == 0
|
|
|
|
|
assert int(farm["era_harvests"]) == 0
|
|
|
|
|
assert int(farm["coins"]) == 12345
|
|
|
|
|
assert int(farm["prestige"]) == 3
|
|
|
|
|
assert int(farm["stars"]) == 7
|
|
|
|
|
store.end_era()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_start_era_twice_blocked(local_db):
|
|
|
|
|
if store.active_era():
|
|
|
|
|
store.end_era()
|
|
|
|
|
store.start_era("TestEra", 7)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.start_era("AnotherEra", 7)
|
|
|
|
|
store.end_era()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_end_era_without_active_blocked(local_db):
|
|
|
|
|
if store.active_era():
|
|
|
|
|
store.end_era()
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.end_era()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_end_era_awards_stars_to_top_participant(local_db):
|
|
|
|
|
if store.active_era():
|
|
|
|
|
store.end_era()
|
|
|
|
|
user = _reset("unit_era_top")
|
|
|
|
|
store.start_era("TestEra", 7)
|
|
|
|
|
_set(user, era_coins=50000, era_harvests=10)
|
2026-07-23 19:09:49 +02:00
|
|
|
before_stars = int(store.get_farm(user["uid"])["stars"] or 0)
|
2026-07-21 03:36:29 +02:00
|
|
|
store.end_era()
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
2026-07-23 19:09:49 +02:00
|
|
|
assert int(farm["stars"] or 0) > before_stars
|
|
|
|
|
assert int(farm["era_coins"] or 0) == 0
|
2026-07-21 03:36:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_era_gates_new_crops_and_leaderboard(local_db):
|
|
|
|
|
if store.active_era():
|
|
|
|
|
store.end_era()
|
|
|
|
|
_clear_game_caches()
|
|
|
|
|
assert store.leaderboard_for("era") == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- weekly contracts ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_weekly_contract_requires_mastery_upgrade(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_quest(user, "harvest", "weekly")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_weekly_contract_claim_pays_stars_and_boost(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_set(user, mastery_points=1000)
|
|
|
|
|
store.upgrade_mastery(user, "contracts")
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
from devplacepy.services.game.store.quests import ensure_weekly_contract
|
|
|
|
|
from devplacepy.services.game.store.common import _iso_week
|
|
|
|
|
|
|
|
|
|
iso_week = _iso_week(datetime.now(timezone.utc))
|
|
|
|
|
ensure_weekly_contract(farm, iso_week)
|
|
|
|
|
row = get_table("game_quests").find_one(farm_uid=farm["uid"], day=iso_week, scope="weekly")
|
|
|
|
|
get_table("game_quests").update({"uid": row["uid"], "progress": row["goal"]}, ["uid"])
|
|
|
|
|
result = store.claim_quest(user, row["kind"], "weekly")
|
|
|
|
|
assert result["reward_stars"] > 0
|
|
|
|
|
farm = store.get_farm(user["uid"])
|
|
|
|
|
assert int(farm["stars"]) == result["reward_stars"]
|
|
|
|
|
assert farm.get("contract_boost_until")
|
2026-07-23 01:14:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- treasury and community grant --------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_treasury(amount):
|
|
|
|
|
store.ensure_treasury()
|
|
|
|
|
get_table("game_treasury").update(
|
|
|
|
|
{"uid": "treasury-main", "balance": amount}, ["uid"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_grant_eligible(user):
|
|
|
|
|
_set(
|
|
|
|
|
user,
|
|
|
|
|
coins=100,
|
|
|
|
|
harvests_week=economy.GRANT_MIN_WEEK_HARVESTS,
|
|
|
|
|
harvests_week_start=store._iso_week(store._now()),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_grant_pays_from_treasury(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_seed_treasury(100_000)
|
|
|
|
|
_make_grant_eligible(user)
|
|
|
|
|
result = store.claim_grant(user)
|
|
|
|
|
assert result["amount"] == economy.GRANT_CAP
|
|
|
|
|
assert _coins(user) == 100 + economy.GRANT_CAP
|
|
|
|
|
assert store.treasury_balance() == 100_000 - economy.GRANT_CAP
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_grant_once_per_week(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_seed_treasury(100_000)
|
|
|
|
|
_make_grant_eligible(user)
|
|
|
|
|
store.claim_grant(user)
|
|
|
|
|
_set(user, coins=100)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_grant(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_grant_requires_activity(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_seed_treasury(100_000)
|
|
|
|
|
_set(user, coins=100, harvests_week=economy.GRANT_MIN_WEEK_HARVESTS - 1)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_grant(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_grant_wealth_ceiling(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_seed_treasury(100_000)
|
|
|
|
|
_set(
|
|
|
|
|
user,
|
|
|
|
|
coins=economy.GRANT_WEALTH_CEILING,
|
|
|
|
|
harvests_week=economy.GRANT_MIN_WEEK_HARVESTS,
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_grant(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_grant_prestige_cap(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_seed_treasury(100_000)
|
|
|
|
|
_make_grant_eligible(user)
|
|
|
|
|
_set(user, prestige=economy.GRANT_MAX_PRESTIGE + 1)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_grant(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_grant_empty_treasury(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_make_grant_eligible(user)
|
|
|
|
|
with pytest.raises(GameError):
|
|
|
|
|
store.claim_grant(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_grant_partial_treasury(local_db):
|
|
|
|
|
user = _reset("unit_a")
|
|
|
|
|
_seed_treasury(300)
|
|
|
|
|
_make_grant_eligible(user)
|
|
|
|
|
result = store.claim_grant(user)
|
|
|
|
|
assert result["amount"] == 300
|
|
|
|
|
assert store.treasury_balance() == 0
|