feat: add Code Farm cooperative idle game with plot-based crop system and pub/sub notifications

Implement a Farmville-style cooperative idle game mounted at `/game` with member-only play and public farm viewing. The data layer is pure and timestamp-driven with no background tick: plot states are derived from `ready_at` vs current time, never stored. Key features include plantable software projects (shell script through kernel) that build over real time, harvest for coins and XP, CI tier upgrades for faster builds, plot purchasing with doubling cost, watering mechanics for friends' builds, daily quests, and prestige system. All game endpoints negotiate HTML or JSON and return full farm state for single-request client refresh. Pub/sub notifications broadcast farm updates on `public.game.farm.{username}` topics. Database schema adds `game_farms`, `game_plots`, and `game_quests` tables with appropriate indexes.
This commit is contained in:
2026-06-22 16:41:53 +00:00
parent 743efbf61f
commit ebf6bf7f82
40 changed files with 3860 additions and 5 deletions
+100
View File
@@ -0,0 +1,100 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.game import economy
def test_level_thresholds():
assert economy.level_for_xp(0) == 1
assert economy.level_for_xp(economy.xp_threshold(2)) == 2
assert economy.level_for_xp(economy.xp_threshold(5)) == 5
assert economy.level_for_xp(economy.xp_threshold(economy.MAX_LEVEL) + 9999) == economy.MAX_LEVEL
def test_level_progress_span():
progress = economy.level_progress(economy.xp_threshold(3))
assert progress["level"] == 3
assert progress["into_level"] == 0
assert progress["is_max"] is False
def test_plot_cost_doubles():
assert economy.plot_cost(economy.STARTING_PLOTS) == economy.PLOT_BASE_COST
assert economy.plot_cost(economy.STARTING_PLOTS + 1) == economy.PLOT_BASE_COST * 2
assert economy.plot_cost(economy.STARTING_PLOTS + 2) == economy.PLOT_BASE_COST * 4
def test_perk_cost_grows():
perk = economy.perk_for("yield")
assert economy.perk_cost(perk, 0) == perk.base_cost
assert economy.perk_cost(perk, 1) == round(perk.base_cost * perk.cost_growth)
assert economy.perk_cost(perk, 2) > economy.perk_cost(perk, 1)
def test_daily_reward_streak_and_cap():
assert economy.daily_reward(1) == economy.DAILY_BASE
assert economy.daily_reward(2) == economy.DAILY_BASE + economy.DAILY_STREAK_STEP
assert economy.daily_reward(99) == economy.daily_reward(economy.DAILY_STREAK_CAP)
def test_effective_plant_cost_discount():
crop = economy.crop_for("python")
assert economy.effective_plant_cost(crop, 0) == crop.cost
assert economy.effective_plant_cost(crop, 3) < crop.cost
assert economy.effective_plant_cost(crop, 3) >= 1
def test_effective_reward_coins_yield_and_prestige():
crop = economy.crop_for("shell")
assert economy.effective_reward_coins(crop, 0, 0) == crop.reward_coins
assert economy.effective_reward_coins(crop, 4, 0) > crop.reward_coins
assert economy.effective_reward_coins(crop, 0, 2) > crop.reward_coins
def test_effective_reward_xp():
crop = economy.crop_for("shell")
assert economy.effective_reward_xp(crop, 0) == crop.reward_xp
assert economy.effective_reward_xp(crop, 10) > crop.reward_xp
def test_growth_perk_speeds_build():
crop = economy.crop_for("python")
assert economy.grow_seconds_for(crop, 1, 5) < economy.grow_seconds_for(crop, 1, 0)
def test_ci_tier_speeds_build():
crop = economy.crop_for("python")
assert economy.grow_seconds_for(crop, 2, 0) < economy.grow_seconds_for(crop, 1, 0)
def test_prestige_multiplier():
assert economy.prestige_multiplier(0) == 1.0
assert economy.prestige_multiplier(2) == 1 + 2 * economy.PRESTIGE_BONUS
def test_fertilize_cost_has_floor():
assert economy.fertilize_cost(1) == economy.FERTILIZE_MIN_COST
assert economy.fertilize_cost(10000) > economy.FERTILIZE_MIN_COST
def test_crop_payload_locked_flag():
rust = economy.crop_for("rust")
assert economy.crop_payload(rust, 1, 1)["locked"] is True
assert economy.crop_payload(rust, 1, rust.min_level)["locked"] is False
def test_crop_payload_reflects_perks():
crop = economy.crop_for("python")
base = economy.crop_payload(crop, 1, 5)
boosted = economy.crop_payload(crop, 1, 5, growth_level=5, discount_level=3, yield_level=4, prestige=1)
assert boosted["cost"] < base["cost"]
assert boosted["grow_seconds"] < base["grow_seconds"]
assert boosted["reward_coins"] > base["reward_coins"]
def test_daily_quests_deterministic_count_and_kinds():
first = economy.daily_quests("user-xyz", "2026-06-22")
second = economy.daily_quests("user-xyz", "2026-06-22")
assert first == second
assert len(first) == economy.DAILY_QUEST_COUNT
assert all(quest["kind"] in economy.QUEST_DEFS for quest in first)
assert all(quest["goal"] > 0 for quest in first)
+537
View File
@@ -0,0 +1,537 @@
# 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
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"])
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")
assert result["coins"] == crop.reward_coins
assert result["xp"] == crop.reward_xp
assert _coins(user) == before + crop.reward_coins
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")
assert result["coins"] == economy.effective_reward_coins(crop, 4, 1)
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,
"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)
result = store.prestige(user)
assert result["prestige"] == 1
farm = store.get_farm(user["uid"])
assert farm["coins"] == economy.STARTING_COINS
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
# --- 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 -------------------------------------------------------------
def test_leaderboard_orders_by_level_then_xp(local_db):
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")