1286 lines
59 KiB
Python
1286 lines
59 KiB
Python
# -*- coding: utf-8 -*-
|
||
import json
|
||
import logging
|
||
import re
|
||
import random
|
||
from odoo import http, fields
|
||
from odoo.http import request, Response
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _json(data):
|
||
"""返回 UTF-8 JSON 响应(与 game_base 一致),并附带 CORS 头以支持跨域(8080→8069)。"""
|
||
headers = {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||
'Access-Control-Allow-Headers': 'Content-Type',
|
||
}
|
||
try:
|
||
origin = request.httprequest.headers.get('Origin')
|
||
if origin:
|
||
headers['Access-Control-Allow-Origin'] = origin
|
||
headers['Access-Control-Allow-Credentials'] = 'true'
|
||
else:
|
||
headers['Access-Control-Allow-Origin'] = '*'
|
||
except Exception:
|
||
headers['Access-Control-Allow-Origin'] = '*'
|
||
return Response(json.dumps(data, ensure_ascii=False), headers=headers)
|
||
|
||
|
||
def _post_body():
|
||
try:
|
||
raw = request.httprequest.get_data(as_text=True) or '{}'
|
||
return json.loads(raw)
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
class YtWorldApi(http.Controller):
|
||
"""前端沙盘记忆接口(JSON over HTTP)。auth=user:需已登录 Odoo。
|
||
前端统一带 ?db=game 且 credentials:'same-origin',故路由须为 type='http'
|
||
并以 _json 返回原始 JSON(与 game-api.js 的 fetch().json() 对齐)。"""
|
||
|
||
@http.route('/yt_world/api/world', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def world(self, **kw):
|
||
"""取或建当前用户世界,导出 seed + 地块覆盖。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
user = self._get_user()
|
||
rec = request.env['yt.world'].sudo().get_or_create_for_user(user)
|
||
return _json(rec.export_state())
|
||
|
||
@http.route('/yt_world/api/tiles/save', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def save_tiles(self, **kw):
|
||
"""增量覆盖保存地块。body: {"tiles": [...]}"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
user = self._get_user()
|
||
world = request.env['yt.world'].sudo().search(
|
||
[('user_id', '=', user.id)], limit=1)
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
tiles = body.get('tiles') or []
|
||
world.import_tiles(tiles)
|
||
return _json({'ok': True, 'saved': len(tiles)})
|
||
|
||
@http.route('/yt_world/api/settings', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def save_settings(self, **kw):
|
||
"""保存相机/UI 偏好。body: {"settings": {...}}"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
user = self._get_user()
|
||
world = request.env['yt.world'].sudo().search(
|
||
[('user_id', '=', user.id)], limit=1)
|
||
body = _post_body()
|
||
settings = body.get('settings')
|
||
if world and settings is not None:
|
||
world.settings = settings
|
||
return _json({'ok': True})
|
||
|
||
@http.route('/yt_world/api/world/rename', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def rename_world(self, **kw):
|
||
"""修改当前用户世界名称。body: {"name": "..."}
|
||
校验:trim 后 1-24 字符,禁控制字符,空白正常允许。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
user = self._get_user()
|
||
world = request.env['yt.world'].sudo().search(
|
||
[('user_id', '=', user.id)], limit=1)
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
name = (body.get('name') or '').strip()
|
||
if not name:
|
||
return _json({'ok': False, 'error': 'empty'})
|
||
if len(name) > 24:
|
||
return _json({'ok': False, 'error': 'too_long'})
|
||
if re.search(r'[\x00-\x1f\x7f]', name):
|
||
return _json({'ok': False, 'error': 'invalid_char'})
|
||
old = world.name
|
||
world.name = name
|
||
_logger.info('yt.world rename uid=%s %r -> %r', user.id, old, name)
|
||
return _json({'ok': True, 'name': name})
|
||
|
||
@http.route('/yt_world/api/micro/build/save', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def save_micro_build(self, **kw):
|
||
"""保存某地块微观世界的建造布置。body: {"q":int,"r":int,"items":[{i,j,type}]}
|
||
找不到地块时自动建(保证种子地块也能落库记忆)。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
user = self._get_user()
|
||
world = request.env['yt.world'].sudo().search(
|
||
[('user_id', '=', user.id)], limit=1)
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
q, r, items = body.get('q'), body.get('r'), body.get('items')
|
||
if q is None or r is None or items is None:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
if not isinstance(items, list):
|
||
items = []
|
||
tile = request.env['yt.world.tile'].sudo().search(
|
||
[('world_id', '=', world.id), ('q', '=', q), ('r', '=', r)], limit=1)
|
||
if not tile:
|
||
tile = request.env['yt.world.tile'].sudo().create(
|
||
{'world_id': world.id, 'q': q, 'r': r})
|
||
if not tile.micro_world_id:
|
||
tile._ensure_micro()
|
||
tile.micro_world_id.build_items = items
|
||
return _json({'ok': True, 'count': len(items)})
|
||
|
||
@http.route('/yt_world/api/micro/name', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def rename_micro(self, **kw):
|
||
"""修改某地块微观世界的名称。body: {"q":int,"r":int,"name":"..."}
|
||
找不到地块时自动建(保证种子地块也能落库记忆);与 rename_world 同样的校验。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
user = self._get_user()
|
||
world = request.env['yt.world'].sudo().search(
|
||
[('user_id', '=', user.id)], limit=1)
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
q, r, name = body.get('q'), body.get('r'), (body.get('name') or '').strip()
|
||
if q is None or r is None:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
if not name:
|
||
return _json({'ok': False, 'error': 'empty'})
|
||
if len(name) > 24:
|
||
return _json({'ok': False, 'error': 'too_long'})
|
||
if re.search(r'[\x00-\x1f\x7f]', name):
|
||
return _json({'ok': False, 'error': 'invalid_char'})
|
||
tile = request.env['yt.world.tile'].sudo().search(
|
||
[('world_id', '=', world.id), ('q', '=', q), ('r', '=', r)], limit=1)
|
||
if not tile:
|
||
tile = request.env['yt.world.tile'].sudo().create(
|
||
{'world_id': world.id, 'q': q, 'r': r})
|
||
if not tile.micro_world_id:
|
||
tile._ensure_micro()
|
||
old = tile.micro_world_id.name
|
||
tile.micro_world_id.name = name
|
||
_logger.info('yt.world.tile.micro rename uid=%s (%s,%s) %r -> %r',
|
||
user.id, q, r, old, name)
|
||
return _json({'ok': True, 'name': name, 'q': q, 'r': r})
|
||
|
||
@http.route('/yt_world/api/micro/catalog', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def micro_catalog(self, **kw):
|
||
"""微观世界建造素材目录(前端 MICRO_BUILD_ITEMS 的单一来源)。
|
||
关联当前用户世界:返回「全局模板(world_id空) + 本世界专属」的素材。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
user = self._get_user()
|
||
world = request.env['yt.world'].sudo().search(
|
||
[('user_id', '=', user.id)], limit=1)
|
||
if world:
|
||
dom = ['|', ('world_id', '=', False), ('world_id', '=', world.id)]
|
||
else:
|
||
dom = [('world_id', '=', False)]
|
||
dom = ['&', ('active', '=', True)] + dom
|
||
items = request.env['yt.world.build.item'].sudo().search(dom, order='sequence, id')
|
||
CAT_LABELS = {
|
||
'build': '建筑', 'facility': '设施', 'scene': '场景',
|
||
'bio': '生物', 'other': '其他', 'road': '道路', 'ground': '地面',
|
||
}
|
||
seen = []
|
||
out = []
|
||
for r in items.read(['code', 'name', 'category', 'icon', 'footprint_w',
|
||
'footprint_h', 'cost_gold', 'terrains', 'desc', 'flat',
|
||
'stat_level', 'max_level', 'stat_hp', 'stat_yield', 'stat_yield_instant',
|
||
'stat_yield_tick', 'actions']):
|
||
if r['category'] not in seen:
|
||
seen.append(r['category'])
|
||
# 产出以代码规格 BUILD_SPECS 为单一真源;代码里已定义的建筑不再回退到 item 字段
|
||
from odoo.addons.yt_world.build_spec import get_spec, spec_defined
|
||
spec = get_spec(r['code'])
|
||
if spec_defined(r['code']):
|
||
instant = spec.get('stat_yield_instant') or {}
|
||
tick = spec.get('stat_yield_tick') or {}
|
||
else:
|
||
# 未在代码规格中定义的建筑,才回退到 item 字段/旧 stat_yield 做兼容
|
||
instant = spec.get('stat_yield_instant') or r['stat_yield_instant'] or {}
|
||
tick = spec.get('stat_yield_tick') or r['stat_yield_tick'] or {}
|
||
legacy = r['stat_yield'] or {}
|
||
if not instant and not tick:
|
||
# 旧数据尚未拆分,全部视为一次性产出(保守处理)
|
||
instant = legacy
|
||
out.append({
|
||
'code': r['code'], 'name': r['name'], 'category': r['category'],
|
||
'icon': r['icon'], 'w': r['footprint_w'], 'h': r['footprint_h'],
|
||
'costGold': r['cost_gold'] or 0,
|
||
'terrains': r['terrains'] or [],
|
||
'desc': r['desc'] or '',
|
||
'flat': bool(r['flat']),
|
||
'statLevel': r['stat_level'] or 1,
|
||
'maxLevel': r['max_level'] or 1,
|
||
'statHp': r['stat_hp'] or 100,
|
||
'statYield': r['stat_yield'] or {},
|
||
'statYieldInstant': instant,
|
||
'statYieldTick': tick,
|
||
'actions': r['actions'] or [],
|
||
})
|
||
cats = [{'id': c, 'name': CAT_LABELS.get(c, c)} for c in seen]
|
||
return _json({'cats': cats, 'items': out})
|
||
|
||
@http.route('/yt_world/api/micro/build/charge', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def charge_build(self, **kw):
|
||
"""建造扣费(落库前调用)。body: {"code": "cottage", "count": 1}
|
||
校验建筑存在并按 cost_gold * count 扣减玩家金币;余额不足明确报错。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
code = (body.get('code') or '').strip()
|
||
count = body.get('count') or 1
|
||
try:
|
||
count = int(count)
|
||
except (TypeError, ValueError):
|
||
count = 1
|
||
if count < 1:
|
||
count = 1
|
||
if not code:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
item = request.env['yt.world.build.item'].sudo().search(
|
||
[('code', '=', code), ('active', '=', True)], limit=1)
|
||
if not item:
|
||
return _json({'ok': False, 'error': 'unknown_build', 'code': code})
|
||
cost = (item.cost_gold or 0) * count
|
||
char = self._get_char(world)
|
||
gold = char.gold or 0
|
||
if gold < cost:
|
||
return _json({'ok': False, 'error': 'no_gold', 'need': cost, 'have': gold, 'code': code})
|
||
char.sudo().write({'gold': gold - cost})
|
||
_logger.info('yt.world build charge uid=%s code=%s count=%s cost=%s remain=%s',
|
||
request.session.uid, code, count, cost, gold - cost)
|
||
return _json({'ok': True, 'code': code, 'count': count, 'cost': cost, 'gold': gold - cost})
|
||
|
||
# ========== 图鉴 API ==========
|
||
|
||
def _records_json(self, model, fields):
|
||
"""将 model 的全部记录转为 [{field: value}, ...] 格式(Json 字段解析为对象)。"""
|
||
recs = request.env[model].sudo().search([], order='sequence, id')
|
||
out = []
|
||
for r in recs:
|
||
item = {}
|
||
for f in fields:
|
||
val = getattr(r, f)
|
||
if isinstance(val, str):
|
||
try:
|
||
val = json.loads(val)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
item[f] = val
|
||
out.append(item)
|
||
return _json(out)
|
||
|
||
@http.route('/yt_world/api/codex/resources', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def codex_resources(self, **kw):
|
||
return self._records_json('yt.codex.resource',
|
||
['code','name','icon','category','quality','source','uses','recipe','description'])
|
||
|
||
@http.route('/yt_world/api/codex/creatures', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def codex_creatures(self, **kw):
|
||
return self._records_json('yt.codex.creature',
|
||
['code','name','icon','habitat','tameable','diet','products','description'])
|
||
|
||
@http.route('/yt_world/api/codex/monsters', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def codex_monsters(self, **kw):
|
||
return self._records_json('yt.codex.monster',
|
||
['code','name','icon','tier','level_min','level_max','habitat','drops','description'])
|
||
|
||
@http.route('/yt_world/api/codex/races', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def codex_races(self, **kw):
|
||
return self._records_json('yt.codex.race',
|
||
['code','name','icon','layer','traits','description'])
|
||
|
||
@http.route('/yt_world/api/codex/talents', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def codex_talents(self, **kw):
|
||
"""返回天赋图鉴配置(含前置天赋 code)。"""
|
||
recs = request.env['yt.codex.talent'].sudo().search([], order='sequence, id')
|
||
out = []
|
||
for r in recs:
|
||
out.append({
|
||
'code': r.code,
|
||
'name': r.name,
|
||
'icon': r.icon or '🌟',
|
||
'quality': r.quality or 'common',
|
||
'trigger': r.trigger or '被动',
|
||
'effect': r.effect or '',
|
||
'description': r.description or '',
|
||
'max_level': r.max_level or 1,
|
||
'cost_type': r.cost_type or 'point',
|
||
'cost_value': r.cost_value or 0,
|
||
'unlock_condition': r.unlock_condition or '',
|
||
'prerequisite_code': r.prerequisite_talent_id.code if r.prerequisite_talent_id else None,
|
||
'sequence': r.sequence or 10,
|
||
})
|
||
return _json(out)
|
||
|
||
# ---------- 角色天赋系统 ----------
|
||
|
||
def _serialize_char_talents(self, char):
|
||
"""把角色已拥有的天赋序列化成前端可用结构。"""
|
||
owned = {}
|
||
for t in char.talent_ids:
|
||
owned[t.talent_id.code] = {
|
||
'id': t.id,
|
||
'level': t.level or 1,
|
||
'active': t.active,
|
||
'acquired_at': fields.Datetime.to_string(t.acquired_at) if t.acquired_at else None,
|
||
}
|
||
all_talents = request.env['yt.codex.talent'].sudo().search([])
|
||
talents = []
|
||
for tal in all_talents:
|
||
info = owned.get(tal.code, {})
|
||
talents.append({
|
||
'code': tal.code,
|
||
'name': tal.name,
|
||
'icon': tal.icon or '🌟',
|
||
'quality': tal.quality or 'common',
|
||
'trigger': tal.trigger or '被动',
|
||
'effect': tal.effect or '',
|
||
'description': tal.description or '',
|
||
'max_level': tal.max_level or 1,
|
||
'cost_type': tal.cost_type or 'point',
|
||
'cost_value': tal.cost_value or 0,
|
||
'unlock_condition': tal.unlock_condition or '',
|
||
'prerequisite_code': tal.prerequisite_talent_id.code if tal.prerequisite_talent_id else None,
|
||
'owned': bool(info),
|
||
'level': info.get('level', 0),
|
||
'active': info.get('active', False),
|
||
})
|
||
return talents
|
||
|
||
@http.route('/yt_world/api/char/talents', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def char_talents(self, **kw):
|
||
"""取当前角色天赋状态(图鉴 + 拥有情况)。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
char = self._get_char(world)
|
||
return _json({
|
||
'ok': True,
|
||
'talent_points': char.talent_points or 0,
|
||
**self._char_currency(char),
|
||
'talents': self._serialize_char_talents(char),
|
||
})
|
||
|
||
@http.route('/yt_world/api/char/talent/activate', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def char_talent_activate(self, **kw):
|
||
"""激活天赋 body: {"code": "talent_xxx"}。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
code = (body.get('code') or '').strip()
|
||
if not code:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
|
||
char = self._get_char(world)
|
||
Talent = request.env['yt.codex.talent'].sudo()
|
||
CharTalent = request.env['yt.world.character.talent'].sudo()
|
||
talent = Talent.search([('code', '=', code)], limit=1)
|
||
if not talent:
|
||
return _json({'ok': False, 'error': 'talent_not_found', 'code': code})
|
||
|
||
existing = CharTalent.search([
|
||
('character_id', '=', char.id),
|
||
('talent_id', '=', talent.id)
|
||
], limit=1)
|
||
if existing:
|
||
return _json({'ok': False, 'error': 'already_owned', 'code': code})
|
||
|
||
# 前置天赋校验
|
||
if talent.prerequisite_talent_id:
|
||
pre = CharTalent.search([
|
||
('character_id', '=', char.id),
|
||
('talent_id', '=', talent.prerequisite_talent_id.id),
|
||
('active', '=', True)
|
||
], limit=1)
|
||
if not pre:
|
||
return _json({'ok': False, 'error': 'prereq_unmet',
|
||
'prerequisite_code': talent.prerequisite_talent_id.code})
|
||
|
||
cost = talent.cost_value or 0
|
||
if talent.cost_type == 'point':
|
||
if (char.talent_points or 0) < cost:
|
||
return _json({'ok': False, 'error': 'no_points',
|
||
'need': cost, 'have': char.talent_points or 0})
|
||
char.talent_points = (char.talent_points or 0) - cost
|
||
elif talent.cost_type == 'gold':
|
||
if (char.gold or 0) < cost:
|
||
return _json({'ok': False, 'error': 'no_gold',
|
||
'need': cost, 'have': char.gold or 0})
|
||
char.gold = (char.gold or 0) - cost
|
||
|
||
CharTalent.create({
|
||
'character_id': char.id,
|
||
'talent_id': talent.id,
|
||
'level': 1,
|
||
'active': True,
|
||
})
|
||
|
||
_logger.info('yt.talent.activate uid=%s code=%s cost=%s',
|
||
request.session.uid, code, cost)
|
||
return _json({
|
||
'ok': True,
|
||
'code': code,
|
||
'level': 1,
|
||
'talent_points': char.talent_points or 0,
|
||
**self._char_currency(char),
|
||
})
|
||
|
||
@http.route('/yt_world/api/char/talent/upgrade', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def char_talent_upgrade(self, **kw):
|
||
"""升级天赋 body: {"code": "talent_xxx"}。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
code = (body.get('code') or '').strip()
|
||
if not code:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
|
||
char = self._get_char(world)
|
||
Talent = request.env['yt.codex.talent'].sudo()
|
||
CharTalent = request.env['yt.world.character.talent'].sudo()
|
||
talent = Talent.search([('code', '=', code)], limit=1)
|
||
if not talent:
|
||
return _json({'ok': False, 'error': 'talent_not_found', 'code': code})
|
||
|
||
rec = CharTalent.search([
|
||
('character_id', '=', char.id),
|
||
('talent_id', '=', talent.id)
|
||
], limit=1)
|
||
if not rec:
|
||
return _json({'ok': False, 'error': 'not_owned', 'code': code})
|
||
|
||
res = rec.do_upgrade()
|
||
if not res.get('ok'):
|
||
return _json(res)
|
||
return _json({
|
||
'ok': True,
|
||
'code': code,
|
||
'level': res['level'],
|
||
'talent_points': char.talent_points or 0,
|
||
**self._char_currency(char),
|
||
})
|
||
|
||
@http.route('/yt_world/api/char/talent/draw', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def char_talent_draw(self, **kw):
|
||
"""新手抽天赋:返回 15 张随机未拥有的天赋牌(前端先展示牌背,翻牌时再免费激活)。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
char = self._get_char(world)
|
||
owned_codes = char.talent_ids.mapped('talent_id.code')
|
||
pool = request.env['yt.codex.talent'].sudo().search([('code', 'not in', owned_codes)])
|
||
data = pool.read(['code', 'name', 'icon', 'quality', 'trigger',
|
||
'effect', 'description', 'max_level'])
|
||
random.shuffle(data)
|
||
return _json({'ok': True, 'deck': data[:15]})
|
||
|
||
@http.route('/yt_world/api/char/talent/draw/activate', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def char_talent_draw_activate(self, **kw):
|
||
"""新手抽卡翻牌激活:免费、免前置、免消耗。body: {"code": "talent_xxx"}。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
code = (body.get('code') or '').strip()
|
||
if not code:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
char = self._get_char(world)
|
||
Talent = request.env['yt.codex.talent'].sudo()
|
||
CharTalent = request.env['yt.world.character.talent'].sudo()
|
||
talent = Talent.search([('code', '=', code)], limit=1)
|
||
if not talent:
|
||
return _json({'ok': False, 'error': 'talent_not_found', 'code': code})
|
||
existing = CharTalent.search([
|
||
('character_id', '=', char.id),
|
||
('talent_id', '=', talent.id)
|
||
], limit=1)
|
||
if existing:
|
||
return _json({'ok': False, 'error': 'already_owned', 'code': code})
|
||
CharTalent.create({
|
||
'character_id': char.id,
|
||
'talent_id': talent.id,
|
||
'level': 1,
|
||
'active': True,
|
||
})
|
||
return _json({'ok': True, 'code': code, 'level': 1,
|
||
'name': talent.name, 'icon': talent.icon or '🌟'})
|
||
|
||
# ============================================================
|
||
# 游戏化系统:角色 / 生物 / 订单 / 建筑交互(前端-后端打通)
|
||
# ============================================================
|
||
|
||
# 建筑动作效果表(数据驱动,与前端 BUILD_INTERACT 的 action id 对应)。
|
||
# gain/cost 中 'gold' 表示金币,其余键为资源编码(food/wood/metal/...)。
|
||
# heal: True 时需要将 creature_id 传回,并把该生物生命回满。
|
||
BUILD_EFFECTS = {
|
||
# —— 建筑(3/4 立绘)——
|
||
('cottage', 'employ'): {'msg': '已发布村长任职委托'},
|
||
('cottage', 'recruit'): {'gain': {'gold': 5}, 'msg': '招募村民,人口+1'},
|
||
('cottage', 'upgrade'): {'cost': {'gold': 50}, 'msg': '农舍升级申请已提交'},
|
||
('windmill', 'collect'): {'gain': {'flour': 3}, 'msg': '领取面粉 +3'},
|
||
('windmill', 'produce'): {'cost': {'flour': 2}, 'gain': {'food': 4}, 'msg': '加工粮食 +4'},
|
||
('windmill', 'upgrade'): {'cost': {'gold': 40}, 'msg': '风车升级申请已提交'},
|
||
('blacksmith', 'forge'): {'gain': {'metal': 3}, 'msg': '锻造金属 +3'},
|
||
('blacksmith', 'commission'): {'msg': '已发布装备委托'},
|
||
('blacksmith', 'upgrade'): {'cost': {'gold': 60}, 'msg': '铁匠铺升级申请已提交'},
|
||
('grocery', 'sell'): {'gain': {'gold': 15}, 'msg': '售货收入 +15 金币'},
|
||
('grocery', 'restock'): {'cost': {'gold': 10}, 'gain': {'food': 2, 'wood': 1}, 'msg': '进货:粮+2 木+1'},
|
||
('grocery', 'upgrade'): {'cost': {'gold': 30}, 'msg': '杂货铺升级申请已提交'},
|
||
('temple', 'pray'): {'gain': {'prestige': 2}, 'msg': '祈祷获得声望 +2'},
|
||
('temple', 'donate'): {'cost': {'gold': 20}, 'gain': {'prestige': 5}, 'msg': '捐赠获得声望 +5'},
|
||
('temple', 'upgrade'): {'cost': {'gold': 50}, 'msg': '神庙升级申请已提交'},
|
||
('clinic', 'heal'): {'heal': True, 'msg': '治疗完成,生物生命已回满'},
|
||
('clinic', 'upgrade'): {'cost': {'gold': 40}, 'msg': '医务室升级申请已提交'},
|
||
('restaurant', 'cook'): {'cost': {'food': 2}, 'gain': {'satiety': 3}, 'msg': '烹制料理,饱食 +3'},
|
||
('restaurant', 'upgrade'): {'cost': {'gold': 30}, 'msg': '饭馆升级申请已提交'},
|
||
('pasture', 'breed'): {'gain': {'meat': 2, 'milk': 1}, 'msg': '畜牧产出:肉+2 奶+1'},
|
||
('pasture', 'upgrade'): {'cost': {'gold': 40}, 'msg': '牧场升级申请已提交'},
|
||
('blackmarket', 'trade'): {'cost': {'gold': 25}, 'gain': {'rare': 1}, 'msg': '黑市交易(风险):稀有物资 +1'},
|
||
('blackmarket', 'upgrade'):{'cost': {'gold': 80}, 'msg': '黑市升级申请已提交'},
|
||
('school', 'study'): {'gain': {'research': 3}, 'msg': '研读获得科研 +3'},
|
||
('school', 'lecture'): {'cost': {'gold': 10}, 'gain': {'prestige': 2}, 'msg': '讲学获得声望 +2'},
|
||
('school', 'upgrade'): {'cost': {'gold': 50}, 'msg': '学堂升级申请已提交'},
|
||
# —— 设施(平面)——
|
||
('farmland', 'harvest'): {'gain': {'food': 5}, 'msg': '收获粮食 +5'},
|
||
('farmland', 'sow'): {'cost': {'gold': 5}, 'gain': {'food': 2}, 'msg': '播种:预期粮+2'},
|
||
('farmland', 'upgrade'): {'cost': {'gold': 20}, 'msg': '农田升级申请已提交'},
|
||
('fence', 'repair'): {'cost': {'wood': 2}, 'msg': '栅栏已修缮'},
|
||
('fence', 'upgrade'): {'cost': {'gold': 15}, 'msg': '栅栏升级申请已提交'},
|
||
('wall', 'reinforce'): {'cost': {'stone': 3}, 'gain': {'prestige': 1}, 'msg': '围墙加固,防御+'},
|
||
('wall', 'upgrade'): {'cost': {'gold': 25}, 'msg': '围墙升级申请已提交'},
|
||
('storage', 'deposit'): {'msg': '已打开仓储(在前端存取背包)'},
|
||
('storage', 'upgrade'): {'cost': {'gold': 20}, 'msg': '存储箱升级申请已提交'},
|
||
('well', 'draw'): {'gain': {'water': 3}, 'msg': '取水 +3'},
|
||
('well', 'upgrade'): {'cost': {'gold': 15}, 'msg': '水井升级申请已提交'},
|
||
('market', 'stall'): {'gain': {'gold': 12}, 'msg': '摆摊收入 +12 金币'},
|
||
('market', 'upgrade'): {'cost': {'gold': 25}, 'msg': '集市升级申请已提交'},
|
||
}
|
||
|
||
@http.route('/yt_world/api/build/effects', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def build_effects(self, **kw):
|
||
"""建筑动作效果表(费用/产出真源),供前端弹窗标注费用。
|
||
返回 {"cottage:upgrade": {"cost": {"gold": 50}, "gain": {}}, ...}"""
|
||
effects = {}
|
||
for (code, action), eff in self.BUILD_EFFECTS.items():
|
||
effects['%s:%s' % (code, action)] = {
|
||
'cost': eff.get('cost') or {},
|
||
'gain': eff.get('gain') or {},
|
||
}
|
||
return _json({'ok': True, 'effects': effects})
|
||
|
||
def _require_user(self):
|
||
"""auth='public' 时手动校验登录态;未登录返回统一错误字典。
|
||
Odoo auth='public' 不会把 session uid 同步到 request.env.user,
|
||
必须显式从 request.session.uid 读取真实用户。
|
||
仅校验「已登录 + 账号有效」:门户/游客账号(share=True)也允许访问
|
||
自己的世界——数据安全靠 world_id 按用户隔离,不依赖内部用户组。"""
|
||
uid = request.session.uid
|
||
if not uid:
|
||
return {'ok': False, 'error': 'auth'}
|
||
user = request.env['res.users'].sudo().browse(uid)
|
||
if not user.exists() or not user.active:
|
||
return {'ok': False, 'error': 'auth'}
|
||
return None
|
||
|
||
def _get_user(self):
|
||
uid = request.session.uid
|
||
return request.env['res.users'].sudo().browse(uid) if uid else request.env['res.users']
|
||
|
||
def _get_world(self):
|
||
"""取当前用户世界,不存在则自动创建(保证任何入口/新账号都能保存)。"""
|
||
user = self._get_user()
|
||
if not user.exists():
|
||
return None
|
||
return request.env['yt.world'].sudo().get_or_create_for_user(user)
|
||
|
||
def _get_char(self, world):
|
||
char = request.env['yt.world.character'].sudo().search(
|
||
[('world_id', '=', world.id)], limit=1)
|
||
if not char:
|
||
# 认领当前登录人的玩家档案(hr.employee),把注册昵称同步为角色名
|
||
vals = {'world_id': world.id}
|
||
uid = request.session.uid
|
||
if uid:
|
||
emp = request.env['hr.employee'].sudo().search(
|
||
[('user_id', '=', uid)], limit=1)
|
||
if emp:
|
||
vals['employee_id'] = emp.id
|
||
vals['name'] = emp.name or char.name
|
||
char = request.env['yt.world.character'].sudo().create(vals)
|
||
else:
|
||
# 兼容老数据:已存在角色但没关联 employee 时补上
|
||
if not char.employee_id:
|
||
uid = request.session.uid
|
||
if uid:
|
||
emp = request.env['hr.employee'].sudo().search(
|
||
[('user_id', '=', uid)], limit=1)
|
||
if emp:
|
||
char.sudo().write({'employee_id': emp.id,
|
||
'name': emp.name or char.name})
|
||
return char
|
||
|
||
def _char_currency(self, char):
|
||
"""角色货币字段统一序列化(金币/铜币/银币/钻石)。"""
|
||
return {
|
||
'gold': char.gold or 0,
|
||
'copper': char.copper or 0,
|
||
'silver': char.silver or 0,
|
||
'diamond': char.diamond or 0,
|
||
}
|
||
|
||
# ========== 任务系统序列化(get_tasks / task_progress 共用) ==========
|
||
def _serialize_tasks(self, char):
|
||
"""把任务定义 + 角色进度汇总成前端面板可直接渲染的结构。"""
|
||
done_codes = [t['code'] for t in (char.tasks or []) if t.get('state') == 'done']
|
||
tasks = request.env['yt.world.task'].sudo().search(
|
||
[('active', '=', True)], order='chapter, sequence, id')
|
||
out = []
|
||
for t in tasks:
|
||
prereq = t.prereq or []
|
||
entry = None
|
||
for x in (char.tasks or []):
|
||
if x.get('code') == t.code:
|
||
entry = x
|
||
break
|
||
if entry and entry.get('state') == 'done':
|
||
state = 'done'
|
||
elif any(p not in done_codes for p in prereq):
|
||
state = 'locked'
|
||
elif entry and entry.get('state') == 'claimable':
|
||
state = 'claimable'
|
||
else:
|
||
state = 'in_progress'
|
||
progress = (entry or {}).get('progress') or {}
|
||
objs = []
|
||
for o in (t.objectives or []):
|
||
oid = o.get('id') or ('%s:%s' % (o.get('type'), o.get('key')))
|
||
need = o.get('count', 1) or 1
|
||
have = min(progress.get(oid, 0), need)
|
||
objs.append({
|
||
'type': o.get('type'), 'key': o.get('key'),
|
||
'label': o.get('label'), 'hint': o.get('hint'),
|
||
'have': have, 'need': need,
|
||
})
|
||
out.append({
|
||
'code': t.code, 'chapter': t.chapter, 'title': t.title,
|
||
'desc': t.desc or '', 'objectives': objs,
|
||
'reward_exp': t.reward_exp or 0,
|
||
'reward_res': t.reward_res or {},
|
||
'prereq': prereq, 'state': state,
|
||
})
|
||
chapters = sorted({t['chapter'] for t in out})
|
||
return chapters, out
|
||
|
||
@http.route('/yt_world/api/tasks', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def get_tasks(self, **kw):
|
||
"""返回任务定义 + 当前角色进度,前端据此渲染面板。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
char = self._get_char(world)
|
||
# 回溯校验:角色等级可能已满足 level_up 类型任务,补发事件
|
||
char.on_game_event('level_up', {'level': char.level})
|
||
chapters, out = self._serialize_tasks(char)
|
||
return _json({
|
||
'ok': True,
|
||
'chapters': chapters,
|
||
'tasks': out,
|
||
'char': {
|
||
'name': char.name, 'level': char.level, 'exp': char.exp,
|
||
'exp_max': char.exp_max, **self._char_currency(char),
|
||
},
|
||
})
|
||
|
||
@http.route('/yt_world/api/task/progress', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def task_progress(self, **kw):
|
||
"""接收前端游戏事件并推进任务进度。body: {"event":"build_placed","payload":{"code":"farmland"}}
|
||
返回更新后的任务列表(与 /tasks 同结构),claimable 列出本次新可领取的任务。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
event = (body.get('event') or '').strip()
|
||
payload = body.get('payload') or {}
|
||
if not event:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
char = self._get_char(world)
|
||
res = char.on_game_event(event, payload)
|
||
# 回溯校验:角色等级可能已满足 level_up 类型任务,补发事件
|
||
char.on_game_event('level_up', {'level': char.level})
|
||
chapters, out = self._serialize_tasks(char)
|
||
return _json({
|
||
'ok': True,
|
||
'chapters': chapters,
|
||
'tasks': out,
|
||
'char': {
|
||
'name': char.name, 'level': char.level, 'exp': char.exp,
|
||
'exp_max': char.exp_max, **self._char_currency(char),
|
||
},
|
||
'claimable': res.get('claimable', []),
|
||
})
|
||
|
||
@http.route('/yt_world/api/task/complete', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def complete_task(self, **kw):
|
||
"""领取/完成任务。body: {"code": "ch1_welcome"}
|
||
校验前置→发奖→写进度,返回最新角色状态。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
code = (body.get('code') or '').strip()
|
||
if not code:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
char = self._get_char(world)
|
||
res = char.complete_task(code)
|
||
if not res.get('ok'):
|
||
return _json(res)
|
||
return _json({
|
||
'ok': True,
|
||
'already': res.get('already', False),
|
||
'char': {
|
||
'name': char.name, 'level': char.level, 'exp': char.exp,
|
||
'exp_max': char.exp_max, **self._char_currency(char),
|
||
},
|
||
'reward_exp': res.get('reward_exp', 0),
|
||
'reward_res': res.get('reward_res', {}),
|
||
})
|
||
|
||
@http.route('/yt_world/api/char', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def get_char(self, **kw):
|
||
"""取当前世界角色(不存在则建)。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
char = self._get_char(world)
|
||
return _json({
|
||
'ok': True,
|
||
'name': char.name, 'level': char.level, 'exp': char.exp,
|
||
'exp_max': char.exp_max or 100, **self._char_currency(char),
|
||
'inventory': char.inventory or {},
|
||
'equip': char.equip or {'weapon': None, 'armor': None, 'accessory': None},
|
||
})
|
||
|
||
@http.route('/yt_world/api/char/save', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def save_char(self, **kw):
|
||
"""保存角色(等级/经验/金币/背包/装备)。body: 部分字段皆可。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
char = self._get_char(world)
|
||
body = _post_body()
|
||
vals = {}
|
||
for f in ('name', 'level', 'exp', 'gold', 'copper', 'silver', 'diamond'):
|
||
if f in body and body[f] is not None:
|
||
vals[f] = body[f]
|
||
if 'inventory' in body and body['inventory'] is not None:
|
||
vals['inventory'] = body['inventory']
|
||
if 'equip' in body and body['equip'] is not None:
|
||
vals['equip'] = self._normalize_equip(body['equip'])
|
||
char.sudo().write(vals)
|
||
return _json({'ok': True, 'char': {
|
||
'name': char.name, 'level': char.level, 'exp': char.exp,
|
||
**self._char_currency(char),
|
||
'inventory': char.inventory or {},
|
||
'equip': char.equip or {},
|
||
}})
|
||
|
||
@staticmethod
|
||
def _normalize_equip(equip):
|
||
"""equip 的值统一规范为装备 code 字符串。
|
||
|
||
读取接口返回的是解析后的装备对象(含 code/name/desc…),前端原样回存
|
||
会把它写成字典,导致后续序列化异常。这里统一压回 code。"""
|
||
if not isinstance(equip, dict):
|
||
return {}
|
||
out = {}
|
||
for slot, raw in equip.items():
|
||
out[slot] = (raw.get('code') if isinstance(raw, dict) else raw)
|
||
return out
|
||
|
||
@http.route('/yt_world/api/creatures', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def get_creatures(self, **kw):
|
||
"""取当前世界全部生物(世界生物表)。返回完整面板数据。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
recs = request.env['yt.world.creature'].sudo().search(
|
||
[('world_id', '=', world.id)], order='id')
|
||
out = []
|
||
for r in recs:
|
||
try:
|
||
out.append(r.to_panel_dict())
|
||
except Exception:
|
||
# 单条脏数据不得拖垮整个接口,否则玩家所有生物都读不出来
|
||
_logger.exception('[yt_world] 生物 %s 序列化失败,已降级返回', r.cid)
|
||
out.append({
|
||
'oid': r.id, 'cid': r.cid, 'race': r.race, 'name': r.name,
|
||
'level': r.level, 'exp': r.exp, 'exp_max': r.exp_max,
|
||
'hp': r.hp, 'hp_max': r.hp_max,
|
||
'energy': r.energy, 'energy_max': r.energy_max,
|
||
'attack': r.attack, 'defense': r.defense, 'speed': r.speed,
|
||
'mood': r.mood, 'job': r.job, 'status': r.status, 'state': r.state,
|
||
'x': r.x, 'y': r.y, 'facing': r.facing,
|
||
'tile_ref': r.tile_ref or {}, 'equip': {},
|
||
})
|
||
return _json({'ok': True, 'creatures': out})
|
||
|
||
@http.route('/yt_world/api/creatures/save', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def save_creatures(self, **kw):
|
||
"""upsert 生物列表。body: {"creatures":[{cid,race,name,level,exp,exp_max,hp,hp_max,energy,energy_max,attack,defense,speed,x,y,facing,status,state,mood,job,equip,tile_ref}]}
|
||
按 cid 匹配:存在则更新,否则新建。返回带 oid 的列表。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
items = body.get('creatures') or []
|
||
model = request.env['yt.world.creature'].sudo()
|
||
out = []
|
||
for c in items:
|
||
cid = c.get('cid')
|
||
vals = {
|
||
'name': c.get('name') or '居民',
|
||
'race': c.get('race') or 'human',
|
||
'level': c.get('level', 1), 'exp': c.get('exp', 0),
|
||
'exp_max': c.get('exp_max', 100),
|
||
'hp': c.get('hp', 50), 'hp_max': c.get('hp_max', 50),
|
||
'energy': c.get('energy', 100), 'energy_max': c.get('energy_max', 100),
|
||
'attack': c.get('attack', 5), 'defense': c.get('defense', 3),
|
||
'speed': c.get('speed', 5),
|
||
'x': float(c.get('x') or 0), 'y': float(c.get('y') or 0),
|
||
'facing': int(c.get('facing') or 1),
|
||
'status': c.get('status') or 'idle',
|
||
'state': c.get('state') or 'idle',
|
||
'mood': c.get('mood', '😊'), 'job': c.get('job', 'none'),
|
||
'tile_ref': c.get('tile_ref') or {},
|
||
}
|
||
rec = model.search([('world_id', '=', world.id), ('cid', '=', cid)], limit=1) if cid else None
|
||
if rec:
|
||
# 装备加成是"临时"的(来自实例,不存基础字段);前端回传的是有效属性,
|
||
# 落库时回扣装备加成,避免把临时加成固化进基础值导致下次加载重复叠加。
|
||
bonus = rec._equip_bonus()
|
||
eff_hp_max = vals.get('hp_max', rec.hp_max)
|
||
for f in ('attack', 'defense', 'hp_max'):
|
||
if f in vals and isinstance(vals[f], (int, float)):
|
||
vals[f] = max(0, int(vals[f]) - bonus.get(f, 0))
|
||
if 'hp' in vals:
|
||
vals['hp'] = min(vals['hp'], eff_hp_max)
|
||
rec.write(vals)
|
||
else:
|
||
vals['world_id'] = world.id
|
||
vals['cid'] = cid
|
||
rec = model.create(vals)
|
||
out.append({'oid': rec.id, 'cid': cid, 'level': rec.level,
|
||
'exp': rec.exp, 'hp': rec.hp})
|
||
# 删除指定 cid
|
||
deleted = body.get('deleted') or []
|
||
if deleted:
|
||
model.search([('world_id', '=', world.id), ('cid', 'in', list(deleted))]).unlink()
|
||
return _json({'ok': True, 'creatures': out, 'deleted': len(deleted)})
|
||
|
||
def _find_build_action(self, code, action):
|
||
"""优先从 yt.world.build.item 的 actions 字段查动作配置;没有则回退 BUILD_EFFECTS。"""
|
||
item = request.env['yt.world.build.item'].sudo().search(
|
||
[('code', '=', code), ('active', '=', True)], limit=1)
|
||
if item and item.actions:
|
||
for a in item.actions:
|
||
if a.get('action') == action:
|
||
return a
|
||
return self.BUILD_EFFECTS.get((code, action))
|
||
|
||
def _upgrade_building_instance(self, world, code, q, r, i, j):
|
||
"""在地块 micro_world 的 build_items 中定位指定建筑并升级一级。"""
|
||
tile = request.env['yt.world.tile'].sudo().search(
|
||
[('world_id', '=', world.id), ('q', '=', q), ('r', '=', r)], limit=1)
|
||
if not tile or not tile.micro_world_id:
|
||
return None
|
||
items = list(tile.micro_world_id.build_items or [])
|
||
for b in items:
|
||
if b.get('type') == code and b.get('i') == i and b.get('j') == j:
|
||
# 满级守卫:最大等级真源 = yt.world.build.item.max_level
|
||
item = request.env['yt.world.build.item'].sudo().search(
|
||
[('code', '=', code)], limit=1)
|
||
max_lv = (item.max_level or 1) if item else 1
|
||
if (b.get('level') or 1) >= max_lv:
|
||
return {'max_reached': True}
|
||
b['level'] = (b.get('level') or 1) + 1
|
||
b['exp'] = (b.get('exp') or 0) + 50
|
||
tile.micro_world_id.sudo().write({'build_items': items})
|
||
return {'level': b['level'], 'exp': b['exp']}
|
||
return None
|
||
|
||
@http.route('/yt_world/api/building/action', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def building_action(self, **kw):
|
||
"""执行建筑交互动作。body: {"code","action","q","r","i","j","creature_id"}
|
||
数据驱动:优先查数据库 actions,回退 BUILD_EFFECTS;升级会真正写入 build_items。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
code = body.get('code') or body.get('type')
|
||
action = body.get('action')
|
||
creature_id = body.get('creature_id')
|
||
q, r, i, j = body.get('q'), body.get('r'), body.get('i'), body.get('j')
|
||
eff = self._find_build_action(code, action)
|
||
if not eff:
|
||
return _json({'ok': False, 'error': 'unknown_action', 'key': [code, action]})
|
||
|
||
char = self._get_char(world)
|
||
inv = dict(char.inventory or {})
|
||
gold = char.gold or 0
|
||
|
||
# 扣除成本
|
||
for k, v in (eff.get('cost') or {}).items():
|
||
if k == 'gold':
|
||
if gold < v:
|
||
return _json({'ok': False, 'error': 'no_gold'})
|
||
gold -= v
|
||
else:
|
||
if inv.get(k, 0) < v:
|
||
return _json({'ok': False, 'error': 'no_res', 'res': k})
|
||
inv[k] = inv.get(k, 0) - v
|
||
|
||
# 发放收益
|
||
for k, v in (eff.get('gain') or {}).items():
|
||
if k == 'gold':
|
||
gold += v
|
||
else:
|
||
inv[k] = inv.get(k, 0) + v
|
||
|
||
# 治疗生物
|
||
if eff.get('heal') and creature_id:
|
||
cr = request.env['yt.world.creature'].sudo().search(
|
||
[('id', '=', int(creature_id)), ('world_id', '=', world.id)], limit=1)
|
||
if cr:
|
||
cr.hp = cr.hp_max
|
||
|
||
# 升级:持久化建筑实例状态
|
||
upgrade_info = None
|
||
if action == 'upgrade':
|
||
if q is None or r is None or i is None or j is None:
|
||
return _json({'ok': False, 'error': 'bad_pos'})
|
||
upgrade_info = self._upgrade_building_instance(world, code, q, r, i, j)
|
||
if upgrade_info and upgrade_info.get('max_reached'):
|
||
return _json({'ok': False, 'error': 'max_level'})
|
||
if not upgrade_info:
|
||
return _json({'ok': False, 'error': 'building_not_found'})
|
||
|
||
# 持久化角色
|
||
char.sudo().write({'gold': gold, 'inventory': inv})
|
||
res = {
|
||
'ok': True, 'gold': gold, 'inventory': inv,
|
||
'message': eff.get('msg', '操作成功'),
|
||
}
|
||
if upgrade_info:
|
||
res.update(upgrade_info)
|
||
return _json(res)
|
||
|
||
# ============================================================
|
||
# 微观世界角色(玩家放置的种族角色,绑定用户持久化)
|
||
# ============================================================
|
||
|
||
@http.route('/yt_world/api/races', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def get_races(self, **kw):
|
||
"""种族模板(单一事实来源)。返回前端 characters.js 初始化所需全部字段。"""
|
||
races = request.env['yt.world.race'].sudo().search([('active', '=', True)], order='sequence')
|
||
return _json({'ok': True, 'races': [r.to_dict() for r in races]})
|
||
|
||
@http.route('/yt_world/api/equip/catalog', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def equip_catalog(self, **kw):
|
||
"""装备目录(单一事实来源)。同时返回 slots 槽位真源列表,
|
||
前端面板据此动态渲染(含尚无装备的空槽位)。"""
|
||
Item = request.env['yt.world.equip.item'].sudo()
|
||
items = Item.search([('active', '=', True)])
|
||
slots = [{'value': v, 'label': l} for v, l in Item._fields['slot'].selection]
|
||
return _json({'ok': True, 'items': [it.to_dict() for it in items], 'slots': slots})
|
||
|
||
def _creature_by_cid(self, world, cid):
|
||
if not cid:
|
||
return None
|
||
return request.env['yt.world.creature'].sudo().search(
|
||
[('world_id', '=', world.id), ('cid', '=', cid)], limit=1)
|
||
|
||
@http.route('/yt_world/api/creature/<string:cid>', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def creature_detail(self, cid, **kw):
|
||
"""取单个生物详情(按 cid)。"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
rec = self._creature_by_cid(world, cid)
|
||
if not rec:
|
||
return _json({'ok': False, 'error': 'char_not_found'})
|
||
return _json({'ok': True, 'char': rec.to_panel_dict()})
|
||
|
||
@http.route('/yt_world/api/creature/action', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def creature_action(self, cid=None, **kw):
|
||
"""生物操作:equip / unequip / train / rest。body: {cid, action, slot?, code?}"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
cid = body.get('cid') or cid
|
||
action = body.get('action')
|
||
rec = self._creature_by_cid(world, cid)
|
||
if not rec:
|
||
return _json({'ok': False, 'error': 'char_not_found'})
|
||
if action == 'equip':
|
||
slot = body.get('slot')
|
||
code = body.get('code')
|
||
valid_slots = [s[0] for s in request.env['yt.world.equip.item']._fields['slot'].selection]
|
||
if slot not in valid_slots:
|
||
return _json({'ok': False, 'error': 'bad_slot'})
|
||
item = request.env['yt.world.equip.item'].sudo().search([('code', '=', code)], limit=1)
|
||
if not item:
|
||
return _json({'ok': False, 'error': 'item_not_found'})
|
||
if item.slot != slot:
|
||
return _json({'ok': False, 'error': 'slot_mismatch'})
|
||
# 装备改为管理"实例":每只装备是独立实物,支撑未来耐久/强化/交易
|
||
Inst = request.env['yt.world.equip.instance'].sudo()
|
||
# 1) 先卸下该槽现有装备(equipped_slot 置空 = 回背包)
|
||
old = Inst.search([('owner_creature_id', '=', rec.id),
|
||
('equipped_slot', '=', slot)], limit=1)
|
||
if old:
|
||
old.write({'equipped_slot': False})
|
||
# 2) 优先复用背包中同款未装备实例(避免每次点击都新建,造成实例堆积)
|
||
reuse = Inst.search([('owner_creature_id', '=', rec.id),
|
||
('item_id', '=', item.id),
|
||
('equipped_slot', '=', False)], limit=1)
|
||
if reuse:
|
||
reuse.write({'equipped_slot': slot})
|
||
else:
|
||
Inst.create({'item_id': item.id, 'owner_creature_id': rec.id,
|
||
'equipped_slot': slot, 'durability': 100,
|
||
'max_durability': 100, 'enhance_level': 0})
|
||
elif action == 'unequip':
|
||
slot = body.get('slot')
|
||
# 仅把该槽实例的 equipped_slot 置空(回背包),不删除实例
|
||
inst = request.env['yt.world.equip.instance'].sudo().search(
|
||
[('owner_creature_id', '=', rec.id), ('equipped_slot', '=', slot)], limit=1)
|
||
if inst:
|
||
inst.write({'equipped_slot': False})
|
||
elif action == 'train':
|
||
rec.write({
|
||
'level': rec.level + 1,
|
||
'exp': 0,
|
||
'exp_max': rec.exp_max + 50,
|
||
'hp_max': rec.hp_max + 10,
|
||
'energy_max': rec.energy_max + 5,
|
||
'attack': rec.attack + 2,
|
||
'defense': rec.defense + 1,
|
||
'speed': rec.speed + 1,
|
||
'hp': rec.hp_max + 10,
|
||
'energy': rec.energy_max + 5,
|
||
})
|
||
elif action == 'rest':
|
||
rec.write({'status': 'resting', 'hp': rec.hp_max, 'energy': rec.energy_max})
|
||
else:
|
||
return _json({'ok': False, 'error': 'unknown_action'})
|
||
return _json({'ok': True, 'char': rec.to_panel_dict()})
|
||
|
||
# ============================================================
|
||
# 资源系统:库存 / 结算 / 离线囤积回收
|
||
# ============================================================
|
||
|
||
def _serialize_build_instance(self, inst):
|
||
"""把 yt.world.build.instance 序列化为前端可用结构。"""
|
||
return {
|
||
'id': inst.id,
|
||
'code': inst.code,
|
||
'i': inst.i, 'j': inst.j,
|
||
'rot': inst.rot or 0,
|
||
'level': inst.level or 1,
|
||
'stored': inst.stored or {},
|
||
'last_tick': fields.Datetime.to_string(inst.last_tick) if inst.last_tick else None,
|
||
}
|
||
|
||
def _serialize_stock(self, world, resource_code=None):
|
||
"""序列化世界库存。resource_code 为 None 返回全部。"""
|
||
Stock = request.env['yt.world.stock'].sudo()
|
||
dom = [('world_id', '=', world.id)]
|
||
if resource_code:
|
||
dom.append(('resource_code', '=', resource_code))
|
||
return {s.resource_code: {
|
||
'amount': s.amount or 0.0,
|
||
'rate_per_min': s.rate_per_min or 0.0,
|
||
'capacity': s.capacity or -1.0,
|
||
'last_settle': fields.Datetime.to_string(s.last_settle) if s.last_settle else None,
|
||
} for s in Stock.search(dom)}
|
||
|
||
@http.route('/yt_world/api/world/resources', type='http', auth='public',
|
||
methods=['GET', 'OPTIONS'], csrf=False)
|
||
def world_resources(self, **kw):
|
||
"""取当前世界资源状态。
|
||
可选 query: q, r(当前微观地块),返回该地块内建筑实例的囤积状态。
|
||
"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
world._recalc_rates()
|
||
q, r = kw.get('q'), kw.get('r')
|
||
instances = []
|
||
if q is not None and r is not None:
|
||
tile = request.env['yt.world.tile'].sudo().search([
|
||
('world_id', '=', world.id), ('q', '=', int(q)), ('r', '=', int(r))], limit=1)
|
||
if tile and tile.micro_world_id:
|
||
instances = [
|
||
self._serialize_build_instance(i)
|
||
for i in request.env['yt.world.build.instance'].sudo().search(
|
||
[('micro_id', '=', tile.micro_world_id.id)])
|
||
]
|
||
return _json({
|
||
'ok': True,
|
||
'stocks': self._serialize_stock(world),
|
||
'instances': instances,
|
||
})
|
||
|
||
@http.route('/yt_world/api/world/settle', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def world_settle(self, **kw):
|
||
"""手动/定时触发世界资源结算。body: {"auto_collect": true/false, "force_collect": true/false}
|
||
在线自动进账时 auto_collect=true;离线登录后 force_collect=true 可把已有囤积也兜底回库。
|
||
"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
auto_collect = bool(body.get('auto_collect', False))
|
||
force_collect = bool(body.get('force_collect', False))
|
||
res = world._settle_all(auto_collect=auto_collect, force_collect=force_collect)
|
||
world._recalc_rates()
|
||
return _json({
|
||
'ok': True,
|
||
'auto_collect': auto_collect,
|
||
'force_collect': force_collect,
|
||
'produced': res.get('produced', {}),
|
||
'collected': res.get('collected', {}),
|
||
'stocks': self._serialize_stock(world),
|
||
})
|
||
|
||
@http.route('/yt_world/api/building/collect', type='http', auth='public',
|
||
methods=['POST', 'OPTIONS'], csrf=False)
|
||
def building_collect(self, **kw):
|
||
"""回收指定建筑实例的囤积资源。body: {"q": int, "r": int, "i": int, "j": int}
|
||
返回回收到的资源数量与最新库存。
|
||
"""
|
||
auth_err = self._require_user()
|
||
if auth_err:
|
||
return _json(auth_err)
|
||
world = self._get_world()
|
||
if not world:
|
||
return _json({'ok': False, 'error': 'no_world'})
|
||
body = _post_body()
|
||
q, r, i, j = body.get('q'), body.get('r'), body.get('i'), body.get('j')
|
||
if q is None or r is None or i is None or j is None:
|
||
return _json({'ok': False, 'error': 'bad_params'})
|
||
tile = request.env['yt.world.tile'].sudo().search([
|
||
('world_id', '=', world.id), ('q', '=', int(q)), ('r', '=', int(r))], limit=1)
|
||
if not tile or not tile.micro_world_id:
|
||
return _json({'ok': False, 'error': 'no_micro'})
|
||
res = world._collect_building(tile.micro_world_id.id, int(i), int(j))
|
||
if not res.get('ok'):
|
||
return _json(res)
|
||
world._recalc_rates()
|
||
return _json({
|
||
'ok': True,
|
||
'collected': res.get('collected', {}),
|
||
'stocks': self._serialize_stock(world),
|
||
})
|