mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-11-05 12:10:11 +08:00
add # 新增偷菜功能
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "铲除",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user