移除行动点系统,倒计时本地走秒,字体优化
为什么:游戏的时间(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:
1 parent
92b1a628b1
commit
15d290a60a
18 files changed
+82
-179
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user