为什么:自动化建筑只有水井和喂食器,中期以后每天的操作只剩浇水喂食 的重复劳动。新设施把"替代行动点动作"的思路推到完整流水线: 浇水(井/喷灌)→ 收割机收熟地 → 磨坊把陈粮变饲料 → 喂食器喂牲口 → 售货机趁高价清库存,人不在也能持续产出。 - config.BUILDINGS 注册 4 座:喷灌系统 2800 金(全量浇水)、 自动收割机 900 金 ×3(每天各收 2 块熟地,刻意不给经验——机器收的 不算劳动,给手动玩法留价值)、自动售货机 1200 金(市价 > 基准价才 出手、每天上限 5 份、越贵越先卖)、饲料磨坊 500 金(1 份最便宜的 作物磨 3 份饲料)。 - engine._run_buildings 扩成六段流水线,顺序即依赖:磨坊排在喂食器前, 早上磨的饲料当天就能喂上;收割机遇满仓停摆、作物留地(同喂食器 缺料停摆哲学——自动系统绝不硬来,更绝不自动花钱)。 - webui / build 动作 / catalog 全部由 config.BUILDINGS 驱动,零改动。 - 测试 +6(喷灌全量浇水、收割入库且无经验、满仓停摆留作物、磨坊挑 最便宜、磨坊→喂食器同日链路、售货机阈值与配额)。修复断言时确认 两个既有行为:引擎自带 START_FEED=5;advance_day 末尾会把动物 fed 复位("喂到了"的证据是 pending 累计而非 fed 本身)。 - 门禁:534 项 OK(skip 39)、ruff/pyright 全绿。
1168 lines
47 KiB
Python
1168 lines
47 KiB
Python
"""引擎层测试:游戏规则、边界与存档。
|
||
|
||
这一层是"纯规则",不依赖 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)
|