"""引擎层测试:游戏规则、边界与存档。 这一层是"纯规则",不依赖 FastAPI / uvicorn,所以能单独跑: uv run python -m unittest tests.test_engine -v 设计原则:**只通过公开接口断言行为**。私有方法(``_gain_exp`` 之类)只在 "没有公开路径能触达"时才碰,且要说明为什么。 """ from __future__ import annotations import contextlib import json import os import shutil import sqlite3 import tempfile import time import unittest from unittest import mock from farm import config from farm.engine import FarmEngine, GameError, Scheduler, tick_decision from farm.models import Animal, GameState, Plot, clamp, format_duration from farm.server.actions import run_action from farm.storage import ( DB_NAME, SAVE_VERSION, delete_save, list_farms, load_state, migrate_json_saves, save_state, ) def make_engine(**kwargs) -> FarmEngine: """造一个测试引擎:默认给足金币,省得每个用例都先攒钱。""" engine = FarmEngine(**kwargs) engine.state.gold = 2000 return engine class FormatDurationTestCase(unittest.TestCase): """时长格式化:界面与命令行都靠它,别让 2400 秒显示成 "2399.6s"。""" def test_none_means_no_schedule(self): self.assertEqual(format_duration(None), "—") def test_seconds_below_a_minute(self): self.assertEqual(format_duration(0), "0s") self.assertEqual(format_duration(45), "45s") self.assertEqual(format_duration(59.9), "59s") def test_minutes_and_seconds_are_zero_padded(self): self.assertEqual(format_duration(60), "1:00") self.assertEqual(format_duration(2400), "40:00") self.assertEqual(format_duration(2399.6), "39:59") self.assertEqual(format_duration(65), "1:05") def test_hours_are_shown_when_needed(self): self.assertEqual(format_duration(3600), "1:00:00") self.assertEqual(format_duration(86400), "24:00:00") def test_negative_is_clamped_to_zero(self): self.assertEqual(format_duration(-5), "0s") class ClampTestCase(unittest.TestCase): def test_clamp_keeps_value_inside_range(self): self.assertEqual(clamp(5, 0, 10), 5) self.assertEqual(clamp(-1, 0, 10), 0) self.assertEqual(clamp(99, 0, 10), 10) self.assertEqual(clamp(3.5, 0, 10), 3.5) class PlotTestCase(unittest.TestCase): def test_empty_plot_defaults(self): plot = Plot(0) self.assertTrue(plot.is_empty) self.assertFalse(plot.is_ripe) self.assertEqual(plot.days_left, 0) self.assertEqual(plot.grow_days, 0) self.assertEqual(plot.stage, "空地") def test_growth_stages_follow_config_thresholds(self): plot = Plot(0) plot.crop = "corn" # 6 天成熟 plot.growth = 0 self.assertEqual(plot.stage, "已播种") plot.growth = 2 # 33% self.assertEqual(plot.stage, "幼苗") plot.growth = 4 # 66% self.assertEqual(plot.stage, "生长中") plot.growth = 6 self.assertEqual(plot.stage, "已成熟") def test_ripeness_and_days_left(self): plot = Plot(2) plot.crop = "carrot" # 3 天 plot.growth = 1 self.assertFalse(plot.is_ripe) self.assertEqual(plot.days_left, 2) plot.growth = 3 self.assertTrue(plot.is_ripe) self.assertEqual(plot.days_left, 0) def test_to_dict_exposes_position_which_is_one_based(self): data = Plot(3).to_dict() self.assertEqual(data["index"], 3) self.assertEqual(data["position"], 4) def test_from_dict_drops_crop_names_that_no_longer_exist(self): """旧存档里可能留着已经删掉的作物,读档不能 KeyError。""" plot = Plot.from_dict({"index": 0, "crop": "古老的神秘作物", "growth": 3}) self.assertIsNone(plot.crop) self.assertTrue(plot.is_empty) def test_from_dict_survives_missing_fields(self): plot = Plot.from_dict({}) self.assertEqual(plot.index, 0) self.assertEqual(plot.growth, 0.0) self.assertEqual(plot.moisture, 0) class AnimalTestCase(unittest.TestCase): def test_info_comes_from_config(self): animal = Animal("chicken") self.assertEqual(animal.name, "鸡") self.assertEqual(animal.product, "鸡蛋") self.assertEqual(animal.cycle, config.ANIMALS["chicken"]["cycle"]) def test_days_to_product_counts_down(self): animal = Animal("sheep") # cycle 2 self.assertEqual(animal.days_to_product, 2) animal.progress = 2 self.assertEqual(animal.days_to_product, 0) def test_sick_after_two_days_without_food(self): animal = Animal("chicken") animal.starve = 1 self.assertFalse(animal.is_sick) animal.starve = 2 self.assertTrue(animal.is_sick) def test_from_dict_rejects_unknown_kind(self): self.assertIsNone(Animal.from_dict({"kind": "恐龙"})) def test_from_dict_bumps_id_counter_so_new_animals_do_not_collide(self): restored = Animal.from_dict({"kind": "cow", "id": 500}) assert restored is not None self.assertEqual(restored.aid, 500) fresh = Animal("chicken") self.assertGreater(fresh.aid, 500) class GameStateTestCase(unittest.TestCase): def test_initial_state_matches_config(self): state = GameState() self.assertEqual(state.day, 1) self.assertEqual(state.gold, config.START_GOLD) self.assertEqual(state.ap, config.START_AP) self.assertEqual(len(state.plots), config.BASE_PLOTS) self.assertEqual(len(state.animals), 0) def test_market_is_seeded_with_every_crop_and_animal_product(self): state = GameState() for key in config.CROPS: self.assertIn(key, state.market) for key in config.ANIMALS: self.assertIn(f"animal:{key}", state.market) def test_max_plots_grows_with_level_and_is_capped(self): state = GameState() self.assertEqual(state.max_plots, config.BASE_PLOTS) state.level = 2 self.assertEqual(state.max_plots, config.BASE_PLOTS + config.PLOTS_PER_LEVEL) state.level = 99 self.assertEqual(state.max_plots, config.MAX_PLOTS) def test_next_plot_price_grows_and_returns_minus_one_at_the_cap(self): state = GameState() state.level = 2 # 初始 6 块地刚好占满 Lv.1 的名额 first = state.next_plot_price self.assertEqual(first, config.PLOT_BASE_PRICE) state.plots_bought = 1 self.assertGreater(state.next_plot_price, first) state.level = 99 state.plots = [Plot(i) for i in range(config.MAX_PLOTS)] self.assertEqual(state.next_plot_price, -1) def test_net_worth_counts_gold_land_livestock_and_inventory(self): state = GameState() state.gold = 100 state.feed = 0 self.assertEqual(state.net_worth, 100.0) state.feed = 10 self.assertEqual(state.net_worth, 100.0 + 10 * config.FEED_UNIT_PRICE) def test_goal_progress(self): state = GameState() state.gold = 0 state.feed = 0 self.assertEqual(state.goal_index, -1) self.assertEqual(state.next_goal["amount"], config.GOALS[0][0]) state.gold = config.GOALS[-1][0] + 1 self.assertEqual(state.goal_index, len(config.GOALS) - 1) self.assertIsNone(state.next_goal) def test_from_dict_clamps_hostile_values(self): """存档是外部输入:负数金币、越界行动点都要被夹回来。""" state = GameState.from_dict({"gold": -500, "ap": 9999, "level": 0, "day": -3}) self.assertEqual(state.gold, 0) self.assertEqual(state.ap, state.max_ap) self.assertEqual(state.level, 1) self.assertEqual(state.day, 1) def test_from_dict_pads_missing_plots_and_reindexes(self): state = GameState.from_dict({"plots": [{"index": 7, "crop": "wheat"}]}) self.assertEqual(len(state.plots), config.BASE_PLOTS) self.assertEqual([p.index for p in state.plots], list(range(config.BASE_PLOTS))) self.assertEqual(state.plots[0].crop, "wheat") def test_from_dict_drops_extra_animals_beyond_capacity(self): raw = {"animals": [{"kind": "chicken", "id": i + 1} for i in range(config.MAX_ANIMALS + 5)]} state = GameState.from_dict(raw) self.assertEqual(len(state.animals), config.MAX_ANIMALS) def test_from_dict_ignores_unparsable_market_prices(self): state = GameState.from_dict({"market": {"wheat": "not a number"}}) self.assertEqual(state.market["wheat"], config.CROPS["wheat"]["base_price"]) class StorageTestCase(unittest.TestCase): """SQLite 存档:往返、多牧场隔离、坏档降级。 存档层换了实现,但这组用例守的是**行为契约**——"读不懂就降级成新档, 绝不抛异常"这条从 JSON 时代起就没变过。 """ def setUp(self): self.dir = tempfile.mkdtemp(prefix="simplefarm-test-") self.db = os.path.join(self.dir, DB_NAME) def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def raw_conn(self): """直连库文件,绕开 storage 去核对落盘结果。""" return sqlite3.connect(self.db) def test_round_trip_keeps_the_important_fields(self): engine = make_engine() engine.plant(1, "wheat") engine.buy_animal("chicken") engine.state.day = 7 engine.state.exp = 42 engine.state.inventory["wheat"] = 3 engine.state.buildings["well"] = 1 engine.state.weather, engine.state.forecast = "drought", "rain" save_state(engine.state, self.db, "alice") loaded, message = load_state(self.db, "alice") assert loaded is not None, message self.assertEqual(loaded.day, 7) self.assertEqual(loaded.exp, 42) self.assertEqual(loaded.plots[0].crop, "wheat") self.assertEqual([a.kind for a in loaded.animals], ["chicken"]) self.assertEqual(dict(loaded.inventory), {"wheat": 3}) self.assertEqual(loaded.buildings["well"], 1) self.assertEqual((loaded.weather, loaded.forecast), ("drought", "rain")) self.assertIn("第 7 天", message) def test_common_fields_are_queryable_columns(self): """换成 SQLite 的意义就在这:不碰 payload 也能直接排榜、巡检。""" engine = make_engine() engine.state.day = 4 save_state(engine.state, self.db, "alice") with contextlib.closing(self.raw_conn()) as conn: row = conn.execute( "SELECT version, day, gold, weather, net_worth FROM farms" ).fetchone() self.assertEqual(row[0], SAVE_VERSION) self.assertEqual(row[1], 4) self.assertEqual(row[2], engine.state.gold) self.assertEqual(row[3], engine.state.weather) self.assertEqual(row[4], engine.state.net_worth) def test_payload_stays_readable_json(self): """payload 是故意留的 JSON——给人看、给工具改,不是黑盒二进制。""" save_state(make_engine().state, self.db, "alice") with contextlib.closing(self.raw_conn()) as conn: payload = conn.execute("SELECT payload FROM farms").fetchone()[0] self.assertIsInstance(json.loads(payload), dict) def test_each_farm_gets_its_own_row(self): alice = make_engine() alice.state.day = 11 bob = make_engine() bob.state.day = 3 save_state(alice.state, self.db, "alice") save_state(bob.state, self.db, "bob") self.assertEqual({row["farm"] for row in list_farms(self.db)}, {"alice", "bob"}) loaded, _ = load_state(self.db, "alice") self.assertEqual(loaded.day if loaded else None, 11) loaded, _ = load_state(self.db, "bob") self.assertEqual(loaded.day if loaded else None, 3) def test_saving_twice_updates_in_place(self): save_state(make_engine().state, self.db, "alice") engine = make_engine() engine.state.day = 30 save_state(engine.state, self.db, "alice") with contextlib.closing(self.raw_conn()) as conn: count = conn.execute("SELECT COUNT(*) FROM farms").fetchone()[0] self.assertEqual(count, 1, "同一个牧场不该插出两行") loaded, _ = load_state(self.db, "alice") self.assertEqual(loaded.day if loaded else None, 30) def test_list_farms_orders_by_net_worth(self): poor = make_engine() poor.state.gold = 10 rich = make_engine() rich.state.gold = 9000 save_state(poor.state, self.db, "poor") save_state(rich.state, self.db, "rich") self.assertEqual([row["farm"] for row in list_farms(self.db)], ["rich", "poor"]) def test_missing_database_is_not_an_error(self): state, message = load_state(self.db, "alice") self.assertIsNone(state) self.assertIn("未找到存档", message) def test_missing_farm_says_so(self): save_state(make_engine().state, self.db, "alice") state, message = load_state(self.db, "bob") self.assertIsNone(state) self.assertIn("没有牧场", message) def test_not_a_database_degrades_instead_of_raising(self): with open(self.db, "w", encoding="utf-8") as handle: handle.write("这不是 SQLite") state, message = load_state(self.db, "alice") self.assertIsNone(state) self.assertIn("无法读取", message) def test_wrong_version_is_refused(self): save_state(make_engine().state, self.db, "alice") with contextlib.closing(self.raw_conn()) as conn, conn: conn.execute("UPDATE farms SET version = ?", (SAVE_VERSION + 1,)) state, message = load_state(self.db, "alice") self.assertIsNone(state) self.assertIn("版本不兼容", message) def test_broken_payload_degrades(self): save_state(make_engine().state, self.db, "alice") with contextlib.closing(self.raw_conn()) as conn, conn: conn.execute("UPDATE farms SET payload = ?", ("{ 不是 json",)) state, message = load_state(self.db, "alice") self.assertIsNone(state) self.assertIn("存档内容异常", message) def test_unexpected_state_shape_degrades(self): save_state(make_engine().state, self.db, "alice") bad = json.dumps({"plots": "nonsense", "animals": 7}) with contextlib.closing(self.raw_conn()) as conn, conn: conn.execute("UPDATE farms SET payload = ?", (bad,)) state, message = load_state(self.db, "alice") self.assertIsNone(state) self.assertIn("存档内容异常", message) def test_delete_removes_the_row_but_keeps_the_library(self): save_state(make_engine().state, self.db, "alice") save_state(make_engine().state, self.db, "bob") self.assertTrue(delete_save(self.db, "alice")) self.assertFalse(delete_save(self.db, "alice"), "删过的再删就没了") self.assertEqual([row["farm"] for row in list_farms(self.db)], ["bob"]) self.assertTrue(os.path.exists(self.db), "库里还有别的牧场,文件不能删") def test_delete_on_a_missing_database_is_false(self): self.assertFalse(delete_save(self.db, "alice")) def test_wal_mode_is_on(self): save_state(make_engine().state, self.db, "alice") with contextlib.closing(self.raw_conn()) as conn: self.assertEqual(conn.execute("PRAGMA journal_mode").fetchone()[0], "wal") class MigrationTestCase(unittest.TestCase): """老版本是一牧场一个 JSON,第一次启动时要并进库里,且一份都不许丢。""" def setUp(self): self.dir = tempfile.mkdtemp(prefix="simplefarm-migrate-") self.db = os.path.join(self.dir, DB_NAME) def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def write_json(self, name: str, payload) -> str: path = os.path.join(self.dir, f"{name}.json") with open(path, "w", encoding="utf-8") as handle: if isinstance(payload, str): handle.write(payload) else: json.dump(payload, handle, ensure_ascii=False) return path def legacy(self, day: int = 12, version: int = SAVE_VERSION) -> dict: return { "version": version, "saved_at": "2026-09-18 21:21:45", "state": {"day": day, "gold": 340, "level": 3, "plots": [], "animals": []}, } def test_legacy_json_is_imported_then_archived(self): src = self.write_json("alice", self.legacy(day=12)) self.assertEqual(migrate_json_saves(self.dir), ["alice"]) state, message = load_state(self.db, "alice") assert state is not None, message self.assertEqual(state.day, 12) self.assertTrue(os.path.exists(src + ".migrated")) self.assertFalse(os.path.exists(src), "迁过的 json 要让位,免得反复导入") def test_migration_is_idempotent(self): self.write_json("alice", self.legacy()) migrate_json_saves(self.dir) self.assertEqual(migrate_json_saves(self.dir), [], "第二次没有可迁的东西了") def test_migration_never_overwrites_a_newer_farm(self): self.write_json("alice", self.legacy(day=999)) fresh = make_engine() fresh.state.day = 5 save_state(fresh.state, self.db, "alice") self.assertEqual(migrate_json_saves(self.dir), []) state, _ = load_state(self.db, "alice") self.assertEqual(state.day if state else None, 5, "库里的新档优先") self.assertTrue(os.path.exists(os.path.join(self.dir, "alice.json"))) def test_unreadable_files_are_left_alone(self): self.write_json("broken", "{ 这不是 json") self.write_json("future", self.legacy(version=SAVE_VERSION + 1)) self.write_json("ancient", self.legacy(version=0)) self.write_json("notobject", "[]") self.assertEqual(migrate_json_saves(self.dir), []) for name in ("broken", "future", "ancient", "notobject"): self.assertTrue(os.path.exists(os.path.join(self.dir, f"{name}.json")), name) def test_migration_is_a_noop_without_json(self): self.assertEqual(migrate_json_saves(self.dir), []) self.assertEqual(migrate_json_saves(os.path.join(self.dir, "没有这个目录")), []) class ActionTestCase(unittest.TestCase): """各玩法动作的成功路径与错误码。""" def setUp(self): self.engine = make_engine() def assert_code(self, code: str, func, *args, **kwargs): with self.assertRaises(GameError) as ctx: func(*args, **kwargs) self.assertEqual(ctx.exception.code, code) self.assertEqual(ctx.exception.status, 400) # ------------------------------------------------------------------ 播种 def test_plant_costs_gold_and_action_point(self): gold, ap = self.engine.state.gold, self.engine.state.ap result = self.engine.plant(1, "wheat") self.assertTrue(result["ok"]) self.assertEqual(self.engine.state.gold, gold - config.CROPS["wheat"]["seed_cost"]) self.assertEqual(self.engine.state.ap, ap - config.AP_COST["plant"]) def test_plant_sets_initial_moisture_and_planted_day(self): self.engine.state.day = 4 self.engine.plant(1, "wheat") plot = self.engine.state.plots[0] self.assertEqual(plot.moisture, config.PLANT_INITIAL_MOISTURE) self.assertEqual(plot.planted_day, 4) self.assertEqual(plot.growth, 0.0) def test_plant_rejects_unknown_crop(self): self.assert_code("unknown_crop", self.engine.plant, 1, "榴莲") def test_plant_rejects_level_locked_crop(self): self.assert_code("locked", self.engine.plant, 1, "strawberry") def test_plant_rejects_busy_plot(self): self.engine.plant(1, "wheat") self.assert_code("plot_busy", self.engine.plant, 1, "carrot") def test_plot_number_is_one_based_and_bounded(self): self.assert_code("no_such_plot", self.engine.plant, 0, "wheat") self.assert_code("no_such_plot", self.engine.plant, 99, "wheat") def test_non_numeric_plot_number_is_a_param_error(self): self.assert_code("bad_param", self.engine.plant, "第一块", "wheat") # ------------------------------------------------------------------ 浇水 def test_water_fills_moisture_to_capacity(self): self.engine.plant(1, "wheat") self.engine.state.plots[0].moisture = 0 self.engine.water(1) self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY) def test_water_needs_ap(self): self.engine.plant(1, "wheat") self.engine.state.ap = 0 self.assert_code("no_ap", self.engine.water, 1) def test_water_rejects_full_and_empty_plots(self): self.assert_code("plot_empty", self.engine.water, 1) self.engine.plant(2, "wheat") self.engine.state.plots[1].moisture = config.WATER_CAPACITY self.assert_code("already_watered", self.engine.water, 2) # ------------------------------------------------------------------ 收获 def test_harvest_lands_in_the_granary_at_market_price(self): self.engine.plant(1, "wheat") self.engine.state.plots[0].growth = config.CROPS["wheat"]["grow_days"] gold = self.engine.state.gold with mock.patch("farm.engine.random.random", return_value=0.99): result = self.engine.harvest(1) self.assertEqual(result["quantity"], 1) self.assertEqual(result["price"], self.engine.market_price("wheat")) self.assertEqual(result["value"], result["price"] * result["quantity"]) self.assertEqual(self.engine.state.inventory["wheat"], 1, "收成先进谷仓") self.assertEqual(self.engine.state.gold, gold, "收获本身不给钱,卖了才给") self.assertTrue(self.engine.state.plots[0].is_empty) def test_lucky_harvest_doubles_the_yield(self): self.engine.plant(1, "wheat") self.engine.state.plots[0].growth = 2 with mock.patch("farm.engine.random.random", return_value=0.0): result = self.engine.harvest(1) self.assertEqual(result["quantity"], 2) self.assertEqual(self.engine.state.inventory["wheat"], 2) def test_harvest_before_ripe_is_refused(self): self.engine.plant(1, "carrot") self.assert_code("not_ripe", self.engine.harvest, 1) def test_harvest_on_empty_plot_is_refused(self): self.assert_code("plot_empty", self.engine.harvest, 3) def test_harvesting_clears_the_plot_completely(self): self.engine.plant(1, "wheat") self.engine.state.plots[0].growth = 2 self.engine.harvest(1) plot = self.engine.state.plots[0] self.assertIsNone(plot.crop) self.assertEqual(plot.growth, 0) self.assertEqual(plot.moisture, 0) # ------------------------------------------------------------------ 牲畜 def test_buy_animal_takes_gold_and_lands_in_the_pasture(self): result = self.engine.buy_animal("chicken") self.assertEqual(len(self.engine.state.animals), 1) self.assertEqual(result["animal"]["kind"], "chicken") def test_buy_animal_checks_level_and_money_and_capacity(self): self.assert_code("unknown_animal", self.engine.buy_animal, "独角兽") self.assert_code("locked", self.engine.buy_animal, "cow") self.engine.state.gold = 0 self.assert_code("not_enough_gold", self.engine.buy_animal, "chicken") def test_pasture_has_a_capacity_limit(self): self.engine.state.level = 5 for _ in range(config.MAX_ANIMALS): self.engine.buy_animal("chicken") self.assert_code("pasture_full", self.engine.buy_animal, "chicken") def test_feed_consumes_inventory(self): self.engine.buy_animal("chicken") aid = self.engine.state.animals[0].aid feed_before = self.engine.state.feed self.engine.feed(aid) self.assertEqual(self.engine.state.feed, feed_before - 1) self.assertTrue(self.engine.state.animals[0].fed) def test_feeding_twice_a_day_is_refused(self): self.engine.buy_animal("chicken") aid = self.engine.state.animals[0].aid self.engine.feed(aid) self.assert_code("already_fed", self.engine.feed, aid) def test_short_feed_is_bought_at_the_emergency_price(self): self.engine.buy_animal("chicken") self.engine.state.feed = 0 gold = self.engine.state.gold self.engine.feed(self.engine.state.animals[0].aid) self.assertEqual(gold - self.engine.state.gold, config.FEED_EMERGENCY_PRICE) self.assertEqual(self.engine.state.feed, 0) def test_overfeeding_is_trimmed_to_the_actual_need(self): self.engine.state.level = 3 self.engine.buy_animal("cow") # 每天 2 份 self.engine.state.feed = 10 self.engine.feed(self.engine.state.animals[0].aid, quantity=99) self.assertEqual(self.engine.state.feed, 8) def test_feed_rejects_bad_quantity(self): self.engine.buy_animal("chicken") aid = self.engine.state.animals[0].aid self.assert_code("bad_param", self.engine.feed, aid, 0) self.assert_code("bad_param", self.engine.feed, aid, "many") def test_collect_lands_every_pending_product_in_the_granary(self): self.engine.buy_animal("chicken") animal = self.engine.state.animals[0] animal.pending = 3 gold = self.engine.state.gold result = self.engine.collect(animal.aid) self.assertEqual(result["quantity"], 3) self.assertEqual(result["price"], self.engine.market_price("animal:chicken")) self.assertEqual(result["value"], result["price"] * 3) self.assertEqual(self.engine.state.inventory["animal:chicken"], 3, "产物先入库") self.assertEqual(self.engine.state.gold, gold, "收取本身不给钱,卖了才给") self.assertEqual(animal.pending, 0) def test_collect_without_output_is_refused(self): self.engine.buy_animal("chicken") self.assert_code( "nothing_to_collect", self.engine.collect, self.engine.state.animals[0].aid ) def test_unknown_animal_id(self): self.assert_code("no_such_animal", self.engine.feed, 999) # ------------------------------------------------------------------ 投资 def test_buy_plot_appends_land_and_raises_the_next_price(self): self.engine.state.level = 2 # Lv.1 的 6 块地已经到手,升级后才有地可买 before = len(self.engine.state.plots) price = self.engine.state.next_plot_price gold = self.engine.state.gold self.engine.buy_plot() self.assertEqual(len(self.engine.state.plots), before + 1) self.assertEqual(gold - self.engine.state.gold, price) self.assertEqual(self.engine.state.plots[-1].index, before) def test_buy_plot_respects_the_level_cap(self): self.engine.state.level = 99 self.engine.state.gold = 10**9 while self.engine.state.next_plot_price > 0: self.engine.state.ap = 10 self.engine.buy_plot() self.assertEqual(len(self.engine.state.plots), config.MAX_PLOTS) self.assert_code("plot_limit", self.engine.buy_plot) def test_buy_feed_adds_inventory(self): before = self.engine.state.feed result = self.engine.buy_feed(20) self.assertEqual(self.engine.state.feed, before + 20) self.assertEqual(result["feed"], before + 20) def test_buy_feed_bounds(self): self.assert_code("bad_param", self.engine.buy_feed, 0) self.assert_code("bad_param", self.engine.buy_feed, "一袋") self.assert_code("too_many", self.engine.buy_feed, config.MAX_FEED_PURCHASE + 1) def test_buy_feed_rejects_negative_amount_without_crediting_gold(self): """负数是"倒收钱"的经典漏洞,必须被金额校验挡住。""" gold = self.engine.state.gold with self.assertRaises(GameError): self.engine.buy_feed(-5) self.assertEqual(self.engine.state.gold, gold) # ------------------------------------------------------------------ 杂项 def test_pause_toggles_and_can_be_explicit(self): self.assertFalse(self.engine.paused) self.assertTrue(self.engine.pause()["paused"], "无参调用应当反转") self.assertFalse(self.engine.pause()["paused"], "再调一次就翻回来") self.assertFalse(self.engine.pause(False)["paused"]) self.assertTrue(self.engine.pause(True)["paused"]) def test_reset_clears_progress_but_keeps_the_clock_setting(self): engine = FarmEngine(tick_seconds=120) engine.state.gold = 9999 engine.pause(True) engine.reset() self.assertEqual(engine.state.gold, config.START_GOLD) self.assertEqual(engine.state.day, 1) self.assertFalse(engine.paused) self.assertEqual(engine.tick_seconds, 120) def test_snapshot_carries_clock_fields(self): snap = self.engine.snapshot() self.assertIn("tick_seconds", snap) self.assertIn("paused", snap) self.assertIn("seconds_to_tick", snap) def test_seconds_to_tick_is_none_without_a_scheduler(self): """没有调度器(单机暂停 / 尚未启动)时倒计时必须是 None,不是 0。""" self.assertIsNone(self.engine.seconds_to_tick) def test_seconds_to_tick_counts_down(self): self.engine.next_tick_at = time.time() + 30 value = self.engine.seconds_to_tick assert value is not None self.assertGreater(value, 25) self.assertLessEqual(value, 30) class GrowthTestCase(unittest.TestCase): """水、生长、成熟这条链——放置游戏的核心节奏。""" def setUp(self): self.engine = make_engine() def test_crops_grow_only_while_watered(self): self.engine.plant(1, "carrot") # 3 天,刚好够观察缺水停摆 self.assertEqual(self.engine.state.plots[0].moisture, config.PLANT_INITIAL_MOISTURE) # 天气按天随机,这里钉死成晴天(每天蒸发 1 点),才观察得到"断水停摆"。 self.engine.advance_day(weather="sunny") self.assertEqual(self.engine.state.plots[0].growth, 1) self.engine.advance_day(weather="sunny") self.assertEqual(self.engine.state.plots[0].moisture, 0) self.assertEqual(self.engine.state.plots[0].growth, 2) events = self.engine.advance_day(weather="sunny") self.assertEqual(self.engine.state.plots[0].growth, 2, "没水就不该再长") self.assertTrue(any("缺水" in text for text in events)) def test_watering_resumes_growth(self): self.engine.plant(1, "carrot") self.engine.advance_day(weather="sunny") self.engine.advance_day(weather="sunny") self.engine.advance_day(weather="sunny") # 此刻缺水停摆 self.assertEqual(self.engine.state.plots[0].growth, 2, "断水期间不该生长") self.engine.water(1) # 浇到 3 点水 self.engine.advance_day(weather="sunny") self.assertEqual(self.engine.state.plots[0].growth, 3, "浇水后又开始长了") self.assertTrue(self.engine.state.plots[0].is_ripe) def test_crop_reportedly_ripens_on_the_last_day(self): self.engine.plant(1, "wheat") # 2 天 self.engine.advance_day() self.engine.advance_day() plot = self.engine.state.plots[0] self.assertTrue(plot.is_ripe) self.assertEqual(plot.growth, plot.grow_days) def test_growth_never_exceeds_grow_days(self): self.engine.plant(1, "wheat") for _ in range(10): self.engine.state.plots[0].moisture = config.WATER_CAPACITY self.engine.advance_day() plot = self.engine.state.plots[0] self.assertEqual(plot.growth, plot.grow_days) class AutomationTestCase(unittest.TestCase): """四座自动化设施:喷灌 / 收割机 / 磨坊 / 售货机。 直接改 ``state.buildings`` 绕过等级门槛——解锁检查是 build() 的通用 逻辑(有独立用例),这里只验"建成后每天自动干了什么"。 """ def setUp(self): self.engine = make_engine() def test_sprinkler_waters_every_dry_plot(self): """水井只能救一块地,喷灌系统要全救。""" e = self.engine for no in (1, 2, 3): e.plant(no, "wheat") e.state.plots[0].moisture = 0 e.state.plots[1].moisture = 0 e.state.buildings["sprinkler"] = 1 events = e.advance_day(weather="sunny") self.assertEqual(e.state.plots[0].moisture, config.WATER_CAPACITY - 1) self.assertEqual(e.state.plots[1].moisture, config.WATER_CAPACITY - 1) self.assertEqual(sum(text.count("喷灌系统") for text in events), 2) def test_harvester_auto_harvests_ripe_plots_without_exp(self): e = self.engine e.plant(1, "wheat") # 2 天成熟 e.state.buildings["harvester"] = 1 e.advance_day(weather="sunny") e.advance_day(weather="sunny") exp_before = e.state.exp events = e.advance_day(weather="sunny") self.assertTrue(any("自动收割机" in text for text in events)) self.assertIsNone(e.state.plots[0].crop) self.assertGreaterEqual(e.state.inventory.get("wheat", 0), 1) self.assertEqual(e.state.exp, exp_before, "机器收的不该给经验") def test_harvester_stalls_when_granary_full(self): e = self.engine e.plant(1, "wheat") e.state.buildings["harvester"] = 1 e.advance_day(weather="sunny") e.advance_day(weather="sunny") e.state.inventory["carrot"] = config.GRANARY_BASE # 塞满谷仓 events = e.advance_day(weather="sunny") self.assertTrue(any("停摆" in text for text in events)) self.assertEqual(e.state.plots[0].crop, "wheat", "满仓时作物留在地里") def test_mill_grinds_the_cheapest_crop(self): e = self.engine e.state.inventory = {"wheat": 2, "pumpkin": 1} e.state.feed = 0 # 引擎自带 START_FEED,清零才好数磨坊的产出 e.state.buildings["mill"] = 1 events = e.advance_day(weather="sunny") self.assertEqual(e.state.inventory["wheat"], 1) self.assertEqual(e.state.inventory["pumpkin"], 1, "南瓜更贵,不该被磨") self.assertEqual(e.state.feed, config.MILL_OUTPUT) self.assertTrue(any("饲料磨坊" in text for text in events)) def test_mill_feeds_the_feeder_in_the_same_morning(self): """流水线顺序:磨坊产饲料 → 喂食器当天就能吃上。 注意 fed 会在当天的牲畜结算末尾被复位(今天的粮今天消化), 所以"喂到了"的证据是周期推进 / 产出累计,不是 fed 本身。 """ e = self.engine e.buy_animal("chicken") e.state.buildings = {"mill": 1, "feeder": 1} e.state.inventory = {"wheat": 1} e.state.feed = 0 e.advance_day(weather="sunny") self.assertEqual(e.state.animals[0].pending, 1, "当天喂到就该产出 1 份待收") def test_vendor_sells_above_baseline_respecting_quota(self): e = self.engine e.state.inventory = {"wheat": 8, "carrot": 3} e.state.market["wheat"] = 20.0 # 基准 14,高于基准 e.state.market["carrot"] = 10.0 # 基准 26,低于基准 e.state.buildings["vendor"] = 1 gold_before = e.state.gold events = e.advance_day(weather="sunny") self.assertEqual(dict(e.state.inventory), {"wheat": 3, "carrot": 3}) self.assertEqual(e.state.gold - gold_before, 100) # 5 份 × 20 self.assertTrue(any("自动售货机" in text for text in events)) class AdvanceDayTestCase(unittest.TestCase): def setUp(self): self.engine = make_engine() def test_day_increments_and_action_points_refill(self): self.engine.state.ap = 0 self.engine.advance_day() self.assertEqual(self.engine.state.day, 2) self.assertEqual(self.engine.state.ap, config.START_AP) def test_fed_animals_make_progress_and_get_hungry_again(self): self.engine.buy_animal("chicken") # cycle 1 animal = self.engine.state.animals[0] self.engine.feed(animal.aid) self.engine.advance_day() self.assertFalse(animal.fed, "新的一天要重新喂") self.assertEqual(animal.pending, 1, "鸡每天下蛋") def test_animal_dies_after_three_hungry_days(self): self.engine.buy_animal("chicken") animal = self.engine.state.animals[0] aid = animal.aid self.engine.advance_day() self.assertEqual(animal.starve, 1) events = self.engine.advance_day() self.assertEqual(animal.starve, 2) self.assertTrue(any("两天没吃东西" in text for text in events)) events = self.engine.advance_day() self.assertEqual(len(self.engine.state.animals), 0) self.assertTrue(any("离你而去" in text for text in events)) with self.assertRaises(GameError): self.engine.feed(aid) def test_feeding_resets_the_hunger_counter(self): self.engine.buy_animal("chicken") animal = self.engine.state.animals[0] self.engine.advance_day() self.engine.advance_day() self.assertEqual(animal.starve, 2) self.engine.state.feed = 5 self.engine.feed(animal.aid) self.assertEqual(animal.starve, 0) self.assertFalse(animal.is_sick) def test_market_prices_stay_within_the_configured_band(self): for _ in range(30): self.engine.advance_day() for key, info in config.CROPS.items(): base = info["base_price"] price = self.engine.state.market[key] self.assertGreaterEqual(price, round(base * 0.55, 2) - 0.01) self.assertLessEqual(price, round(base * 1.55, 2) + 0.01) for key, info in config.ANIMALS.items(): base = info["base_price"] price = self.engine.state.market[f"animal:{key}"] self.assertGreaterEqual(price, round(base * 0.55, 2) - 0.01) self.assertLessEqual(price, round(base * 1.55, 2) + 0.01) def test_progress_message_mentions_the_new_day(self): self.engine.advance_day(reason="测试") texts = [entry["text"] for entry in self.engine.state.log] self.assertTrue(any("第 2 天" in text for text in texts)) class LevelUpTestCase(unittest.TestCase): def setUp(self): self.engine = make_engine() def test_exp_accumulates_and_triggers_level_up(self): needed = self.engine.state.exp_needed self.engine.state.exp = needed - 1 self.engine.plant(1, "wheat") self.engine.state.plots[0].growth = 2 self.engine.harvest(1) self.assertEqual(self.engine.state.level, 2) self.assertEqual(self.engine.state.exp, config.CROPS["wheat"]["exp"] - 1) self.assertGreater(self.engine.state.gold, 2000 + config.CROPS["wheat"]["base_price"]) def test_big_exp_award_can_level_up_several_times(self): self.engine.state.exp = 0 messages = self.engine._gain_exp(10**5) self.assertGreater(len(messages), 2) self.assertGreater(self.engine.state.level, 3) def test_new_crop_unlocks_with_level(self): with self.assertRaises(GameError): self.engine.plant(1, "tomato") self.engine.state.level = 2 self.engine.plant(1, "tomato") self.assertEqual(self.engine.state.plots[0].crop, "tomato") class AvailableActionsTestCase(unittest.TestCase): """给 AI 看的"当前合法动作"列表必须是**真的合法**。""" def setUp(self): self.engine = make_engine() def names(self) -> set[str]: return {item["action"] for item in self.engine.available_actions()} def test_every_listed_action_can_actually_run(self): """这张表存在的意义:AI 照抄 params 就一定成功。 牲畜类动作的 animal_id 只对产生它的那个引擎有意义, 所以它们在别的用例里单独验证。 """ actions = self.engine.available_actions() self.assertTrue(actions) for item in actions: name = item["action"] params = item.get("params") or {} if "animal_id" in params: continue with self.subTest(action=name): fresh = make_engine() fresh.state.level = 5 # 解锁全部作物与牲畜 fresh.state.feed = 50 if name == "water": plot = fresh.state.plots[params["plot"] - 1] plot.crop, plot.moisture = "wheat", 0 elif name == "harvest": plot = fresh.state.plots[params["plot"] - 1] plot.crop, plot.growth = "wheat", 2 result = run_action(fresh, name, dict(params)) self.assertTrue(result["ok"]) def test_locked_crops_are_not_offered(self): keys = { item["params"].get("crop") for item in self.engine.available_actions() if item["action"] == "plant" } self.assertIn("wheat", keys) self.assertNotIn("strawberry", keys, "Lv.5 的作物不该出现在 Lv.1") def test_ripe_crop_offers_harvest_instead_of_water(self): self.engine.plant(1, "wheat") self.engine.state.plots[0].growth = 2 for_plot_1 = [ item["action"] for item in self.engine.available_actions() if item["params"].get("plot") == 1 ] self.assertIn("harvest", for_plot_1) self.assertNotIn("water", for_plot_1) def test_dry_plot_offers_water(self): self.engine.plant(1, "carrot") self.engine.state.plots[0].moisture = 0 self.assertIn("water", self.names()) def test_poor_player_is_not_offered_things_they_cannot_pay_for(self): self.engine.state.gold = 0 self.assertNotIn("buy_plot", self.names()) self.assertNotIn("buy_animal", self.names()) self.assertNotIn("plant", self.names()) def test_feed_is_offered_only_while_inventory_is_low(self): self.assertEqual(self.engine.state.feed, config.START_FEED) self.assertNotIn("buy_feed", self.names(), "默认 5 份不算低") self.engine.state.feed = 1 self.assertIn("buy_feed", self.names()) def test_hungry_animal_offers_feed_and_pending_offers_collect(self): self.engine.buy_animal("chicken") animal = self.engine.state.animals[0] self.assertIn("feed", self.names()) animal.fed = True animal.pending = 1 actions = [ item["action"] for item in self.engine.available_actions() if item["params"].get("animal_id") == animal.aid ] self.assertIn("collect", actions) self.assertNotIn("feed", actions) def test_action_point_shortage_hides_action_point_actions(self): self.engine.state.ap = 0 names = self.names() self.assertNotIn("plant", names) self.assertNotIn("water", names) self.assertNotIn("feed", names) def test_actions_explain_themselves(self): for item in self.engine.available_actions(): with self.subTest(action=item["action"]): self.assertTrue(item["reason"]) self.assertIn("gold", item["cost"]) self.assertIn("ap", item["cost"]) class LogTestCase(unittest.TestCase): def setUp(self): self.engine = make_engine() def test_log_entries_carry_increasing_sequence_numbers(self): self.engine.add_log("第一件事") self.engine.add_log("第二件事") seqs = [entry["seq"] for entry in self.engine.state.log] self.assertEqual(seqs, sorted(seqs)) self.assertEqual(len(set(seqs)), len(seqs)) def test_recent_log_can_filter_by_sequence(self): self.engine.add_log("老消息") mark = self.engine.state.log[-1]["seq"] self.engine.add_log("新消息") entries = self.engine.recent_log(since=mark) self.assertEqual([entry["text"] for entry in entries], ["新消息"]) def test_log_is_trimmed_to_the_configured_maximum(self): for i in range(config.MAX_LOG_ENTRIES + 25): self.engine.add_log(f"第 {i} 条") self.assertEqual(len(self.engine.state.log), config.MAX_LOG_ENTRIES) def test_listener_errors_do_not_break_the_game(self): """界面回调抛异常不能把玩法带走。""" self.engine.subscribe(lambda: (_ for _ in ()).throw(RuntimeError("界面挂了"))) self.assertTrue(self.engine.plant(1, "wheat")["ok"]) class ClockTestCase(unittest.TestCase): """tick 配置、纯函数决策、以及单机调度线程。""" def test_default_day_length_is_forty_minutes(self): self.assertEqual(config.DEFAULT_TICK_SECONDS, 2400.0) self.assertEqual(FarmEngine().tick_seconds, 2400.0) def test_tick_is_clamped_on_both_ends(self): self.assertEqual(FarmEngine(tick_seconds=0).tick_seconds, config.MIN_TICK_SECONDS) self.assertEqual(FarmEngine(tick_seconds=10**9).tick_seconds, config.MAX_TICK_SECONDS) self.assertEqual(FarmEngine(tick_seconds=999).tick_seconds, 999.0) def test_the_old_ten_minute_ceiling_is_gone(self): """历史坑:上限曾是 600 秒,会把 40 分钟悄悄夹回 10 分钟。""" self.assertGreater(config.MAX_TICK_SECONDS, config.DEFAULT_TICK_SECONDS) def test_tick_decision_paused_defers(self): advance, next_at = tick_decision(True, 100.0, 60.0, 500.0) self.assertFalse(advance) self.assertEqual(next_at, 560.0, "暂停时要顺延,保证恢复后有完整一轮") def test_tick_decision_without_schedule_starts_counting(self): advance, next_at = tick_decision(False, None, 60.0, 500.0) self.assertFalse(advance) self.assertEqual(next_at, 560.0) def test_tick_decision_fires_when_due(self): advance, next_at = tick_decision(False, 100.0, 60.0, 100.0) self.assertTrue(advance) self.assertEqual(next_at, 160.0) def test_tick_decision_keeps_the_deadline_while_waiting(self): advance, next_at = tick_decision(False, 100.0, 60.0, 40.0) self.assertFalse(advance) self.assertEqual(next_at, 100.0, "没到点不要重排,否则永远到不了") def test_scheduler_sets_and_clears_the_deadline(self): engine = make_engine() scheduler = Scheduler(engine) self.assertIsNone(engine.next_tick_at) scheduler.start() try: self.assertIsNotNone(engine.next_tick_at) finally: scheduler.stop() self.assertIsNone(engine.next_tick_at) def test_scheduler_advances_days_on_its_own(self): """真的让它跑一小会儿:把 tick 手动压到 50ms。""" engine = make_engine() engine.tick_seconds = 0.05 scheduler = Scheduler(engine) scheduler.start() try: deadline = time.time() + 3.0 while engine.state.day < 2 and time.time() < deadline: time.sleep(0.05) finally: scheduler.stop() self.assertGreaterEqual(engine.state.day, 2, "时间应当自己往前走") def test_disabled_scheduler_does_nothing(self): engine = make_engine() scheduler = Scheduler(engine, enabled=False) scheduler.start() self.assertIsNone(engine.next_tick_at) self.assertIsNone(scheduler._thread) def test_paused_engine_does_not_advance(self): engine = make_engine() engine.tick_seconds = 0.05 engine.pause(True) scheduler = Scheduler(engine) scheduler.start() try: time.sleep(0.4) finally: scheduler.stop() self.assertEqual(engine.state.day, 1) if __name__ == "__main__": unittest.main(verbosity=2)