Files
game/addons/yt_world/controllers/world_api.py
T
2026-08-27 20:15:18 +08:00

1057 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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', 'actions']):
if r['category'] not in seen:
seen.append(r['category'])
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 {},
'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,
'gold': char.gold or 0,
'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,
'gold': char.gold or 0,
})
@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,
'gold': char.gold or 0,
})
@http.route('/yt_world/api/char/talent/draw', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def char_talent_draw(self, **kw):
"""新手抽天赋:返回 5 张随机未拥有的天赋牌(前端先展示牌背,翻牌时再免费激活)。"""
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[:5]})
@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
# ========== 任务系统序列化(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, 'gold': char.gold,
},
})
@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, 'gold': char.gold,
},
'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, 'gold': char.gold,
},
'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, 'gold': char.gold,
'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'):
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'] = body['equip']
char.sudo().write(vals)
return _json({'ok': True, 'char': {
'name': char.name, 'level': char.level, 'exp': char.exp,
'gold': char.gold, 'inventory': char.inventory or {},
'equip': char.equip or {},
}})
@http.route('/yt_world/api/creatures', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def get_creatures(self, **kw):
"""取当前世界全部生物。返回含服务器 id 与客户端 cid(partner.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'})
recs = request.env['yt.world.creature'].sudo().search(
[('world_id', '=', world.id)], order='id')
out = []
for r in recs:
out.append({
'oid': r.id,
'cid': (r.partner or {}).get('cid'),
'name': r.name, 'species': r.species, 'level': r.level,
'exp': r.exp, 'hp': r.hp, 'hp_max': r.hp_max,
'energy': r.energy, 'mood': r.mood or '😊', 'job': r.job,
'equip': r.equip or {'weapon': None, 'armor': None, 'accessory': None},
'tile_ref': r.tile_ref or {}, 'state': r.state or 'resting',
})
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,name,species,level,exp,hp,hp_max,energy,mood,job,equip,tile_ref,state}]}
按 partner.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 '居民',
'species': c.get('species'),
'level': c.get('level', 1), 'exp': c.get('exp', 0),
'hp': c.get('hp', 100), 'hp_max': c.get('hp_max', 100),
'energy': c.get('energy', 100), 'mood': c.get('mood', '😊'),
'job': c.get('job', 'none'),
'equip': c.get('equip') or {'weapon': None, 'armor': None, 'accessory': None},
'tile_ref': c.get('tile_ref') or {},
'state': c.get('state', 'resting'),
'partner': {'cid': cid},
}
# Json 字段不能用 ilike 检索,改为在 Python 侧按 partner.cid 匹配。
rec = None
if cid:
for r in model.search([('world_id', '=', world.id)]):
if (r.partner or {}).get('cid') == cid:
rec = r
break
if rec:
rec.write(vals)
else:
vals['world_id'] = world.id
rec = model.create(vals)
out.append({'oid': rec.id, 'cid': cid, 'level': rec.level,
'exp': rec.exp, 'hp': rec.hp, 'equip': rec.equip or {}})
return _json({'ok': True, 'creatures': out})
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/micro/chars', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def micro_chars(self, **kw):
"""取当前世界全部微观角色。返回 [{oid, cid, race, x, y, facing, state, tile_ref}]"""
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.micro.char'].sudo().search(
[('world_id', '=', world.id)], order='id')
out = []
for r in recs:
out.append({
'oid': r.id, 'cid': r.cid,
'race': r.race, 'x': r.x, 'y': r.y,
'facing': r.facing, 'state': r.state,
'hp': r.hp,
'tile_ref': r.tile_ref or {},
})
return _json({'ok': True, 'chars': out})
@http.route('/yt_world/api/micro/chars/save', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def save_micro_chars(self, **kw):
"""upsert 微观角色列表。body: {"chars":[{cid,race,x,y,facing,state,tile_ref}], "deleted":[cid...]}
按 cid 匹配:存在则更新,否则新建;deleted 中的 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'})
body = _post_body()
items = body.get('chars') or []
deleted = body.get('deleted') or []
model = request.env['yt.world.micro.char'].sudo()
out = []
for c in items:
cid = c.get('cid')
vals = {
'race': c.get('race') or 'human',
'x': float(c.get('x') or 0),
'y': float(c.get('y') or 0),
'facing': int(c.get('facing') or 1),
'state': c.get('state') or 'idle',
'tile_ref': c.get('tile_ref') or {},
}
# 生命值:传了才写(兼容旧版前端不传 hp)
if c.get('hp') is not None:
vals['hp'] = int(c.get('hp') or 0)
rec = None
if cid:
for r in model.search([('world_id', '=', world.id)]):
if r.cid == cid:
rec = r
break
if rec:
rec.write(vals)
else:
vals['world_id'] = world.id
vals['cid'] = cid
rec = model.create(vals)
out.append({'oid': rec.id, 'cid': cid})
# 删除指定 cid
del_count = 0
if deleted:
for r in model.search([('world_id', '=', world.id)]):
if r.cid in deleted:
r.unlink()
del_count += 1
return _json({'ok': True, 'saved': len(out), 'deleted': del_count})