移除行动点系统,倒计时本地走秒,字体优化

为什么:游戏的时间(40 分钟一天、只能自然流动)本身就是唯一的行动节奏
阀门,再叠一层"每天 10 点行动点"纯属重复惩罚——浇水还要体力既不拟真
也不好玩。删掉之后,稀缺的只剩时间本身,放置玩法的定位反而更纯粹。

改动范围(引擎到界面连根拔):
- config 删 START_AP/AP_COST;models 删 ap 字段与 max_ap,存档 payload
  里的旧 ap 键被 from_dict 自然忽略;
- engine 删 _spend_ap 与全部 8 个调用点、每日恢复逻辑与日志;
  available_actions 不再按行动点筛,cost 只剩金币;
- storage 建表去掉 ap 列,老库在 connect() 里 ALTER TABLE DROP COLUMN
  无痛迁移(实测 saves/farm.db 已自动迁完);
- bot 删 AP 分支(无 AP 后 available_actions 恒可用);client/TUI/CLI/
  README/帮助文本同步清理。

网页端顺带两件事:
- "下一天"倒计时改为客户端本地走秒——SSE 只在有变化时推送,以前倒计时
  只在推送瞬间跳变,现在每秒都在动(记基点 + 1s 定时器补齐);
- 字体优化:页面字体栈补齐 MiSans/HarmonyOS Sans/PingFang,正文字号
  14→15,统计数字 tabular-nums;canvas 小字统一字体栈并加粗提对比度。

测试适配:删 2 个 AP 专属用例(无 AP 可测)、改名/收敛 5 个,
532 项全绿(skip 39),ruff/pyright 干净。真机验证:老库迁移、
统计条无行动点、倒计时逐秒递减(39:04→39:02)、动作不再报 no_ap。
This commit is contained in:
snowy-yang committed 2026-09-25 22:44:08 +08:00
1 parent 92b1a628b1
commit 15d290a60a
18 files changed
+82 -179

No files matched your search

+8 -8
View File
@@ -58,7 +58,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
| 系统 | 规则 | | 系统 | 规则 |
| --- | --- | | --- | --- |
| **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 | | **时间** | 每个牧场每 **40 分钟**自动推进一天(`--tick` 可调),作物生长、牲畜产出、市场波动都在此刻结算 |
| **行动点** | 每天 10 点;播种 / 浇水 / 喂食各 1 点,收获、收取、卖货、建造都免费。用完只能等第二天——时间只能自然流动,没有手动快进 | | **节奏** | 动作**没有**行动点/体力限制,想种就种——稀缺的只有时间本身:一天就是 40 分钟,只能自然流动,没有手动快进 |
| **天气** | 每天从晴 / 多云 / 雨 / 干旱 / 暴风雨里抽一种(同种不连着出),而且**明天的天气提前可见**——「今晚浇不浇」因此是个能算的决策 | | **天气** | 每天从晴 / 多云 / 雨 / 干旱 / 暴风雨里抽一种(同种不连着出),而且**明天的天气提前可见**——「今晚浇不浇」因此是个能算的决策 |
| **水分** | 每天按天气消耗:晴 1、多云 0、干旱 2,雨天反补 3(上限 3);水分为 0 就停止生长。播种自带 2 点,只够晴天撑两天,所以下种前得先看预报 | | **水分** | 每天按天气消耗:晴 1、多云 0、干旱 2,雨天反补 3(上限 3);水分为 0 就停止生长。播种自带 2 点,只够晴天撑两天,所以下种前得先看预报 |
| **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 | | **作物** | 小麦 → 胡萝卜 → 番茄 → 玉米 → 南瓜 → 草莓,按等级解锁,售价越高成熟越慢 |
@@ -73,7 +73,7 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。 净资产在 TUI 顶部实时显示,`goal_index` 表示已解锁的里程碑数。
> **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。 > **关于节奏**:这是一款“放置型”农场,一天 40 分钟是刻意设计的,不是笔误。
> 每天 10 点行动点,用完就是等时间流动——所以一天的玩法是“上来干十件事,然后离开”。 > 动作没有体力限制,但作物、牲畜、市场的进展都随换天推进——所以一天的玩法是“上来把事干完,然后离开”。
> 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时);断水会停长,所以实际天数看天吃饭—— > 小麦 2 天(80 分钟)成熟,草莓 10 天(约 6.7 小时);断水会停长,所以实际天数看天吃饭——
> 好在预报提前一天就给你,来得及浇。想调试就把节奏调快: > 好在预报提前一天就给你,来得及浇。想调试就把节奏调快:
> `uv run main.py play --tick 3`(一天 3 秒)。 > `uv run main.py play --tick 3`(一天 3 秒)。
@@ -125,18 +125,18 @@ uv run main.py solo # 本地引擎 + 终端界面,不开
```bash ```bash
$ uv run main.py actions $ uv run main.py actions
牧场 default · 第 3 天 · 金币 96 · 行动点 7/10 牧场 default · 第 3 天 · 金币 96
共 9 个候选动作: 共 9 个候选动作:
[ 1] harvest {"plot": 2} (免费) [ 1] harvest {"plot": 2} (免费)
2 号地的胡萝卜已成熟 2 号地的胡萝卜已成熟
[ 2] plant {"plot": 4, "crop": "wheat"} (gold:5 ap:1) [ 2] plant {"plot": 4, "crop": "wheat"} (gold:5)
4 号地空闲,可种小麦 4 号地空闲,可种小麦
... ...
$ uv run main.py do harvest '{"plot":2}' $ uv run main.py do harvest '{"plot":2}'
✓ 收获 2 份胡萝卜(丰收!),获得 104 金币 (0.01s) ✓ 收获 2 份胡萝卜(丰收!),获得 104 金币 (0.01s)
第 3 天 · 金币 200 · 行动点 7/10 · 净资产 512.0 第 3 天 · 金币 200 · 净资产 512.0
``` ```
--- ---
@@ -189,7 +189,7 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
plant(plot,crop) water(plot) harvest(plot) harvest_all() plant(plot,crop) water(plot) harvest(plot) harvest_all()
buy_animal(kind) feed(animal_id,quantity?) collect(animal_id) buy_animal(kind) feed(animal_id,quantity?) collect(animal_id)
buy_feed(quantity?) buy_plot() pause(paused?) buy_feed(quantity?) buy_plot() pause(paused?)
sell(key?,quantity?) build(key) ← 谷仓变现与建造,0 行动点 sell(key?,quantity?) build(key) ← 谷仓变现与建造
``` ```
> `save` / `reset` 是**存档类动作**,不进通用动作表:它们必须由牧场会话处理, > `save` / `reset` 是**存档类动作**,不进通用动作表:它们必须由牧场会话处理,
@@ -215,7 +215,7 @@ curl -X POST 'http://127.0.0.1:8765/api/batch?farm=alice' \
{"action":"buy_feed","params":{"quantity":10}}, {"action":"buy_feed","params":{"quantity":10}},
{"action":"plant","params":{"plot":1,"crop":"carrot"}}]}' {"action":"plant","params":{"plot":1,"crop":"carrot"}}]}'
# 收成变现 / 造建筑:都是 0 行动点,行动点用完了照样能做 # 收成变现 / 造建筑
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \ curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \
-H 'Content-Type: application/json' -d '{"action":"sell"}' -H 'Content-Type: application/json' -d '{"action":"sell"}'
curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \ curl -X POST 'http://127.0.0.1:8765/api/actions?farm=alice' \
@@ -481,7 +481,7 @@ None 误用、属性不存在、参数类型不符。本项目的状态快照是
* 未知动作 → `400 unknown_action`,并列出可用动作; * 未知动作 → `400 unknown_action`,并列出可用动作;
* 缺必需参数 → `missing_param`;多传参数 → `bad_param`(明确拒绝而不是静默忽略); * 缺必需参数 → `missing_param`;多传参数 → `bad_param`(明确拒绝而不是静默忽略);
* 类型错误 → `bad_param`,但宽容地把 `"3"` 这类数字字符串转成整数(命令行/表单常见); * 类型错误 → `bad_param`,但宽容地把 `"3"` 这类数字字符串转成整数(命令行/表单常见);
* 越界地块 → `no_such_plot`;未解锁作物 → `locked`;金币/行动点不足 → `not_enough_gold` / `no_ap`; * 越界地块 → `no_such_plot`;未解锁作物 → `locked`;金币不足 → `not_enough_gold`;
* 牧场名会被用作文件名 → 白名单校验,挡掉 `../`、`a/b` 之类的路径穿越; * 牧场名会被用作文件名 → 白名单校验,挡掉 `../`、`a/b` 之类的路径穿越;
* 请求体上限 64KB、批量动作上限 50、`limit` 参数自动夹取到合法区间。 * 请求体上限 64KB、批量动作上限 50、`limit` 参数自动夹取到合法区间。
+3 -14
View File
@@ -13,8 +13,8 @@
它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑—— 它只依赖 :mod:`farm.client`,因此既能在本机跑,也能从别处连上服务器跑——
换个 ``--url`` 就是一个独立的外挂玩家。 换个 ``--url`` 就是一个独立的外挂玩家。
节奏完全由服务器时钟决定。服务器**没有**"手动推进一天"的接口,所以行动点 节奏完全由服务器时钟决定。服务器**没有**"手动推进一天"的接口,没事可做时
用完之后它只能等时间流动(或收工)::meth:`RuleBot.run` 的 ``wait_seconds`` 它只能等时间流动(或收工)::meth:`RuleBot.run` 的 ``wait_seconds``
就是"最多等多久",默认 0——一天 40 分钟,干等不划算。 就是"最多等多久",默认 0——一天 40 分钟,干等不划算。
""" """
@@ -69,7 +69,6 @@ class RuleBot:
params = action.get("params") or {} params = action.get("params") or {}
gold = state.get("gold", 0) gold = state.get("gold", 0)
feed = state.get("feed", 0) feed = state.get("feed", 0)
ap = state.get("ap", 0)
if name == "harvest": if name == "harvest":
return 100.0 return 100.0
@@ -101,7 +100,7 @@ class RuleBot:
(a for a in catalog.get("animals", []) if a["key"] == params.get("kind")), None (a for a in catalog.get("animals", []) if a["key"] == params.get("kind")), None
) )
cost = info["cost"] if info else 0 cost = info["cost"] if info else 0
if cost and gold >= cost * 3 and ap >= 1: if cost and gold >= cost * 3:
return 30.0 + (info.get("profit_per_day", 0) if info else 0) return 30.0 + (info.get("profit_per_day", 0) if info else 0)
return 3.0 return 3.0
if name == "sell": if name == "sell":
@@ -213,16 +212,6 @@ class RuleBot:
self.say("到时间了,收工。") self.say("到时间了,收工。")
break break
state = self.client.snapshot()
if state.get("ap", 0) <= 0:
# 行动点用完 ≠ 没事可做:sell / build / harvest / collect / buy_*
# 都是 0 消耗,而 available_actions 本身就按行动点筛过一遍了,
# 所以照常挑一次最优的;真的没得选才等下一天。
if self.step() is None and not self.wait_for_next_day(wait_seconds):
break
idle = 0
continue
did = self.step() did = self.step()
if did is None: if did is None:
idle += 1 idle += 1
-5
View File
@@ -218,10 +218,6 @@ class StateView:
def exp(self) -> int: def exp(self) -> int:
return int(self._d.get("exp", 0)) return int(self._d.get("exp", 0))
@property
def ap(self) -> int:
return int(self._d.get("ap", 0))
@property @property
def feed(self) -> int: def feed(self) -> int:
return int(self._d.get("feed", 0)) return int(self._d.get("feed", 0))
@@ -315,7 +311,6 @@ class RemoteFarm:
"granary": {}, "granary": {},
"feed_price": config.FEED_UNIT_PRICE, "feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY, "water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST,
} }
self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP) self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP)
self._log_seq = 0 self._log_seq = 0
-15
View File
@@ -14,7 +14,6 @@ from typing import Any
START_GOLD = 120 # 初始金币 START_GOLD = 120 # 初始金币
START_LEVEL = 1 # 初始等级 START_LEVEL = 1 # 初始等级
START_AP = 10 # 每天行动点上限
START_FEED = 5 # 初始饲料库存 START_FEED = 5 # 初始饲料库存
BASE_PLOTS = 6 # 初始土地数量 BASE_PLOTS = 6 # 初始土地数量
@@ -36,20 +35,6 @@ MAX_FEED_PURCHASE = 200 # 单次购买饲料的数量上限
# 每级提供的土地上限(基础 6 + 2/级,封顶 MAX_PLOTS) # 每级提供的土地上限(基础 6 + 2/级,封顶 MAX_PLOTS)
PLOTS_PER_LEVEL = 2 PLOTS_PER_LEVEL = 2
# 行动点消耗表
AP_COST = {
"plant": 1,
"water": 1,
"harvest": 0,
"feed": 1,
"collect": 0,
"buy_animal": 0,
"buy_plot": 0,
"buy_feed": 0,
"build": 0,
"sell": 0,
}
# 经验曲线:升到 level+1 所需经验 # 经验曲线:升到 level+1 所需经验
def exp_to_next(level: int) -> int: def exp_to_next(level: int) -> int:
+17 -43
View File
@@ -129,8 +129,6 @@ class FarmEngine:
"level": state.level, "level": state.level,
"exp": state.exp, "exp": state.exp,
"exp_needed": state.exp_needed, "exp_needed": state.exp_needed,
"ap": state.ap,
"max_ap": state.max_ap,
"feed": state.feed, "feed": state.feed,
"plot_count": len(state.plots), "plot_count": len(state.plots),
"max_plots": state.max_plots, "max_plots": state.max_plots,
@@ -216,7 +214,6 @@ class FarmEngine:
}, },
"feed_price": config.FEED_UNIT_PRICE, "feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY, "water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST,
} }
# ---- catalog 的三个子表。拆开是因为它们的"变化频率"各不相同: # ---- catalog 的三个子表。拆开是因为它们的"变化频率"各不相同:
@@ -299,7 +296,7 @@ class FarmEngine:
"""列出当前**合法**的动作,供 AI 直接决策使用。 """列出当前**合法**的动作,供 AI 直接决策使用。
每个动作包含:``action``(接口名)、``params``(参数模板)、 每个动作包含:``action``(接口名)、``params``(参数模板)、
``cost``(金币/行动点开销)、``reason``(为什么值得做)。 ``cost``(金币开销)、``reason``(为什么值得做)。
""" """
with self._lock: with self._lock:
state = self.state state = self.state
@@ -314,15 +311,12 @@ class FarmEngine:
# ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试 # ---- available_actions 的三个分区;拆开是为了各自能独立阅读/测试
def _plot_actions(self, state: GameState) -> list[dict[str, Any]]: def _plot_actions(self, state: GameState) -> list[dict[str, Any]]:
"""每块地当前能做的事:播种 / 收获 / 浇水。""" """每块地当前能做的事:播种 / 收获 / 浇水。"""
# 能种什么只取决于等级、金币和行动点,与**具体是哪块地**无关, # 能种什么只取决于等级和金币,与**具体是哪块地**无关,
# 所以整趟只筛一次,不要放进地块循环里做 O(地块 × 作物) 次判断。 # 所以整趟只筛一次,不要放进地块循环里做 O(地块 × 作物) 次判断。
plant_ap = config.AP_COST["plant"]
plantable = [ plantable = [
(key, info) (key, info)
for key, info in config.CROPS.items() for key, info in config.CROPS.items()
if not self._is_locked(info) if not self._is_locked(info) and state.gold >= info["seed_cost"]
and state.gold >= info["seed_cost"]
and state.ap >= plant_ap
] ]
actions: list[dict[str, Any]] = [] actions: list[dict[str, Any]] = []
for plot in state.plots: for plot in state.plots:
@@ -333,7 +327,7 @@ class FarmEngine:
{ {
"action": "plant", "action": "plant",
"params": {"plot": position, "crop": key}, "params": {"plot": position, "crop": key},
"cost": {"gold": info["seed_cost"], "ap": plant_ap}, "cost": {"gold": info["seed_cost"]},
"reason": f"{position} 号地空闲,可种{info['name']}", "reason": f"{position} 号地空闲,可种{info['name']}",
} }
) )
@@ -342,11 +336,11 @@ class FarmEngine:
{ {
"action": "harvest", "action": "harvest",
"params": {"plot": position}, "params": {"plot": position},
"cost": {"gold": 0, "ap": config.AP_COST["harvest"]}, "cost": {"gold": 0},
"reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟", "reason": f"{position} 号地的{config.CROPS[plot.crop]['name']}已成熟",
} }
) )
elif self._needs_water(plot) and state.ap >= config.AP_COST["water"]: elif self._needs_water(plot):
reason = ( reason = (
f"{position} 号地缺水,作物已停止生长" f"{position} 号地缺水,作物已停止生长"
if plot.moisture <= 0 if plot.moisture <= 0
@@ -356,7 +350,7 @@ class FarmEngine:
{ {
"action": "water", "action": "water",
"params": {"plot": position}, "params": {"plot": position},
"cost": {"gold": 0, "ap": config.AP_COST["water"]}, "cost": {"gold": 0},
"reason": reason, "reason": reason,
} }
) )
@@ -371,19 +365,19 @@ class FarmEngine:
{ {
"action": "collect", "action": "collect",
"params": {"animal_id": animal.aid}, "params": {"animal_id": animal.aid},
"cost": {"gold": 0, "ap": config.AP_COST["collect"]}, "cost": {"gold": 0},
"reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取", "reason": f"{animal.name}有 {animal.pending} 份{animal.product}待收取",
} }
) )
elif not animal.fed: elif not animal.fed:
need = animal.info["feed_per_day"] need = animal.info["feed_per_day"]
cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE cost = 0 if state.feed >= need else need * config.FEED_EMERGENCY_PRICE
if state.gold >= cost and state.ap >= config.AP_COST["feed"]: if state.gold >= cost:
actions.append( actions.append(
{ {
"action": "feed", "action": "feed",
"params": {"animal_id": animal.aid}, "params": {"animal_id": animal.aid},
"cost": {"gold": cost, "ap": config.AP_COST["feed"]}, "cost": {"gold": cost},
"reason": f"{animal.name}(#{animal.aid})今天还没喂食", "reason": f"{animal.name}(#{animal.aid})今天还没喂食",
} }
) )
@@ -398,7 +392,7 @@ class FarmEngine:
{ {
"action": "buy_plot", "action": "buy_plot",
"params": {}, "params": {},
"cost": {"gold": price, "ap": config.AP_COST["buy_plot"]}, "cost": {"gold": price},
"reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地", "reason": f"可花 {price} 金币开垦第 {len(state.plots) + 1} 块地",
} }
) )
@@ -408,7 +402,7 @@ class FarmEngine:
{ {
"action": "buy_feed", "action": "buy_feed",
"params": {"quantity": config.FEED_BULK}, "params": {"quantity": config.FEED_BULK},
"cost": {"gold": bulk_cost, "ap": config.AP_COST["buy_feed"]}, "cost": {"gold": bulk_cost},
"reason": "饲料库存偏低,建议补货", "reason": "饲料库存偏低,建议补货",
} }
) )
@@ -421,7 +415,7 @@ class FarmEngine:
{ {
"action": "buy_animal", "action": "buy_animal",
"params": {"kind": key}, "params": {"kind": key},
"cost": {"gold": info["cost"], "ap": config.AP_COST["buy_animal"]}, "cost": {"gold": info["cost"]},
"reason": f"可购买{info['name']}({info['cost']} 金币)", "reason": f"可购买{info['name']}({info['cost']} 金币)",
} }
) )
@@ -457,7 +451,7 @@ class FarmEngine:
{ {
"action": "sell", "action": "sell",
"params": {"key": key, "quantity": count}, "params": {"key": key, "quantity": count},
"cost": {"gold": 0, "ap": config.AP_COST["sell"]}, "cost": {"gold": 0},
"reason": ";".join(reasons), "reason": ";".join(reasons),
} }
) )
@@ -474,7 +468,7 @@ class FarmEngine:
{ {
"action": "build", "action": "build",
"params": {"key": key}, "params": {"key": key},
"cost": {"gold": entry["next_cost"], "ap": config.AP_COST["build"]}, "cost": {"gold": entry["next_cost"]},
"reason": f"可造第 {entry['owned'] + 1} 座{entry['name']}:{entry['effect']}", "reason": f"可造第 {entry['owned'] + 1} 座{entry['name']}:{entry['effect']}",
} }
) )
@@ -531,17 +525,6 @@ class FarmEngine:
return animal return animal
raise GameError("no_such_animal", f"没有编号为 {aid} 的动物") raise GameError("no_such_animal", f"没有编号为 {aid} 的动物")
def _spend_ap(self, action: str) -> None:
cost = config.AP_COST.get(action, 0)
if cost and self.state.ap < cost:
raise GameError(
"no_ap",
f"行动点不足:{action} 需要 {cost} 点,当前只剩 {self.state.ap} 点。"
f"等一天的时间自然走完就会恢复。",
)
if cost:
self.state.ap -= cost
def _spend_gold(self, amount: int, what: str) -> None: def _spend_gold(self, amount: int, what: str) -> None:
amount = int(amount) amount = int(amount)
if amount < 0: if amount < 0:
@@ -639,7 +622,6 @@ class FarmEngine:
) )
self._spend_gold(info["seed_cost"], f"购买{info['name']}种子") self._spend_gold(info["seed_cost"], f"购买{info['name']}种子")
self._spend_ap("plant")
plot.crop = crop plot.crop = crop
plot.growth = 0.0 plot.growth = 0.0
@@ -666,7 +648,6 @@ class FarmEngine:
"already_watered", "already_watered",
f"{plot.index + 1} 号地水分已满({plot.moisture}/{config.WATER_CAPACITY})", f"{plot.index + 1} 号地水分已满({plot.moisture}/{config.WATER_CAPACITY})",
) )
self._spend_ap("water")
plot.moisture = config.WATER_CAPACITY plot.moisture = config.WATER_CAPACITY
self.add_log(f"给 {plot.index + 1} 号地浇水(水分 {config.WATER_CAPACITY})", "action") self.add_log(f"给 {plot.index + 1} 号地浇水(水分 {config.WATER_CAPACITY})", "action")
self._notify() self._notify()
@@ -684,7 +665,6 @@ class FarmEngine:
) )
crop_key = plot.crop crop_key = plot.crop
info = config.CROPS[crop_key] info = config.CROPS[crop_key]
self._spend_ap("harvest")
quantity = self._roll_yield() quantity = self._roll_yield()
# 先入库再清地:谷仓满了会在这里抛 granary_full,作物原地不动, # 先入库再清地:谷仓满了会在这里抛 granary_full,作物原地不动,
@@ -729,7 +709,6 @@ class FarmEngine:
raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)") raise GameError("pasture_full", f"牧场已满(上限 {config.MAX_ANIMALS} 只)")
self._spend_gold(info["cost"], f"购买{info['name']}") self._spend_gold(info["cost"], f"购买{info['name']}")
self._spend_ap("buy_animal")
animal = Animal(kind) animal = Animal(kind)
self.state.animals.append(animal) self.state.animals.append(animal)
@@ -766,7 +745,6 @@ class FarmEngine:
self.state.feed += shortfall self.state.feed += shortfall
self.add_log(f"饲料不足,临时高价补货 {shortfall} 份(-{cost} 金币)", "warn") self.add_log(f"饲料不足,临时高价补货 {shortfall} 份(-{cost} 金币)", "warn")
self._spend_ap("feed")
self.state.feed -= quantity self.state.feed -= quantity
animal.fed = True animal.fed = True
animal.starve = 0 animal.starve = 0
@@ -788,7 +766,6 @@ class FarmEngine:
f"{animal.name} #{animal.aid} 暂时没有可收取的{animal.product}" f"{animal.name} #{animal.aid} 暂时没有可收取的{animal.product}"
f"(还需 {animal.days_to_product} 天)", f"(还需 {animal.days_to_product} 天)",
) )
self._spend_ap("collect")
quantity = animal.pending quantity = animal.pending
key = f"animal:{animal.kind}" key = f"animal:{animal.kind}"
# 与收获同理:先确认装得下,再清空待收,免得产出凭空消失 # 与收获同理:先确认装得下,再清空待收,免得产出凭空消失
@@ -850,7 +827,6 @@ class FarmEngine:
f"土地已达当前等级上限({self.state.max_plots} 块),升级后可继续开垦", f"土地已达当前等级上限({self.state.max_plots} 块),升级后可继续开垦",
) )
self._spend_gold(price, "开垦新土地") self._spend_gold(price, "开垦新土地")
self._spend_ap("buy_plot")
plot = Plot(len(self.state.plots)) plot = Plot(len(self.state.plots))
self.state.plots.append(plot) self.state.plots.append(plot)
self.state.plots_bought += 1 self.state.plots_bought += 1
@@ -971,7 +947,6 @@ class FarmEngine:
raise GameError("build_limit", f"{entry['name']}已建满({entry['max_level']} 座)") raise GameError("build_limit", f"{entry['name']}已建满({entry['max_level']} 座)")
self._spend_gold(entry["next_cost"], f"建造{entry['name']}") self._spend_gold(entry["next_cost"], f"建造{entry['name']}")
self._spend_ap("build")
self.state.buildings[key] = entry["owned"] + 1 self.state.buildings[key] = entry["owned"] + 1
built = self._building_entry(self.state, key) built = self._building_entry(self.state, key)
self.add_log( self.add_log(
@@ -1070,7 +1045,7 @@ class FarmEngine:
顺序是一条流水线:浇水(水井/喷灌)保住作物 → 收割机把熟了的收进 顺序是一条流水线:浇水(水井/喷灌)保住作物 → 收割机把熟了的收进
谷仓 → 磨坊把陈粮磨成饲料 → 喂食器喂牲口 → 售货机趁高价清库存。 谷仓 → 磨坊把陈粮磨成饲料 → 喂食器喂牲口 → 售货机趁高价清库存。
共同的设计底线:**只替代玩家本来要花行动点做的事,绝不自动花钱** 共同的设计底线:**只替代玩家本来要花工夫做的事,绝不自动花钱**
——喂食器缺饲料停摆、收割机遇满仓停摆,都宁可停也不硬来。 ——喂食器缺饲料停摆、收割机遇满仓停摆,都宁可停也不硬来。
""" """
return ( return (
@@ -1246,7 +1221,6 @@ class FarmEngine:
state = self.state state = self.state
info = self._resolve_weather(state, weather) info = self._resolve_weather(state, weather)
state.day += 1 state.day += 1
state.ap = state.max_ap
events: list[str] = [] events: list[str] = []
# ---- 建筑自动化。必须排在生长结算之前:水井救的正是"今天就断水"的 # ---- 建筑自动化。必须排在生长结算之前:水井救的正是"今天就断水"的
@@ -1303,7 +1277,7 @@ class FarmEngine:
self.add_log(text, "warn") self.add_log(text, "warn")
today = f"第 {state.day} 天开始({reason}),今日{info['name']}" today = f"第 {state.day} 天开始({reason}),今日{info['name']}"
if events: if events:
self.add_log(f"{today},行动点已恢复", "info") self.add_log(f"{today},新的一天开始了", "info")
else: else:
self.add_log(f"{today},一切安好", "info") self.add_log(f"{today},一切安好", "info")
-8
View File
@@ -199,7 +199,6 @@ class GameState:
self.gold: int = config.START_GOLD self.gold: int = config.START_GOLD
self.level: int = config.START_LEVEL self.level: int = config.START_LEVEL
self.exp: int = 0 self.exp: int = 0
self.ap: int = config.START_AP
self.feed: int = config.START_FEED self.feed: int = config.START_FEED
self.plots: list[Plot] = [Plot(i) for i in range(config.BASE_PLOTS)] self.plots: list[Plot] = [Plot(i) for i in range(config.BASE_PLOTS)]
self.animals: list[Animal] = [] self.animals: list[Animal] = []
@@ -231,10 +230,6 @@ class GameState:
self.forecast: str = config.DEFAULT_WEATHER self.forecast: str = config.DEFAULT_WEATHER
# ---------------------------------------------------------------- 派生属性 # ---------------------------------------------------------------- 派生属性
@property
def max_ap(self) -> int:
return config.START_AP
@property @property
def max_plots(self) -> int: def max_plots(self) -> int:
"""当前等级允许持有的土地上限。""" """当前等级允许持有的土地上限。"""
@@ -327,8 +322,6 @@ class GameState:
"level": self.level, "level": self.level,
"exp": self.exp, "exp": self.exp,
"exp_needed": self.exp_needed, "exp_needed": self.exp_needed,
"ap": self.ap,
"max_ap": self.max_ap,
"feed": self.feed, "feed": self.feed,
"inventory": dict(sorted(self.inventory.items())), "inventory": dict(sorted(self.inventory.items())),
"storage_used": self.storage_used, "storage_used": self.storage_used,
@@ -362,7 +355,6 @@ class GameState:
state.level = max(1, int(data.get("level", 1))) state.level = max(1, int(data.get("level", 1)))
state.exp = max(0, int(data.get("exp", 0))) state.exp = max(0, int(data.get("exp", 0)))
state.feed = max(0, int(data.get("feed", 0))) state.feed = max(0, int(data.get("feed", 0)))
state.ap = int(clamp(int(data.get("ap", state.max_ap)), 0, state.max_ap))
plots = [Plot.from_dict(p) for p in data.get("plots", []) if isinstance(p, dict)] plots = [Plot.from_dict(p) for p in data.get("plots", []) if isinstance(p, dict)]
# 保证至少有初始土地,且不超过硬上限 # 保证至少有初始土地,且不超过硬上限
+2 -4
View File
@@ -115,8 +115,6 @@ BRIEF_KEYS = (
"level", "level",
"exp", "exp",
"exp_needed", "exp_needed",
"ap",
"max_ap",
"feed", "feed",
"plot_count", "plot_count",
"max_plots", "max_plots",
@@ -158,8 +156,8 @@ def rules_text() -> str:
"==========================================\n" "==========================================\n"
f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟" f"1. 时间:每个牧场每 {config.DEFAULT_TICK_SECONDS / 60:.0f} 分钟"
f"({config.DEFAULT_TICK_SECONDS:.0f} 秒)自动推进一天,可配置。" f"({config.DEFAULT_TICK_SECONDS:.0f} 秒)自动推进一天,可配置。"
f"每天开始时行动点恢复为 {config.START_AP} 点,作物生长、牲畜产出、市场价格都在此刻结算。\n" f"作物生长、牲畜产出、市场价格都在此刻结算。\n"
f"2. 行动点:播种/浇水/喂食各消耗 1 点,收获与收取产出免费。行动点用完只能等第二天——" "2. 节奏:动作没有行动点/体力限制,想种就种——稀缺的只有时间本身。"
"时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n" "时间只能自然流动,没有任何「手动推进一天」的接口,别白费请求。\n"
f"3. 天气与水分:每天随机一种天气(晴/多云/雨/干旱/暴风雨)。作物每天按天气蒸发" f"3. 天气与水分:每天随机一种天气(晴/多云/雨/干旱/暴风雨)。作物每天按天气蒸发"
f" 0~2 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长;" f" 0~2 点水分(上限 {config.WATER_CAPACITY}),水分为 0 时停止生长;"
+22 -9
View File
@@ -27,7 +27,8 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; background: var(--bg); color: var(--ink); margin: 0; background: var(--bg); color: var(--ink);
font: 14px/1.6 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; font: 15px/1.6 "Segoe UI Variable Text", "Segoe UI", "MiSans",
"HarmonyOS Sans SC", "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
} }
header { header {
display: flex; flex-wrap: wrap; gap: 12px; align-items: center; display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
@@ -53,7 +54,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
main { padding: 16px 20px 40px; max-width: 1180px; margin: 0 auto; } main { padding: 16px 20px 40px; max-width: 1180px; margin: 0 auto; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 10px; } .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 10px; }
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; } .stat { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
.stat b { display: block; font-size: 18px; } .stat b { display: block; font-size: 18px; font-variant-numeric: tabular-nums; }
.stat small { color: var(--dim); } .stat small { color: var(--dim); }
.cols { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; margin-top: 14px; } .cols { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; margin-top: 14px; }
@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } } @media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
@@ -206,6 +207,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
let es = null, state = null, catalog = null, pollTimer = null, me = null; let es = null, state = null, catalog = null, pollTimer = null, me = null;
let tickLeft = null, tickStamp = 0;
const cropNames = { const cropNames = {
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄", wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
@@ -361,12 +363,16 @@ function render() {
["第几天", s.day], ["第几天", s.day],
["天气", (s.weather_name || "—") + " → " + (s.forecast_name || "?")], ["天气", (s.weather_name || "—") + " → " + (s.forecast_name || "?")],
["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"], ["金币", s.gold], ["等级", "Lv." + s.level + " (" + s.exp + "/" + s.exp_needed + ")"],
["行动点", s.ap + "/" + s.max_ap], ["饲料", s.feed], ["饲料", s.feed],
["仓储", (s.storage_used || 0) + "/" + (s.granary_capacity || 0)], ["仓储", (s.storage_used || 0) + "/" + (s.granary_capacity || 0)],
["净资产", Math.round(s.net_worth)], ["净资产", Math.round(s.net_worth)],
["在线", s.online !== undefined ? s.online : "—"], ["在线", s.online !== undefined ? s.online : "—"],
["下一天", fmtLeft(s.seconds_to_tick)] ["下一天", fmtLeft(s.seconds_to_tick), "tickcell"]
].map((p) => '<div class="stat"><small>' + p[0] + '</small><b>' + p[1] + '</b></div>').join(""); ].map((p) => '<div class="stat"><small>' + p[0] + '</small><b' +
(p[2] ? ' id="' + p[2] + '"' : '') + '>' + p[1] + '</b></div>').join("");
/* 倒计时基点:SSE 只在有变化时推送,中间的每一秒由本地定时器补齐 */
tickLeft = (s.seconds_to_tick === undefined || s.seconds_to_tick === null) ? null : s.seconds_to_tick;
tickStamp = Date.now();
/* 农田 */ /* 农田 */
$("plots").innerHTML = s.plots.map((p) => { $("plots").innerHTML = s.plots.map((p) => {
@@ -629,7 +635,7 @@ function mapDrawTile(p, x, y, ts) {
c.restore(); c.restore();
} else { } else {
c.fillStyle = "rgba(60,40,20,.5)"; c.fillStyle = "rgba(60,40,20,.5)";
c.font = '11px "Segoe UI",sans-serif'; c.font = '600 11px "Segoe UI","Microsoft YaHei",sans-serif';
c.textAlign = "center"; c.textBaseline = "middle"; c.textAlign = "center"; c.textBaseline = "middle";
c.fillText("空地", x + ts / 2, y + ts / 2); c.fillText("空地", x + ts / 2, y + ts / 2);
} }
@@ -648,7 +654,7 @@ function mapDrawTile(p, x, y, ts) {
mapRoundRect(x - 1.5, y - 1.5, ts + 3, ts + 3, 8); c.stroke(); mapRoundRect(x - 1.5, y - 1.5, ts + 3, ts + 3, 8); c.stroke();
} }
c.fillStyle = "rgba(255,255,255,.75)"; c.fillStyle = "rgba(255,255,255,.75)";
c.font = '10px "Segoe UI",sans-serif'; c.font = '600 10px "Segoe UI",sans-serif';
c.textAlign = "left"; c.textBaseline = "top"; c.textAlign = "left"; c.textBaseline = "top";
c.fillText(String(p.position), x + 4, y + 3); c.fillText(String(p.position), x + 4, y + 3);
} }
@@ -707,7 +713,7 @@ function mapDraw(now) {
c.fillRect(pen.x + (pen.w / posts) * i - 2, pen.y + pen.h - 5, 4, 10); c.fillRect(pen.x + (pen.w / posts) * i - 2, pen.y + pen.h - 5, 4, 10);
} }
c.fillStyle = "rgba(60,45,25,.75)"; c.fillStyle = "rgba(60,45,25,.75)";
c.font = '11px "Segoe UI","Microsoft YaHei",sans-serif'; c.font = '600 11px "Segoe UI","Microsoft YaHei",sans-serif';
c.textAlign = "center"; c.textBaseline = "top"; c.textAlign = "center"; c.textBaseline = "top";
c.fillText("牧场", pen.x + pen.w / 2, pen.y + 3); c.fillText("牧场", pen.x + pen.w / 2, pen.y + 3);
(MAP.walkers || []).forEach((wd) => { (MAP.walkers || []).forEach((wd) => {
@@ -746,7 +752,7 @@ function mapDraw(now) {
c.font = '22px "Segoe UI Emoji","Noto Color Emoji",sans-serif'; c.font = '22px "Segoe UI Emoji","Noto Color Emoji",sans-serif';
c.textAlign = "center"; c.textBaseline = "top"; c.textAlign = "center"; c.textBaseline = "top";
c.fillText(info[0], cx, y0 + 4); c.fillText(info[0], cx, y0 + 4);
c.fillStyle = "#3a4a33"; c.font = '10px "Segoe UI","Microsoft YaHei",sans-serif'; c.fillStyle = "#3a4a33"; c.font = '600 11px "Segoe UI","Microsoft YaHei",sans-serif';
c.fillText(kv[1] > 1 ? info[1] + " ×" + kv[1] : info[1], cx, y0 + 32); c.fillText(kv[1] > 1 ? info[1] + " ×" + kv[1] : info[1], cx, y0 + 32);
}); });
} }
@@ -943,6 +949,13 @@ $("logout").addEventListener("click", async () => {
applyIdentity(); applyIdentity();
$("loginname").focus(); $("loginname").focus();
}); });
/* 倒计时本地走秒:状态只在动作/推送时到达,中间的每一秒自己补 */
setInterval(() => {
if (tickLeft === null) return;
const el = $("tickcell");
if (el) el.textContent = fmtLeft(Math.max(0, tickLeft - (Date.now() - tickStamp) / 1000));
}, 1000);
/* ---- 启动 ---- */ /* ---- 启动 ---- */
$("token").value = lsGet("sf.token") || ""; $("token").value = lsGet("sf.token") || "";
$("token").addEventListener("change", () => { $("token").addEventListener("change", () => {
+7 -8
View File
@@ -54,7 +54,6 @@ CREATE TABLE IF NOT EXISTS farms (
day INTEGER NOT NULL, day INTEGER NOT NULL,
gold NUMERIC NOT NULL, gold NUMERIC NOT NULL,
level INTEGER NOT NULL, level INTEGER NOT NULL,
ap INTEGER NOT NULL,
weather TEXT NOT NULL, weather TEXT NOT NULL,
forecast TEXT NOT NULL, forecast TEXT NOT NULL,
net_worth NUMERIC NOT NULL DEFAULT 0, net_worth NUMERIC NOT NULL DEFAULT 0,
@@ -112,6 +111,10 @@ def connect(path: str | None = None) -> sqlite3.Connection:
if fresh: if fresh:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(_SCHEMA) conn.executescript(_SCHEMA)
# v2 时代的库里留着已废弃的 ap 列(行动点从玩法里移除了),老库在此无痛迁移。
cols = {row[1] for row in conn.execute("PRAGMA table_info(farms)")}
if "ap" in cols:
conn.execute("ALTER TABLE farms DROP COLUMN ap")
except Exception: except Exception:
# 建表失败(比如这根本不是个 SQLite 文件)也要把句柄还回去。 # 建表失败(比如这根本不是个 SQLite 文件)也要把句柄还回去。
# Windows 上文件句柄没关就删不掉,而调用方正是靠"读失败→开新档"来容错的。 # Windows 上文件句柄没关就删不掉,而调用方正是靠"读失败→开新档"来容错的。
@@ -131,9 +134,9 @@ def save_state(state: GameState, path: str | None = None, farm_id: str = DEFAULT
conn.execute( conn.execute(
""" """
INSERT INTO farms INSERT INTO farms
(farm_id, version, saved_at, updated_at, day, gold, level, ap, (farm_id, version, saved_at, updated_at, day, gold, level,
weather, forecast, net_worth, payload) weather, forecast, net_worth, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(farm_id) DO UPDATE SET ON CONFLICT(farm_id) DO UPDATE SET
version = excluded.version, version = excluded.version,
saved_at = excluded.saved_at, saved_at = excluded.saved_at,
@@ -141,7 +144,6 @@ def save_state(state: GameState, path: str | None = None, farm_id: str = DEFAULT
day = excluded.day, day = excluded.day,
gold = excluded.gold, gold = excluded.gold,
level = excluded.level, level = excluded.level,
ap = excluded.ap,
weather = excluded.weather, weather = excluded.weather,
forecast = excluded.forecast, forecast = excluded.forecast,
net_worth = excluded.net_worth, net_worth = excluded.net_worth,
@@ -155,7 +157,6 @@ def save_state(state: GameState, path: str | None = None, farm_id: str = DEFAULT
state.day, state.day,
state.gold, state.gold,
state.level, state.level,
state.ap,
state.weather, state.weather,
state.forecast, state.forecast,
state.net_worth, state.net_worth,
@@ -174,7 +175,6 @@ def _raw_state(state: GameState) -> dict[str, Any]:
"gold": state.gold, "gold": state.gold,
"level": state.level, "level": state.level,
"exp": state.exp, "exp": state.exp,
"ap": state.ap,
"feed": state.feed, "feed": state.feed,
"inventory": dict(state.inventory), "inventory": dict(state.inventory),
"buildings": dict(state.buildings), "buildings": dict(state.buildings),
@@ -285,7 +285,7 @@ def list_farms(path: str | None = None) -> list[dict[str, Any]]:
with contextlib.closing(connect(path)) as conn: with contextlib.closing(connect(path)) as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT farm_id, day, gold, level, ap, weather, forecast, SELECT farm_id, day, gold, level, weather, forecast,
net_worth, saved_at net_worth, saved_at
FROM farms FROM farms
ORDER BY net_worth DESC, day DESC, farm_id ORDER BY net_worth DESC, day DESC, farm_id
@@ -300,7 +300,6 @@ def list_farms(path: str | None = None) -> list[dict[str, Any]]:
"day": row["day"], "day": row["day"],
"gold": row["gold"], "gold": row["gold"],
"level": row["level"], "level": row["level"],
"ap": row["ap"],
"weather": row["weather"], "weather": row["weather"],
"forecast": row["forecast"], "forecast": row["forecast"],
"net_worth": row["net_worth"], "net_worth": row["net_worth"],
-12
View File
@@ -630,10 +630,6 @@ class TuiApp:
net_worth = snap["net_worth"] net_worth = snap["net_worth"]
goal = snap["next_goal"] goal = snap["next_goal"]
ap_ratio = snap["ap"] / max(1, snap["max_ap"])
ap_color = C_GREEN if ap_ratio > 0.4 else (C_ORANGE if ap_ratio > 0 else C_RED)
ap_bar = bar(snap["ap"], snap["max_ap"], 10)
line1 = ( line1 = (
color(f" {snap['gold']:>5}", C_BOLD + C_GOLD, self.use_color) color(f" {snap['gold']:>5}", C_BOLD + C_GOLD, self.use_color)
+ color(" 金币", C_GRAY, self.use_color) + color(" 金币", C_GRAY, self.use_color)
@@ -641,10 +637,6 @@ class TuiApp:
+ color(f"Lv.{snap['level']}", C_BOLD + C_PURPLE, self.use_color) + color(f"Lv.{snap['level']}", C_BOLD + C_PURPLE, self.use_color)
+ color(f" {snap['exp']}/{snap['exp_needed']} exp", C_GRAY, self.use_color) + color(f" {snap['exp']}/{snap['exp_needed']} exp", C_GRAY, self.use_color)
+ " " + " "
+ color("行动点 ", C_GRAY, self.use_color)
+ color(ap_bar, ap_color, self.use_color)
+ color(f" {snap['ap']}/{snap['max_ap']}", C_WHITE, self.use_color)
+ " "
+ color(f"饲料 {snap['feed']}", C_ORANGE, self.use_color) + color(f"饲料 {snap['feed']}", C_ORANGE, self.use_color)
) )
@@ -955,10 +947,6 @@ class TuiApp:
C_GRAY, C_GRAY,
self.use_color, self.use_color,
), ),
color(" 行动点", C_BOLD + C_GOLD, self.use_color)
+ color(
" 每天 10 点,播种/浇水/喂食各消耗 1 点,收获和收取免费。", C_GRAY, self.use_color
),
color(" 牲畜", C_BOLD + C_GOLD, self.use_color) color(" 牲畜", C_BOLD + C_GOLD, self.use_color)
+ color( + color(
" 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color " 必须每天喂食才会产出;连续 3 天没吃东西会离开农场。", C_GRAY, self.use_color
+4 -5
View File
@@ -444,7 +444,7 @@ def cmd_do(args) -> int:
if state: if state:
print( print(
f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · " f" 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
f"行动点 {state.get('ap')}/{state.get('max_ap')} · 净资产 {state.get('net_worth')}" f"净资产 {state.get('net_worth')}"
) )
return 0 return 0
@@ -480,11 +480,10 @@ def cmd_actions(args) -> int:
items = client.available_actions() items = client.available_actions()
print( print(
f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · " f"牧场 {client.farm} · 第 {state.get('day')} 天 · 金币 {state.get('gold')} · "
f"行动点 {state.get('ap')}/{state.get('max_ap')}" f"净资产 {state.get('net_worth')}"
) )
if not items: if not items:
print(warn("• 当前没有可执行的动作")) print(warn("• 当前没有可执行的动作"))
print(dim(" 多半是行动点用完了:时间走完一天就会恢复(默认一天 40 分钟)"))
print(dim(" 也可能是作物还没熟:uv run main.py state 看进度")) print(dim(" 也可能是作物还没熟:uv run main.py state 看进度"))
return 0 return 0
print(f"共 {len(items)} 个候选动作:\n") print(f"共 {len(items)} 个候选动作:\n")
@@ -522,7 +521,7 @@ def cmd_state(args) -> int:
desc = snap.get("weather_desc") desc = snap.get("weather_desc")
tomorrow = snap.get("forecast_name") tomorrow = snap.get("forecast_name")
print(f"天气 {day_weather}({desc})· 明日 {tomorrow}") print(f"天气 {day_weather}({desc})· 明日 {tomorrow}")
print(f"行动点 {snap.get('ap')}/{snap.get('max_ap')} 饲料 {snap.get('feed')}") print(f"饲料 {snap.get('feed')}")
print(f"净资产 {snap.get('net_worth')}") print(f"净资产 {snap.get('net_worth')}")
print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块") print(f"土地 {snap.get('plot_count')}/{snap.get('max_plots')} 块")
@@ -589,7 +588,7 @@ def cmd_watch(args) -> int:
capacity = snap.get("granary_capacity") or 0 capacity = snap.get("granary_capacity") or 0
head = ( head = (
f"第 {day_no} 天 {weather} | 金币 {snap.get('gold')} | " f"第 {day_no} 天 {weather} | 金币 {snap.get('gold')} | "
f"Lv.{snap.get('level')} | 行动点 {snap.get('ap')}/{snap.get('max_ap')} | " f"Lv.{snap.get('level')} | "
f"仓储 {used}/{capacity} | 净资产 {snap.get('net_worth')}" f"仓储 {used}/{capacity} | 净资产 {snap.get('net_worth')}"
) )
if online is not None: if online is not None:
-1
View File
@@ -304,7 +304,6 @@ class HarvestAllTestCase(unittest.TestCase):
run_action(self.engine, "plant", {"plot": number, "crop": "wheat"}) run_action(self.engine, "plant", {"plot": number, "crop": "wheat"})
for index in (0, 1): for index in (0, 1):
self.engine.state.plots[index].growth = 2 self.engine.state.plots[index].growth = 2
self.engine.state.ap = 10
# 把第一块地做成"看起来熟了、实际收不了"(作物键被改坏) # 把第一块地做成"看起来熟了、实际收不了"(作物键被改坏)
self.engine.state.plots[0].crop = "wheat" self.engine.state.plots[0].crop = "wheat"
+11 -16
View File
@@ -64,7 +64,6 @@ BASE_STATE = {
"day": 1, "day": 1,
"gold": 1000, "gold": 1000,
"feed": 20, "feed": 20,
"ap": 10,
"next_plot_price": 200, "next_plot_price": 200,
"paused": False, "paused": False,
"seconds_to_tick": 120.0, "seconds_to_tick": 120.0,
@@ -123,11 +122,10 @@ class ScoreTestCase(unittest.TestCase):
self.assertEqual(self.score(action, gold=599, next_plot_price=200), 4.0) self.assertEqual(self.score(action, gold=599, next_plot_price=200), 4.0)
self.assertEqual(self.score(action, gold=9999, next_plot_price=-1), 4.0, "没地可买") self.assertEqual(self.score(action, gold=9999, next_plot_price=-1), 4.0, "没地可买")
def test_animals_need_a_cash_buffer_and_a_free_action_point(self): def test_animals_need_a_cash_buffer(self):
action = {"action": "buy_animal", "params": {"kind": "chicken"}} action = {"action": "buy_animal", "params": {"kind": "chicken"}}
self.assertEqual(self.score(action, gold=270, ap=1), 39.0) self.assertEqual(self.score(action, gold=270), 39.0)
self.assertEqual(self.score(action, gold=269, ap=1), 3.0) self.assertEqual(self.score(action, gold=269), 3.0)
self.assertEqual(self.score(action, gold=1000, ap=0), 3.0, "没行动点就别想")
def test_unknown_actions_have_the_lowest_score(self): def test_unknown_actions_have_the_lowest_score(self):
self.assertEqual(self.score({"action": "fly_to_moon"}), 1.0) self.assertEqual(self.score({"action": "fly_to_moon"}), 1.0)
@@ -196,7 +194,7 @@ class StepTestCase(unittest.TestCase):
state=BASE_STATE, state=BASE_STATE,
catalog=CATALOG, catalog=CATALOG,
) )
client.fail_with = GameError("no_ap", "行动点不足") client.fail_with = GameError("not_enough_gold", "金币不足")
bot = make_bot(client) bot = make_bot(client)
self.assertIsNone(bot.step()) self.assertIsNone(bot.step())
self.assertEqual(bot.failures, 1) self.assertEqual(bot.failures, 1)
@@ -208,7 +206,7 @@ class StepTestCase(unittest.TestCase):
state=BASE_STATE, state=BASE_STATE,
catalog=CATALOG, catalog=CATALOG,
) )
client.fail_with = GameError("no_ap", "行动点不足") client.fail_with = GameError("not_enough_gold", "金币不足")
bot = make_bot(client) bot = make_bot(client)
bot.step() bot.step()
client.fail_with = None client.fail_with = None
@@ -290,13 +288,11 @@ class RunTestCase(unittest.TestCase):
summary = make_bot(client).run(rounds=1) summary = make_bot(client).run(rounds=1)
self.assertEqual(set(summary) >= {"name", "days", "actions", "failures", "net_worth"}, True) self.assertEqual(set(summary) >= {"name", "days", "actions", "failures", "net_worth"}, True)
def test_no_action_points_means_clocking_out(self): def test_no_actions_means_clocking_out(self):
# AP 用完时,服务端的 available_actions 只会剩下 0 消耗的动作 # 没有任何可执行动作时机器人应当直接收工,而不是空转或乱发请求。
# (卖货/建造/收获/收取),这里模拟"连这些都没有"的极端情况:
# 机器人应当直接收工,而不是空转或乱发请求。
client = FakeClient( client = FakeClient(
actions=[], actions=[],
state={**BASE_STATE, "ap": 0}, state=BASE_STATE,
catalog=CATALOG, catalog=CATALOG,
) )
bot = make_bot(client) bot = make_bot(client)
@@ -304,12 +300,11 @@ class RunTestCase(unittest.TestCase):
self.assertEqual(summary["actions"], 0) self.assertEqual(summary["actions"], 0)
self.assertEqual(client.calls, [], "没得做就不该发动作请求") self.assertEqual(client.calls, [], "没得做就不该发动作请求")
def test_free_actions_still_run_without_action_points(self): def test_sell_still_runs_when_idle(self):
# 新设计:收获不再直接给钱,得靠 sell 把谷仓里的货变现;而 sell/build # 收获不再直接给钱,得靠 sell 把谷仓里的货变现;sell 随时可执行。
# 是 0 消耗动作,AP=0 时也必须能执行,否则一整天只能干等着。
client = FakeClient( client = FakeClient(
actions=[{"action": "sell", "params": {}}], actions=[{"action": "sell", "params": {}}],
state={**BASE_STATE, "ap": 0}, state=BASE_STATE,
catalog=CATALOG, catalog=CATALOG,
) )
bot = make_bot(client) bot = make_bot(client)
+2 -4
View File
@@ -227,10 +227,9 @@ class StateViewTestCase(unittest.TestCase):
view = StateView( view = StateView(
{"day": 1, "gold": 100, "plots": [plot_dict(), plot_dict(index=1)], "animals": []} {"day": 1, "gold": 100, "plots": [plot_dict(), plot_dict(index=1)], "animals": []}
) )
view.apply({"gold": 60, "ap": 5}) view.apply({"gold": 60})
self.assertEqual(view.gold, 60) self.assertEqual(view.gold, 60)
self.assertEqual(view.ap, 5)
self.assertEqual(view.day, 1, "没提到的字段要保留") self.assertEqual(view.day, 1, "没提到的字段要保留")
self.assertEqual(len(view.plots), 2, "一次播种不该让农田消失") self.assertEqual(len(view.plots), 2, "一次播种不该让农田消失")
@@ -286,13 +285,12 @@ class StateViewTestCase(unittest.TestCase):
"gold": 320, "gold": 320,
"level": 4, "level": 4,
"exp": 11, "exp": 11,
"ap": 2,
"feed": 9, "feed": 9,
"market": {"corn": 12}, "market": {"corn": 12},
} }
) )
self.assertEqual((view.day, view.gold, view.level), (5, 320, 4)) self.assertEqual((view.day, view.gold, view.level), (5, 320, 4))
self.assertEqual((view.exp, view.ap, view.feed), (11, 2, 9)) self.assertEqual((view.exp, view.feed), (11, 9))
self.assertEqual(view.market["corn"], 12) self.assertEqual(view.market["corn"], 12)
def test_get_and_item_access(self): def test_get_and_item_access(self):
+5 -24
View File
@@ -160,7 +160,6 @@ class GameStateTestCase(unittest.TestCase):
state = GameState() state = GameState()
self.assertEqual(state.day, 1) self.assertEqual(state.day, 1)
self.assertEqual(state.gold, config.START_GOLD) 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.plots), config.BASE_PLOTS)
self.assertEqual(len(state.animals), 0) self.assertEqual(len(state.animals), 0)
@@ -209,10 +208,9 @@ class GameStateTestCase(unittest.TestCase):
self.assertIsNone(state.next_goal) self.assertIsNone(state.next_goal)
def test_from_dict_clamps_hostile_values(self): def test_from_dict_clamps_hostile_values(self):
"""存档是外部输入:负数金币、越界行动点都要被夹回来。""" """存档是外部输入:负数金币、越界等级都要被夹回来。"""
state = GameState.from_dict({"gold": -500, "ap": 9999, "level": 0, "day": -3}) state = GameState.from_dict({"gold": -500, "level": 0, "day": -3})
self.assertEqual(state.gold, 0) self.assertEqual(state.gold, 0)
self.assertEqual(state.ap, state.max_ap)
self.assertEqual(state.level, 1) self.assertEqual(state.level, 1)
self.assertEqual(state.day, 1) self.assertEqual(state.day, 1)
@@ -472,12 +470,11 @@ class ActionTestCase(unittest.TestCase):
self.assertEqual(ctx.exception.status, 400) self.assertEqual(ctx.exception.status, 400)
# ------------------------------------------------------------------ 播种 # ------------------------------------------------------------------ 播种
def test_plant_costs_gold_and_action_point(self): def test_plant_costs_gold(self):
gold, ap = self.engine.state.gold, self.engine.state.ap gold = self.engine.state.gold
result = self.engine.plant(1, "wheat") result = self.engine.plant(1, "wheat")
self.assertTrue(result["ok"]) self.assertTrue(result["ok"])
self.assertEqual(self.engine.state.gold, gold - config.CROPS["wheat"]["seed_cost"]) 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): def test_plant_sets_initial_moisture_and_planted_day(self):
self.engine.state.day = 4 self.engine.state.day = 4
@@ -511,11 +508,6 @@ class ActionTestCase(unittest.TestCase):
self.engine.water(1) self.engine.water(1)
self.assertEqual(self.engine.state.plots[0].moisture, config.WATER_CAPACITY) 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): def test_water_rejects_full_and_empty_plots(self):
self.assert_code("plot_empty", self.engine.water, 1) self.assert_code("plot_empty", self.engine.water, 1)
self.engine.plant(2, "wheat") self.engine.plant(2, "wheat")
@@ -650,7 +642,6 @@ class ActionTestCase(unittest.TestCase):
self.engine.state.level = 99 self.engine.state.level = 99
self.engine.state.gold = 10**9 self.engine.state.gold = 10**9
while self.engine.state.next_plot_price > 0: while self.engine.state.next_plot_price > 0:
self.engine.state.ap = 10
self.engine.buy_plot() self.engine.buy_plot()
self.assertEqual(len(self.engine.state.plots), config.MAX_PLOTS) self.assertEqual(len(self.engine.state.plots), config.MAX_PLOTS)
self.assert_code("plot_limit", self.engine.buy_plot) self.assert_code("plot_limit", self.engine.buy_plot)
@@ -861,11 +852,9 @@ class AdvanceDayTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
self.engine = make_engine() self.engine = make_engine()
def test_day_increments_and_action_points_refill(self): def test_day_increments(self):
self.engine.state.ap = 0
self.engine.advance_day() self.engine.advance_day()
self.assertEqual(self.engine.state.day, 2) 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): def test_fed_animals_make_progress_and_get_hungry_again(self):
self.engine.buy_animal("chicken") # cycle 1 self.engine.buy_animal("chicken") # cycle 1
@@ -1038,19 +1027,11 @@ class AvailableActionsTestCase(unittest.TestCase):
self.assertIn("collect", actions) self.assertIn("collect", actions)
self.assertNotIn("feed", 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): def test_actions_explain_themselves(self):
for item in self.engine.available_actions(): for item in self.engine.available_actions():
with self.subTest(action=item["action"]): with self.subTest(action=item["action"]):
self.assertTrue(item["reason"]) self.assertTrue(item["reason"])
self.assertIn("gold", item["cost"]) self.assertIn("gold", item["cost"])
self.assertIn("ap", item["cost"])
class LogTestCase(unittest.TestCase): class LogTestCase(unittest.TestCase):
-1
View File
@@ -881,7 +881,6 @@ class RestApiTestCase(unittest.TestCase):
catalog = self.client.get("/api/catalog?farm=alice").json()["catalog"] catalog = self.client.get("/api/catalog?farm=alice").json()["catalog"]
self.assertEqual(len(catalog["crops"]), len(config.CROPS)) self.assertEqual(len(catalog["crops"]), len(config.CROPS))
self.assertIn("ap_cost", catalog)
def test_action_then_read_it_back(self): def test_action_then_read_it_back(self):
response = self.client.post( response = self.client.post(
-1
View File
@@ -76,7 +76,6 @@ def fill_endgame(engine: FarmEngine) -> None:
state = engine.state state = engine.state
state.level = 6 # 6 + 2*(6-1) = 16,正好顶到 MAX_PLOTS state.level = 6 # 6 + 2*(6-1) = 16,正好顶到 MAX_PLOTS
state.gold = 50000 state.gold = 50000
state.ap = config.START_AP
state.feed = 40 state.feed = 40
state.plots = [Plot(i) for i in range(config.MAX_PLOTS)] state.plots = [Plot(i) for i in range(config.MAX_PLOTS)]
for i, plot in enumerate(state.plots): for i, plot in enumerate(state.plots):
+1 -1
View File
@@ -106,7 +106,7 @@ def run(endpoint: str, farm: str, token: str | None, seconds: float) -> int:
snapshot = message.get("state", {}) snapshot = message.get("state", {})
print( print(
f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · " f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · "
f"金币 {snapshot.get('gold')} · 行动点 {snapshot.get('ap')} · " f"金币 {snapshot.get('gold')} · "
f"在线 {message.get('online')}" f"在线 {message.get('online')}"
) )
if acted: if acted: