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

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