# -*- 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', '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, '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': '集市升级申请已提交'}, } 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: 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 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/', 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), })