diff --git a/addons/game_base/controllers/chat_api.py b/addons/game_base/controllers/chat_api.py index 129b7c1..6b1e02f 100644 --- a/addons/game_base/controllers/chat_api.py +++ b/addons/game_base/controllers/chat_api.py @@ -2,6 +2,7 @@ from odoo import http from odoo.http import request from .game_api import _json, _post_body +from ..models.chat import GUIDE_NAME class ChatApiController(http.Controller): @@ -12,13 +13,14 @@ class ChatApiController(http.Controller): @http.route('/game/api/chat/channels', type='http', auth='public', methods=['GET'], csrf=False) def chat_channels(self, **kw): - """当前用户可见频道:世界频道(公开) + 自己参与的私聊/公会。""" + """当前用户可见频道:世界频道 + AI向导频道(均公开) + 自己参与的私聊/公会。""" uid = request.session.uid Channel = request.env['game.chat.channel'].sudo() - domain = [('channel_type', '=', 'world')] + public = [('channel_type', 'in', ['world', 'guide'])] if uid: - domain = ['|', ('channel_type', '=', 'world'), - ('member_ids', 'in', [uid])] + domain = ['|'] + public + [('member_ids', 'in', [uid])] + else: + domain = public recs = Channel.search_read(domain, ['id', 'name', 'channel_type'], order='sequence, id') return _json({'items': [{ @@ -60,6 +62,7 @@ class ChatApiController(http.Controller): 'create_date': str(r['create_date']), 'is_self': bool(uid and r.get('author_id') and r['author_id'][0] == uid), + 'is_guide': bool(r['author_name'] == GUIDE_NAME), } for r in recs] return _json({'items': items}) @@ -92,6 +95,26 @@ class ChatApiController(http.Controller): 'author_name': author_name, 'body': body, }) + # AI 向导自动回复:仅对 guide 类型频道生效,与世界频道隔离 + guide_reply = None + ch = request.env['game.chat.channel'].sudo().browse(channel_id) + if ch.exists() and ch.channel_type == 'guide': + reply_text = ch.generate_guide_reply(body) + if reply_text: + reply = request.env['game.chat.message'].sudo().create({ + 'channel_id': channel_id, + 'author_id': False, + 'author_name': GUIDE_NAME, + 'body': reply_text, + }) + guide_reply = { + 'id': reply.id, + 'author_name': GUIDE_NAME, + 'body': reply_text, + 'create_date': str(reply.create_date), + 'is_self': False, + 'is_guide': True, + } return _json({ 'ok': True, 'message': { @@ -100,5 +123,6 @@ class ChatApiController(http.Controller): 'body': body, 'create_date': str(msg.create_date), 'is_self': True, - } + }, + 'guide_reply': guide_reply, }) diff --git a/addons/game_base/controllers/game_api.py b/addons/game_base/controllers/game_api.py index ab64c46..ed6f643 100644 --- a/addons/game_base/controllers/game_api.py +++ b/addons/game_base/controllers/game_api.py @@ -22,10 +22,18 @@ def _json(data): def _post_body(): try: - raw = request.httprequest.get_data(as_text=True) or '{}' - return json.loads(raw) + # Odoo 官方解析 JSON 请求体(正确处理 UTF-8),优先使用。 + return request.get_json_data() except Exception: - return {} + try: + # 兜底:显式按 UTF-8 解码原始字节,避免 Werkzeug 在 application/json + # 无 charset 时按 latin1 解码中文导致乱码。 + raw = request.httprequest.get_data(cache=True) or b'{}' + if isinstance(raw, str): + raw = raw.encode('utf-8') + return json.loads(raw.decode('utf-8')) + except Exception: + return {} class GameApiController(http.Controller): diff --git a/addons/game_base/data/chat_data.xml b/addons/game_base/data/chat_data.xml index 73b0462..0960726 100644 --- a/addons/game_base/data/chat_data.xml +++ b/addons/game_base/data/chat_data.xml @@ -1,11 +1,18 @@ - - + + 世界频道 world 10 + + + + 游戏助手 + guide + 20 + diff --git a/addons/game_base/models/__init__.py b/addons/game_base/models/__init__.py index e83e93a..a05fd5d 100644 --- a/addons/game_base/models/__init__.py +++ b/addons/game_base/models/__init__.py @@ -2,5 +2,6 @@ from . import game_employee from . import game_achievement from . import game_mail from . import chat +from . import sensitive_word from . import page from . import asset diff --git a/addons/game_base/models/chat.py b/addons/game_base/models/chat.py index 69c908c..87ee25f 100644 --- a/addons/game_base/models/chat.py +++ b/addons/game_base/models/chat.py @@ -1,5 +1,16 @@ # -*- coding: utf-8 -*- -from odoo import models, fields +import re +from odoo import api, models, fields + +# 游戏助手的展示昵称(前后端共用,落库消息的作者名) +GUIDE_NAME = '游戏助手' + + +def _plain(text): + """去掉 HTML 标签,用于把 rule.desc(Html) 当作纯文本兜底。""" + if not text: + return '' + return re.sub(r'<[^>]+>', '', text).strip() class GameChatChannel(models.Model): @@ -12,11 +23,93 @@ class GameChatChannel(models.Model): ('world', '世界频道'), ('guild', '公会'), ('pm', '私聊'), + ('guide', 'AI向导'), ], string='类型', default='world', index=True) sequence = fields.Integer(string='排序', default=10) creator_id = fields.Many2one('res.users', string='创建者') member_ids = fields.Many2many('res.users', string='成员') message_ids = fields.One2many('game.chat.message', 'channel_id', string='消息') + message_count = fields.Integer(string='消息数量', compute='_compute_message_count') + + @api.depends('message_ids') + def _compute_message_count(self): + for rec in self: + rec.message_count = len(rec.message_ids) + + def action_view_messages(self): + """从频道列表跳转查看该频道全部消息。""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': '频道消息', + 'res_model': 'game.chat.message', + 'view_mode': 'list,form', + 'domain': [('channel_id', '=', self.id)], + 'context': dict(self.env.context), + } + + def generate_guide_reply(self, question): + """内置游戏知识引擎(零外部依赖):按关键词检索 game.rule / game.rule.category 作答。 + 预留扩展点:若需接入真实 LLM,替换本方法实现、返回模型生成的文本即可,其余调用方不变。 + """ + q = (question or '').strip() + if not q: + return '你好,我是游戏助手。你可以问我关于「属性」「等级」「货币」「制度」的问题~' + env = self.env + Rule = env['game.rule'] + Cat = env['game.rule.category'] + + def rules_of(key): + c = Cat.search([('key', '=', key)], limit=1) + return Rule.search([('category_id', '=', c.id)], order='name') if c else [] + + def line(r): + return f'· {r.name}:{r.summary or _plain(r.desc)}' + + # 问候 + if re.search(r'(你好|您好|hi|hello|在吗|嗨|早上好|晚上好)', q, re.I): + return ('你好,我是游戏助手 🤖 随时为你解答宇森世界的玩法。\n' + '可以问我:属性怎么算?怎么升级?货币有什么用?有哪些制度?') + + # 属性(关键词覆盖攻击/物攻/法攻/防御/暴击等;穿透/韧性已移除) + _attr_re = re.search(r'(属性|攻击|物攻|法攻|防御|物防|法防|血量|生命|法力|速度|攻速|暴击|爆伤|命中|闪避|格挡|抗性|吸血|幸运)', q) + if _attr_re: + rules = list(rules_of('attribute_basic')) + list(rules_of('attribute_advanced')) + if rules: + head = '【属性规则】不同属性决定你的战斗表现,核心属性如下:' + return head + '\n' + '\n'.join(line(r) for r in rules[:12]) + '\n\n想深入了解某项,直接问我它的名字~' + + # 等级 + if re.search(r'(等级|升级|经验|世界等级|角色等级|怎么变强|变强)', q): + for key in ('world', 'character'): + rules = rules_of(key) + if rules: + c = Cat.search([('key', '=', key)], limit=1) + head = f'【{c.name}】' + lines = [f'· {r.name}(Lv{r.level}):{r.summary or ""}' for r in rules[:6]] + return head + '\n' + '\n'.join(l for l in lines if l.strip()) + + # 货币 + if re.search(r'(货币|金币|钱|元宝|钻石|充值|兑换|经济)', q): + rules = rules_of('currency') + if rules: + head = '【货币规则】游戏内货币与获取/消耗方式:' + return head + '\n' + '\n'.join(line(r) for r in rules[:8]) + + # 制度 / 规则 + if re.search(r'(制度|规定|规则|条款|公约|不能|禁止|惩罚|封号)', q): + rules = rules_of('system') + if rules: + head = '【制度说明】请遵守以下基本规则:' + return head + '\n' + '\n'.join(line(r) for r in rules[:8]) + + # 兜底:给出可问的方向 + cats = Cat.search( + [('key', 'in', ['world', 'character', 'currency', 'attribute', 'system'])], + order='sequence') + tips = '、'.join(c.name for c in cats) if cats else '属性、等级、货币、制度' + return ('我是游戏助手,这个问题我还在学习中 🤔\n' + f'你可以问我这些方向:{tips}。\n例如:「攻击和物防怎么算?」「怎么升级最快?」') class GameChatMessage(models.Model): @@ -29,3 +122,12 @@ class GameChatMessage(models.Model): author_id = fields.Many2one('res.users', string='发送者') author_name = fields.Char(string='昵称') body = fields.Text(string='内容', required=True) + + @api.model_create_multi + def create(self, vals_list): + """创建消息时自动过滤敏感词。""" + sanitize = self.env['game.sensitive.word'].sanitize + for vals in vals_list: + if vals.get('body'): + vals['body'] = sanitize(vals['body']) + return super(GameChatMessage, self).create(vals_list) diff --git a/addons/game_base/models/sensitive_word.py b/addons/game_base/models/sensitive_word.py new file mode 100644 index 0000000..1171351 --- /dev/null +++ b/addons/game_base/models/sensitive_word.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +import re +from odoo import api, models, fields + + +class GameSensitiveWord(models.Model): + _name = 'game.sensitive.word' + _description = '聊天敏感词' + _order = 'name' + + name = fields.Char(string='敏感词', required=True, index=True) + active = fields.Boolean(string='启用', default=True) + replacement = fields.Char(string='替换为', default='**') + + _sql_constraints = [ + ('name_uniq', 'unique(name)', '敏感词不能重复'), + ] + + @api.model + def sanitize(self, text): + """将文本中的敏感词替换为对应替换字符(默认 **)。""" + if not text: + return text + words = self.search([('active', '=', True)]) + if not words: + return text + result = text + for w in words: + pat = re.escape(w.name) + repl = w.replacement or '**' + result = re.sub(pat, repl, result, flags=re.IGNORECASE) + return result diff --git a/addons/game_base/security/ir.model.access.csv b/addons/game_base/security/ir.model.access.csv index 8cb7c17..2fa3d5d 100644 --- a/addons/game_base/security/ir.model.access.csv +++ b/addons/game_base/security/ir.model.access.csv @@ -9,6 +9,8 @@ access_game_chat_channel,game.chat.channel public,model_game_chat_channel,base.g access_game_chat_channel_user,game.chat.channel user,model_game_chat_channel,base.group_user,1,1,1,1 access_game_chat_message,game.chat.message public,model_game_chat_message,base.group_public,1,0,0,0 access_game_chat_message_user,game.chat.message user,model_game_chat_message,base.group_user,1,1,1,1 +access_game_sensitive_word,game.sensitive.word public,model_game_sensitive_word,base.group_public,1,0,0,0 +access_game_sensitive_word_user,game.sensitive.word user,model_game_sensitive_word,base.group_user,1,1,1,1 access_game_page,game.page public,model_game_page,base.group_public,1,0,0,0 access_game_page_user,game.page user,model_game_page,base.group_user,1,1,1,1 access_game_page_item,game.page.item public,model_game_page_item,base.group_public,1,0,0,0 diff --git a/addons/game_base/views/asset_views.xml b/addons/game_base/views/asset_views.xml index 9a850c5..addf8e6 100644 --- a/addons/game_base/views/asset_views.xml +++ b/addons/game_base/views/asset_views.xml @@ -2,10 +2,52 @@ + + + yusen.attachment.kanban + ir.attachment + + + + + + + + +
+
+ 🖼️ + 📎 +
+
+ +
+ + — +
+
+ + MB + + + KB + + + B + +
+
+
+
+
+
+
+
+ 附件 ir.attachment - list,form + kanban,list,form {'search_default_filter_website': 0} diff --git a/addons/game_base/views/chat_views.xml b/addons/game_base/views/chat_views.xml index d4dbc26..6e40056 100644 --- a/addons/game_base/views/chat_views.xml +++ b/addons/game_base/views/chat_views.xml @@ -1,13 +1,27 @@ + + + 世界聊天 + + 95 + + - 世界聊天 + 频道 game.chat.channel list,form + + 频道 + + + 10 + + game.chat.channel.list game.chat.channel @@ -15,7 +29,8 @@ - + + + - + +
+ + + + + + + + + +
+ +
@@ -791,6 +813,17 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa + + +
+
+
+ 世界 Lv.-- + -- + EXP -- / -- + --% +
+
@@ -1101,7 +1134,9 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa + + + +
+
+ 👥 好友 + +
+
+ + + + +
+
+
+ + diff --git a/yuthon_playable_loop_plan.md b/yuthon_playable_loop_plan.md new file mode 100644 index 0000000..c514a01 --- /dev/null +++ b/yuthon_playable_loop_plan.md @@ -0,0 +1,73 @@ +# yuthon 微世界 · 最小可玩闭环规划 + +> 目标:把"已定义但没接活"的规则(属性/等级/货币)真正驱动玩法,让玩家登录后有 **目标 → 动作 → 可感知成长** 的循环。后台让运营能配置规则、管理玩家进度,而不是只能看静态文案。 +> 原则:先打通 P0 最小闭环(可玩性),再补 P1 后台运营能力,P2 扩展社交/战斗。 + +--- + +## 一、最小可玩闭环(单一场景讲清) + +玩家每天登录 → 收到 1 个每日任务(如"建造 1 个建筑 / 训练 1 只生物")→ 去沙盘或微观世界做动作 → 系统检测进度完成 → 发经验+金币 → 经验满则角色升级(升级按规则消耗金币、并按属性规则加属性点)→ 员工卡/图鉴上的"战力"数字实时涨了 → 看到成长,第二天还想来。 + +这条链路当前断了 4 处:经验不累加、升级不耗币、属性不生效、没有任务给目标。P0 就是补这 4 处。 + +--- + +## 二、P0 最小闭环(必须先做,可独立验收) + +### 1. 经验/等级/货币 接通(后端) +- 文件:`game_base` 或 `yt_world` 的 `hr.employee` 扩展(`game_employee.py`,已有 `game_exp/game_level/game_coins` 字段但无逻辑)。 +- 新增方法 `add_exp(amount)`: + - 累加 `game_exp`;按"角色等级"规则里的经验曲线判断升级; + - 升级时调用 `GameRule.convert()` 消耗金币(接通当前从未被调用的货币换算); + - 升级按"属性规则"给基础属性点(让属性第一次进入数值)。 +- 等级规则种子当前 `noupdate=1` 只读 → 改为可编辑(`rule_data.xml` 去掉 noupdate 或单独建可配表),后台能调曲线。 + +### 2. 战力计算(让属性有意义) +- 新增 `compute_power(employee)`:用基础属性(物攻/法攻/防御/物防/法防/速度/生命/法力等)加权求和得到"战力"。 +- 员工卡(`world.html` 员工卡区块)、图鉴、AI 向导问答都展示战力,让升级反馈可见。 + +### 3. 每日任务机制(给目标+奖励) +- 新增模型 `game.daily.task`(任务定义:动作类型、目标次数、奖励经验/金币)+ `game.player.task`(玩家每日进度)。 +- 接口:`API.dailyTask`(拉今日任务+进度)、`completeTask`(领奖)。 +- 在现有动作完成时挂钩:建筑动作(`world_api.py buildingAction`)、生物训练(`microUnitTrain`)→ 上报进度。 + +### 4. 前端成长反馈 +- `world.html` 员工卡加:战力数字、经验进度条、今日任务面板(API 对接)。 +- 升级/完成任务时弹"获得经验+XX / 金币+XX / 战力+XX"的轻提示。 + +**P0 验收标准**:登录 → 看到今日任务 → 做个建造/训练 → 任务完成领经验金币 → 经验满升级(扣金币、属性+战力涨)→ 员工卡数字变化可见。全程无需管理员手动改数据。 + +--- + +## 三、P1 后台功能优化(运营可配置) + +- **任务管理后台**:`game.daily.task` 的表单/列表视图(定义任务、奖励、周期),运营可增删改,不必改代码。 +- **经济/等级曲线后台**:把升级所需经验、货币消耗、属性增益做成可编辑规则(替代当前硬编码种子),运营调数值立刻生效。 +- **属性生效验证**:员工表单加 smart button 显示"当前战力/属性明细",后台能验证规则确实驱动了数值(解决当前"升级实际效果后台不可验证")。 +- **签到 / 简单抽奖**:P1 轻量正反馈,签到发币、抽奖给属性点/图鉴碎片,进一步拉日活。 +- **玩家进度管理**:运营可查看/修正单个玩家等级、货币、任务进度(客服/调试用)。 + +--- + +## 四、P2 扩展(闭环跑通后再做) + +- 战斗系统(用属性+战力做单位对战,当前 `yt.world.creature` 已有养成骨架)。 +- AI NPC 深度互动(已有 `game_npc` 模型与知识视图,接对话触发任务)。 +- 社交玩法(公会/排行榜,复用 `channel_type` 体系)。 + +--- + +## 五、执行建议(分期,每期可验收) + +1. **第 1 期(P0 后端)**:`add_exp` + 战力计算 + 每日任务模型 + 规则可编辑。升级 Odoo 落库。 +2. **第 2 期(P0 前端)**:员工卡战力/经验条 + 任务面板 + 升级提示。本地联调闭环跑通。 +3. **第 3 期(P1 后台)**:任务管理视图 + 经济曲线后台 + 属性验证 smart button + 签到/抽奖。 +4. 每期做完停下给你验收,不一次性闷头跑完。 + +--- + +## 附:当前已具备、无需重做的资产 +- 登录/会话、员工卡展示、沙盘建造、世界+AI向导聊天、图鉴、邮件/成就、建筑经济结算、生物训练升级(均已在 `world.html` / `yt_world` / `game_base` 落地)。 +- 规则文案(属性18条已分基础/高级、等级/货币/制度)已有,只需从"展示"改为"被逻辑读取"。 +- 后台图鉴/规则/聊天/公告/员工等视图已较完善,缺的是任务/经济/属性生效三类管理界面。