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
+217
View File
@@ -0,0 +1,217 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL
def reset_farm(username, coins=100000):
from devplacepy.database import get_table
from devplacepy.services.game import store
user = get_table("users").find_one(username=username)
uid = user["uid"]
farm = store.get_farm(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(uid)
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
return {"uid": uid, "username": username}
def warp_ready(username):
from devplacepy.database import get_table
from devplacepy.services.game import store
user = get_table("users").find_one(username=username)
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 set_farm_xp(username, xp):
from devplacepy.database import get_table
from devplacepy.services.game import store
user = get_table("users").find_one(username=username)
farm = store.get_farm(user["uid"])
get_table("game_farms").update({"uid": farm["uid"], "xp": xp}, ["uid"])
def open_game(page):
page.goto(f"{BASE_URL}/game", wait_until="domcontentloaded")
page.locator("[data-game-grid]").first.wait_for(state="visible")
page.wait_for_timeout(800)
def coins(page):
return int(page.locator("[data-hud-coins]").inner_text())
def test_game_page_loads(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
assert page.locator(".game-header h1").is_visible()
expect(page.locator("[data-hud-coins]")).to_have_text("100000")
expect(page.locator("[data-game-grid] [data-slot]")).to_have_count(4)
expect(page.locator(".perk-card")).to_have_count(4)
expect(page.locator(".quest-item")).to_have_count(3)
def test_guest_redirected_to_login(page):
page.goto(f"{BASE_URL}/game", wait_until="domcontentloaded")
page.wait_for_timeout(500)
assert "/auth/login" in page.url
def test_plant_crop(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
form = page.locator("form[data-game-action='plant']").first
form.locator("select[name='crop']").select_option("shell")
form.locator("button").click()
expect(page.locator(".game-plot-growing")).to_have_count(1)
expect(page.locator("[data-hud-coins]")).to_have_text("99995")
def test_harvest_crop(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
form = page.locator("form[data-game-action='plant']").first
form.locator("select[name='crop']").select_option("shell")
form.locator("button").click()
expect(page.locator(".game-plot-growing")).to_have_count(1)
warp_ready("alice_test")
open_game(page)
page.locator("form[data-game-action='harvest'] button").first.click()
expect(page.locator("[data-hud-coins]")).to_have_text("100006")
expect(page.locator(".game-plot-growing")).to_have_count(0)
def test_fertilize_spends_coins(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
form = page.locator("form[data-game-action='plant']").first
form.locator("select[name='crop']").select_option("python")
form.locator("button").click()
expect(page.locator("[data-hud-coins]")).to_have_text("99985")
fert = page.locator("form[data-game-action='fertilize'] button").first
fert.wait_for(state="visible")
fert.click()
expect(page.locator("[data-hud-coins]")).not_to_have_text("99985")
assert coins(page) < 99985
def test_buy_plot(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
page.locator("form[data-game-action='buy-plot'] button").click()
expect(page.locator("[data-game-grid] [data-slot]")).to_have_count(5)
expect(page.locator("[data-hud-coins]")).to_have_text("99900")
def test_upgrade_ci(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
page.locator("form[data-game-action='upgrade'] button").click()
expect(page.locator("[data-hud-ci]")).to_have_text("2")
expect(page.locator("[data-hud-coins]")).to_have_text("99850")
def test_claim_daily(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
page.locator("form[data-game-action='daily'] button").click()
expect(page.locator("[data-hud-coins]")).to_have_text("100020")
expect(page.locator(".daily-claimed")).to_be_visible()
def test_upgrade_perk(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
growth = page.locator(
"form[data-game-action='perk']:has(input[value='growth']) button"
)
growth.click()
expect(page.locator("[data-hud-coins]")).to_have_text("99850")
expect(
page.locator(".perk-card:has-text('Build Cache') .perk-level")
).to_have_text("Lv 1/10")
def test_claim_quest(alice):
page, _ = alice
from devplacepy.database import get_table
from devplacepy.services.game import store
farm_info = reset_farm("alice_test")
open_game(page)
farm = store.get_farm(farm_info["uid"])
quest = list(get_table("game_quests").find(farm_uid=farm["uid"]))[0]
get_table("game_quests").update(
{"uid": quest["uid"], "progress": quest["goal"]}, ["uid"]
)
open_game(page)
page.locator("form[data-game-action='claim-quest'] button").first.click()
expect(page.locator("[data-hud-coins]")).not_to_have_text("100000")
assert coins(page) > 100000
def test_prestige_hidden_below_level(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
assert page.locator("form[data-game-action='prestige']").count() == 0
assert page.is_visible("text=Reach level 10 to refactor")
def test_prestige_resets_farm(alice):
page, _ = alice
from devplacepy.services.game import economy
reset_farm("alice_test")
set_farm_xp("alice_test", economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
open_game(page)
prestige = page.locator("form[data-game-action='prestige'] button")
prestige.wait_for(state="visible")
prestige.click()
confirm_btn = page.locator(".dialog-confirm")
confirm_btn.wait_for(state="visible")
confirm_btn.click()
expect(page.locator("[data-hud-prestige]")).to_have_text("1")
expect(page.locator("[data-hud-coins]")).to_have_text("50")
assert page.locator("form[data-game-action='prestige']").count() == 0
def test_leaderboard_panel_populates(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
page.locator(".game-lb-row").first.wait_for(state="visible")
assert page.is_visible("a.game-lb-name:has-text('alice_test')")
def test_mobile_no_horizontal_overflow(mobile_page):
page, _ = mobile_page
reset_farm("alice_test")
open_game(page)
scroll_w = page.evaluate("document.documentElement.scrollWidth")
client_w = page.evaluate("document.documentElement.clientWidth")
assert scroll_w - client_w <= 1, f"horizontal overflow: {scroll_w} vs {client_w}"
assert page.locator("[data-game-grid]").first.is_visible()