Files
SimpleFarm/tests/test_engine.py
T
snowy-yang cf24cb43f1 feat: 加天气 / 谷仓 / 建筑三套系统,收获改为入库 + 择时卖出
动手前先修掉了两处「死机制」,新增的三个系统各自激活一个:

* 天气:小麦 2 天成熟、初始水分 2、每天只掉 1 点,等于永远不需要浇水。
  现在水分消耗由天气决定(晴 1 / 多云 0 / 干旱 2,雨天反补 3),而
  `state.forecast` 把明天的天气提前告诉你——「今晚浇不浇」成了能算的决策。
  5 种天气、权重合计 100、同种不连出;暴风雨会毁未熟作物,温室免疫。
* 谷仓:市场波动原本纯装饰(收获当场按市价变现,没有择时空间)。现在收获与
  收取只进 `state.inventory`,靠新增的 `sell` 换金币,价格波动才真正参与决策。
  仓满时 `_store()` 抛 `granary_full` 且不改状态,作物原样留在地里。
* 建筑:新增 `build`,买的是「以后不用天天手动做」——水井自动浇干地、
  喂食器自动喂食(只吃库存饲料,绝不自动花钱)、温室、谷仓扩容。
  每天在 `advance_day` 里先于生长执行,同类越造越贵(×1.6)。

存档升到 `SAVE_VERSION=2`,配 `MIN_SAVE_VERSION=1` 向后兼容,旧档缺的新字段
一律走默认值,不作废玩家手里的档。`advance_day(weather=...)` 是留给测试的
确定性入口。

顺带修两个真 bug:
* `daemon.py` 三处 `makedirs(os.path.dirname(path))`:`--pid-file pid.txt`
  这种只给文件名的用法会让 dirname 变成空串,启动阶段直接崩。抽出
  `ensure_parent_dir()` 兜底。
* `RemoteFarm.snapshot()` 是本地缓存,非实时客户端轮询会一直读到旧数据,
  看起来像服务器卡死。补 docstring 指明要自己调 `refresh()`。

质量门禁:ruff format/check 全绿、pyright 0 错误、503 项测试 OK(skip 35)、
bench 无回归(brief 19.5µs / snapshot 48.5µs / POST full=1 400µs)。
另起真实服务器(--tick 3 + 预置存档)跑了 38 项端到端验收,全过。
2026-09-19 17:02:33 +08:00

918 lines
37 KiB
Python

"""引擎层测试:游戏规则、边界与存档。
这一层是"纯规则",不依赖 FastAPI / uvicorn,所以能单独跑:
uv run python -m unittest tests.test_engine -v
设计原则:**只通过公开接口断言行为**。私有方法(``_gain_exp`` 之类)只在
"没有公开路径能触达"时才碰,且要说明为什么。
"""
from __future__ import annotations
import json
import os
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 SAVE_VERSION, delete_save, load_state, 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):
def setUp(self):
self.dir = tempfile.mkdtemp(prefix="simplefarm-test-")
self.path = os.path.join(self.dir, "save.json")
def tearDown(self):
delete_save(self.path)
def write_raw(self, payload) -> str:
with open(self.path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False)
return self.path
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
save_state(engine.state, self.path)
loaded, message = load_state(self.path)
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.assertIn("第 7 天", message)
def test_saved_file_is_readable_json_with_version(self):
engine = make_engine()
save_state(engine.state, self.path)
with open(self.path, encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(payload["version"], SAVE_VERSION)
self.assertIsInstance(payload["state"], dict)
def test_missing_file_is_not_an_error(self):
state, message = load_state(self.path)
self.assertIsNone(state)
self.assertIn("未找到存档", message)
def test_broken_json_degrades_instead_of_raising(self):
with open(self.path, "w", encoding="utf-8") as handle:
handle.write("{ this is not json")
state, message = load_state(self.path)
self.assertIsNone(state)
self.assertIn("无法解析", message)
def test_wrong_version_is_refused(self):
self.write_raw({"version": SAVE_VERSION + 1, "state": {}})
state, message = load_state(self.path)
self.assertIsNone(state)
self.assertIn("版本不兼容", message)
def test_root_must_be_an_object(self):
self.write_raw([1, 2, 3])
state, message = load_state(self.path)
self.assertIsNone(state)
self.assertIn("根节点不是对象", message)
def test_state_field_is_required(self):
self.write_raw({"version": SAVE_VERSION})
state, message = load_state(self.path)
self.assertIsNone(state)
self.assertIn("缺少 state", message)
def test_unexpected_state_shape_degrades(self):
self.write_raw({"version": SAVE_VERSION, "state": {"plots": "nonsense", "animals": 7}})
state, message = load_state(self.path)
self.assertIsNone(state)
self.assertIn("存档内容异常", message)
def test_atomic_write_leaves_no_temp_file(self):
engine = make_engine()
save_state(engine.state, self.path)
self.assertFalse(os.path.exists(self.path + ".tmp"))
def test_delete_save_reports_truthfully(self):
save_state(make_engine().state, self.path)
self.assertTrue(delete_save(self.path))
self.assertFalse(os.path.exists(self.path))
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 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)