add # 新增偷菜功能

This commit is contained in:
2026-10-21 17:57:55 +08:00
parent dd47ab70c4
commit c7d97e294a
17 changed files with 495 additions and 30 deletions
+65 -2
View File
@@ -2,6 +2,7 @@
from dataclasses import asdict, replace
import time
import secrets
from .catalogue import Catalogue
from .domain import (
@@ -9,6 +10,7 @@ from .domain import (
)
from .ports import Store
from .pests import advance_plot, start_growth
from .yield_rules import season_yield, remaining_yield
class FarmService:
@@ -151,8 +153,7 @@ class FarmService:
or plot.mature_at > now
):
continue
amount = boosted(plant.harvest, plot.yield_bonus)
amount = amount * (100 - 5 * plot.maturity_pests) // 100
amount = remaining_yield(plot)
experience = boosted(
plant.experience, plot.experience_bonus,
)
@@ -168,7 +169,9 @@ class FarmService:
pest_count=0, pest_spawned=0, growth_remaining=None,
growth_updated_at=None, next_pest_at=None,
maturity_pests=0,
stolen_count=0,
)
repository.clear_thefts(plot.slot)
repository.save_plot(
start_growth(updated, now) if count < plant.crop
else updated
@@ -200,11 +203,13 @@ class FarmService:
if not withered:
raise FarmError("nothing_to_eradicate")
for plot in withered:
repository.clear_thefts(plot.slot)
repository.save_plot(replace(
plot, plant=None, mature_at=None, harvest_count=0,
pest_count=0, pest_spawned=0, growth_remaining=None,
growth_updated_at=None, next_pest_at=None,
maturity_pests=0,
stolen_count=0,
))
experience = integer(len(withered) * 2)
repository.asset(Asset.EXPERIENCE, experience)
@@ -217,6 +222,64 @@ class FarmService:
identity, operation_id, "eradicate", {}, operation,
)
async def steal(self, identity: Identity, target: Identity,
operation_id: str):
self._identity(identity)
self._identity(target)
if identity == target:
raise FarmError('cannot_steal_self')
if (identity.deployment, identity.realm, identity.platform) != (
target.deployment, target.realm, target.platform
):
raise FarmError('different_farm_scope')
def operation(repository, now):
repository.account()
owner = repository.for_identity(target)
try:
owner.account()
except FarmError as error:
if error.code == 'not_registered':
raise FarmError('target_not_registered') from error
raise
day_start = ((now+28800)//86400)*86400-28800
used = repository.successful_thefts(day_start)
if used >= 5:
raise FarmError('steal_daily_limit')
items = []
skipped = {'not_ripe': 0, 'already_stolen': 0, 'protected': 0}
for stored in owner.plots():
plot = advance_plot(stored, now)
if (plot.plant is None or plot.withered
or plot.mature_at is None or plot.mature_at > now):
skipped['not_ripe'] += 1
continue
if owner.theft_seen(plot.slot, identity):
skipped['already_stolen'] += 1
continue
total = season_yield(plot)
available = remaining_yield(plot)-(total+1)//2
if available <= 0:
skipped['protected'] += 1
continue
amount = min(secrets.randbelow(3)+1, available)
repository.stock(plot.plant, crops=amount)
owner.save_plot(replace(
plot, stolen_count=plot.stolen_count+amount,
))
owner.record_theft(plot.slot, identity)
items.append({'slot': plot.slot, 'name': plot.plant.name,
'plant_id': plot.plant.plant_id,
'quantity': amount})
return self._result(
repository, items=items, skipped=skipped,
quantity=sum(i['quantity'] for i in items),
remaining_attempts=5-used-bool(items),
)
return await self._execute(identity, operation_id, 'steal',
{'target': target.key}, operation)
async def clear_pests(self, identity: Identity, operation_id: str):
def operation(repository, now):
repository.account()
+2
View File
@@ -213,8 +213,10 @@ class Plot:
growth_updated_at: int | None = None # 上次工作量结算的 UTC 整数秒。
next_pest_at: int | None = None # 下一次生虫时间,最多每季三次。
maturity_pests: int = 0 # 成熟瞬间冻结的虫数,决定本季减产。
stolen_count: int = 0 # 本季已被偷走数量;收获或铲除后归零。
def __post_init__(self):
integer(self.stolen_count)
integer(self.pest_count, 0, 3)
integer(self.pest_spawned, self.pest_count, 3)
integer(self.maturity_pests, 0, 3)
+19
View File
@@ -4,6 +4,25 @@ from .domain import Receipt, Snapshot
def receipt_text(receipt: Receipt) -> str:
if receipt.action == 'steal':
data = receipt.data
items = data['items']
lines = [f"偷菜成功:{len(items)}块地,共{data['quantity']}个。"
if items else '本次未偷到作物,不消耗次数。']
grouped = {}
for item in items:
key = (item['plant_id'], item['name'])
grouped[key] = grouped.get(key, 0)+item['quantity']
lines.extend(f'{name}(#{pid})×{amount}'
for (pid, name), amount in grouped.items())
reasons = {'already_stolen': '本季已偷过',
'protected': '防偷保护', 'not_ripe': '无成熟作物'}
notes = [f'{reasons[key]} {count}块'
for key, count in data['skipped'].items() if count]
if notes:
lines.append('跳过:'+';'.join(notes))
lines.append(f"今日剩余:{data['remaining_attempts']}/5次(北京时间)")
return '\n'.join(lines)
labels = {
"register": "注册", "buy": "购买", "sow": "播种",
"harvest": "收获", "eradicate": "铲除",
+30 -3
View File
@@ -138,7 +138,7 @@ class SQLiteRepository:
r["yield_bonus"], r["experience_bonus"],
r["pest_count"], r["pest_spawned"], r["growth_remaining"],
r["growth_updated_at"], r["next_pest_at"],
r["maturity_pests"],
r["maturity_pests"], r['stolen_count'],
)
for r in rows
)
@@ -154,14 +154,41 @@ class SQLiteRepository:
"UPDATE plots SET plant=?, mature_at=?, harvest_count=?, "
"yield_bonus=?, experience_bonus=?, pest_count=?, pest_spawned=?, "
"growth_remaining=?, growth_updated_at=?, next_pest_at=?, "
"maturity_pests=? WHERE identity=? AND slot=?",
"maturity_pests=?, stolen_count=? WHERE identity=? AND slot=?",
(
data, plot.mature_at, plot.harvest_count, plot.yield_bonus,
plot.experience_bonus, plot.pest_count, plot.pest_spawned,
plot.growth_remaining, plot.growth_updated_at,
plot.next_pest_at, plot.maturity_pests,
plot.next_pest_at, plot.maturity_pests, plot.stolen_count,
self._identity, plot.slot,
),
)
if cursor.rowcount != 1:
raise FarmError("unknown_plot")
def for_identity(self, identity):
"""同一事务内访问目标账户,禁止独立提交。"""
return SQLiteRepository(self._db, identity.key, self._operation_id,
self._action, self._now)
def theft_seen(self, slot, thief):
return self._db.execute(
'SELECT 1 FROM thefts WHERE owner=? AND slot=? AND thief=?',
(self._identity, slot, thief.key),
).fetchone() is not None
def record_theft(self, slot, thief):
self._db.execute('INSERT INTO thefts VALUES (?, ?, ?)',
(self._identity, slot, thief.key))
def clear_thefts(self, slot):
self._db.execute('DELETE FROM thefts WHERE owner=? AND slot=?',
(self._identity, slot))
def successful_thefts(self, day_start):
return self._db.execute(
"SELECT COUNT(*) FROM operations WHERE identity=? "
"AND action='steal' AND created_at>=? AND created_at<? "
"AND json_extract(result, '$.quantity')>0",
(self._identity, day_start, day_start+86400),
).fetchone()[0]
+26 -3
View File
@@ -16,7 +16,7 @@ from .catalogue import Catalogue
LOGGER = logging.getLogger(__name__)
APPLICATION_ID = 0x4641524D
SCHEMA_VERSION = 3
SCHEMA_VERSION = 4
PEST_COLUMNS = {
"pest_count": (
@@ -72,11 +72,19 @@ SCHEMA = (
harvest_count INTEGER NOT NULL DEFAULT 0 CHECK(harvest_count>=0),
yield_bonus INTEGER NOT NULL DEFAULT 0,
experience_bonus INTEGER NOT NULL DEFAULT 0,
stolen_count INTEGER NOT NULL DEFAULT 0 CHECK(stolen_count>=0),
""" + ",\n ".join(
f"{name} {definition}" for name, definition in PEST_COLUMNS.items()
) + """,
PRIMARY KEY(identity, slot)
)""",
"""CREATE TABLE thefts(
owner TEXT NOT NULL,
slot INTEGER NOT NULL,
thief TEXT NOT NULL REFERENCES accounts(identity),
PRIMARY KEY(owner, slot, thief),
FOREIGN KEY(owner, slot) REFERENCES plots(identity, slot)
)""",
"""CREATE TABLE ledger(
id INTEGER PRIMARY KEY,
identity TEXT NOT NULL,
@@ -137,6 +145,8 @@ class SQLiteStore:
)
}
expected = {"accounts", "operations", "inventory", "plots", "ledger"}
if expected_version >= 4:
expected.add('thefts')
if (
app_id != APPLICATION_ID or version != expected_version
or tables != expected
@@ -227,15 +237,28 @@ class SQLiteStore:
)
connection.execute("PRAGMA user_version=3")
migrated_pests = True
version = 3
if app == APPLICATION_ID and version == 3:
self._validate(connection, expected_version=3)
connection.execute(
'ALTER TABLE plots ADD COLUMN stolen_count '
'INTEGER NOT NULL DEFAULT 0 CHECK(stolen_count>=0)'
)
connection.execute(next(
sql for sql in SCHEMA
if sql.startswith('CREATE TABLE thefts(')
))
connection.execute('PRAGMA user_version=4')
LOGGER.info('farm schema migrated version=4 theft=enabled')
self._validate(connection)
connection.commit()
if migrated_rows is not None:
LOGGER.info(
"farm schema migrated version=3 inventory_rows=%s",
"farm schema migrated version=4 inventory_rows=%s",
migrated_rows,
)
elif migrated_pests:
LOGGER.info("farm schema migrated version=3 pests=enabled")
LOGGER.info("farm schema migrated version=4 pests=enabled")
except BaseException:
connection.rollback()
LOGGER.warning("farm schema initialization rolled back")
+12
View File
@@ -0,0 +1,12 @@
"""本季产量的唯一口径:土地加成、成熟虫害、实际偷取扣减。"""
from .domain import boosted
def season_yield(plot):
return (boosted(plot.plant.harvest, plot.yield_bonus)
* (100-5*plot.maturity_pests)//100)
def remaining_yield(plot):
return max(0, season_yield(plot)-plot.stolen_count)
+2 -1
View File
@@ -56,7 +56,8 @@ def detail_rows(renderer, identity, snapshot, now, page):
continue
ripe = plot.mature_at is not None and plot.mature_at <= now
pests = plot.maturity_pests if ripe else plot.pest_count
amount = boosted(plant.harvest, plot.yield_bonus)*(100-5*pests)//100
amount = max(0, boosted(plant.harvest, plot.yield_bonus)
*(100-5*pests)//100-plot.stolen_count)
info = metadata['plants'].get(plant.name)
total = len(set(info['phase'].split(','))) if info else None
stage = ('成熟' if ripe else
+10 -4
View File
@@ -118,17 +118,21 @@ class RenderService:
return await asyncio.shield(task)
async def warehouse(self, rows, snapshot, kind, page, pages,
image_format='AUTO'):
image_format='AUTO', high=False):
if self._closed:
raise RuntimeError('Render service closed')
key = ('warehouse', rows, snapshot, kind, page, pages, image_format)
key = ('warehouse', rows, snapshot, kind, page, pages,
image_format, high)
cached = self.renderer.results.get(key)
if cached is not None:
return cached
task = self._pending.get(key)
if task is None:
if len(self._pending) >= self._max_pending:
raise RuntimeError('Render queue full')
task = asyncio.create_task(self._run_function(
render_warehouse, self.renderer, rows, snapshot, kind,
page, pages, image_format,
page, pages, image_format, high,
))
self._pending[key] = task
@@ -138,7 +142,9 @@ class RenderService:
done.exception()
task.add_done_callback(finished)
return await asyncio.shield(task)
content = await asyncio.shield(task)
self.renderer.results.put(key, content, len(content))
return content
async def _run_function(self, function, *args):
started = perf_counter()
+24 -12
View File
@@ -110,15 +110,25 @@ def warehouse_text(rows, snapshot, kind, page, pages):
def render_warehouse(renderer, rows, snapshot, kind, page, pages,
image_format='AUTO'):
options = RenderOptions(format=image_format, quality=80)
image_format='AUTO', high=False):
options = RenderOptions(width=1024 if high else 768,
format=image_format, quality=85 if high else 70)
codec = options.codec()
scale = options.width/1024
s = lambda n: round(n*scale)
canvas = Canvas(renderer.assets.image('webgl/shop-board.png',
(1024, 1536)))
patch = canvas.image.crop((300, 340, 880, 445))
canvas.image.paste(patch.resize((824, 930)), (100, 320))
canvas.rectangle((105, 322, 919, 1249), '#fff1cf', radius=15)
(s(1024), s(1536))))
patch = canvas.image.crop(tuple(s(n) for n in (300, 340, 880, 445)))
canvas.image.paste(patch.resize((s(824), s(930))), (s(100), s(320)))
def rectangle(box, color, radius):
canvas.rectangle(tuple(s(n) for n in box), color, radius=s(radius))
rectangle((105, 322, 919, 1249), '#fff1cf', 15)
def text(value, xy, size=26, color='#603817', width=760):
size = max(1, s(size))
xy = tuple(s(n) for n in xy)
width = s(width)
value = canvas.fit_text(value, renderer.font_path, size, width)
box = font(renderer.font_path, size).getbbox(value)
canvas.text((xy[0], xy[1]-box[1]), value,
@@ -136,7 +146,7 @@ def render_warehouse(renderer, rows, snapshot, kind, page, pages,
for index, row in enumerate(chunk):
if row.group != group:
group = row.group
canvas.rectangle((123, y, 899, y+43), '#d8e5b6', radius=7)
rectangle((123, y, 899, y+43), '#d8e5b6', 7)
count = sum(r.group == group for r in rows)
label = f'{group}({count}种)'
if group == '已锁定':
@@ -144,12 +154,13 @@ def render_warehouse(renderer, rows, snapshot, kind, page, pages,
text(label, (140, y+8), 24, '#607d30')
y += 49
if index % 2 == 0:
canvas.rectangle((123, y, 899, y+55), '#fff8e4', radius=5)
rectangle((123, y, 899, y+55), '#fff8e4', 5)
icon = f'plant/{row.name}/icon.png'
if icon in renderer.assets.metadata['images']:
image = renderer.assets.image(icon)
image.thumbnail((44, 46), Image.Resampling.LANCZOS)
canvas.paste(image, (158-image.width//2, y+27-image.height//2))
image.thumbnail((s(44), s(46)), Image.Resampling.LANCZOS)
canvas.paste(image, (s(158)-image.width//2,
s(y+27)-image.height//2))
else:
text('?', (149, y+15), 26)
text(f'#{row.plant_id}', (193, y+19), 21, '#607d30', 82)
@@ -186,6 +197,7 @@ def render_warehouse(renderer, rows, snapshot, kind, page, pages,
x = round((1024-canvas.measure_text(nav, renderer.font_path, 23))/2)
text(nav, (x, 1424), 23, width=510)
content = canvas.encode(codec, options.quality, options.method)
logger.info('farm warehouse kind=%s page=%s bytes=%s codec=%s',
kind, page, len(content), codec)
logger.info('farm warehouse kind=%s page=%s bytes=%s codec=%s width=%s '
'quality=%s', kind, page, len(content), codec,
options.width, options.quality)
return content