// 宇森游戏 · 前端与 Odoo 通信层 // 本地调试:前端 8080/8888 -> Odoo 8069(跨域,带调试 CORS 头) // 生产部署:前端与 Odoo 同域,由 nginx 代理 /game/*。 // 库名说明:本地 Odoo 用 odoo.conf 钉死 yt_game;线上游戏库名为 game(dbfilter 不含 yt_game)。 // game/* 走统一 API_CONFIG.db;yt_world/* 单独带 ?db=,按 WORLD_DB 区分本地/线上,避免 git 同步互相覆盖。 const _isLocalDev = () => location.hostname === '127.0.0.1' || location.hostname === 'localhost'; // 本地开发:统一走前端开发服务器(dev-server 默认 8080)的反向代理到 Odoo, // 这样无论页面由 dev-server 同源提供,还是从本机其它端口(如预览窗)打开, // API 都能经 8080 代理抵达 Odoo;_json 会回显 Origin 以支持跨域带 cookie。 // 线上(非 localhost)则同源走 nginx,base 为空。 const _isDevPort = (port) => location.port === String(port) || // 某些环境(如 Python http.server)可能不设 port(默认 80)或用空串 (port === 80 && (location.port === '' || location.port === '80')); const API_CONFIG = { base: _isLocalDev() ? ((_isDevPort(8080) || _isDevPort(8888)) ? '' : `http://${location.hostname}:8080`) : '', db: _isLocalDev() ? 'study' : 'game', }; // 世界(yt_world)所在库:本地 Odoo 钉 yt_game;线上游戏库名为 game。按域名自适应,git 同步不冲突。 const WORLD_DB = _isLocalDev() ? 'yt_game' : 'game'; // Odoo 后端基础 URL(世界 API 专用,兼容本地跨域 / 生产同域) const WORLD_BASE = API_CONFIG.base || ''; const _cred = API_CONFIG.base ? 'include' : 'same-origin'; const _wcred = WORLD_BASE ? 'include' : 'same-origin'; function _url(path) { return `${API_CONFIG.base}${path}?db=${API_CONFIG.db}`; } function _worldUrl(path) { return `${WORLD_BASE}${path}?db=${WORLD_DB}`; } const API = { async me() { return (await fetch(_url('/game/api/me'), { credentials: _cred })).json(); }, async login(login, password) { return (await fetch(_url('/game/api/login'), { method: 'POST', credentials: _cred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ login, password }), })).json(); }, async register(payload) { return (await fetch(_url('/game/api/register'), { method: 'POST', credentials: _cred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), })).json(); }, async logout() { return (await fetch(_url('/game/api/logout'), { method: 'POST', credentials: _cred, })).json(); }, async achievements() { return (await fetch(_url('/game/api/achievements'), { credentials: _cred })).json(); }, async mails() { return (await fetch(_url('/game/api/mails'), { credentials: _cred })).json(); }, async mailRead(mailId) { return (await fetch(_url('/game/api/mail/read'), { method: 'POST', credentials: _cred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mail_id: mailId }), })).json(); }, async codex() { return (await fetch(_url('/game/api/codex'), { credentials: _cred })).json(); }, async rules() { return (await fetch(_url('/game/api/rules'), { credentials: _cred })).json(); }, async currencies() { return (await fetch(_url('/game/api/currencies'), { credentials: _cred })).json(); }, async pages() { return (await fetch(_url('/game/api/pages'), { credentials: _cred })).json(); }, async threads() { return (await fetch(_url('/game/api/threads'), { credentials: _cred })).json(); }, async announcements() { return (await fetch(_url('/game/api/announcements'), { credentials: _cred })).json(); }, async story() { return (await fetch(_url('/game/api/story'), { credentials: _cred })).json(); }, async storyChapter(cid) { return (await fetch(_url('/game/api/story/chapter/' + cid), { credentials: _cred })).json(); }, // ---------- 沙盘记忆(yt_world 模块) ---------- async world() { return (await fetch(_worldUrl('/yt_world/api/world'), { credentials: _wcred })).json(); }, async saveTiles(tiles) { return (await fetch(_worldUrl('/yt_world/api/tiles/save'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tiles }), })).json(); }, async saveSettings(settings) { return (await fetch(_worldUrl('/yt_world/api/settings'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings }), })).json(); }, async renameWorld(name) { return (await fetch(_worldUrl('/yt_world/api/world/rename'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), })).json(); }, // 微观世界命名(按地块坐标):命名后由后端持久化到 yt.world.tile.micro.name async renameMicro(q, r, name) { try { const r0 = await fetch(_worldUrl('/yt_world/api/micro/name'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ q, r, name }), }); if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } const j = await r0.json(); return j && j.ok === false ? j : (j || { ok: true }); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, // 微观建造布置保存(按地块坐标) // 返回结构化结果:{ok:true,...} 或 {ok:false, error:'auth'|'network', detail} // —— auth='user' 路由未登录会 303 跳转到 /web/login,fetch 跟随后得到 HTML, // .json() 会抛错;这里捕获并明确区分「未登录」与「网络不可达」。 async saveMicroBuild(q, r, items) { try { const r0 = await fetch(_worldUrl('/yt_world/api/micro/build/save'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ q, r, items }), }); if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } const j = await r0.json(); return j && j.ok === false ? j : (j || { ok: true }); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, // 建造扣费(落库前调用):扣成功才允许放置建筑。 // 返回 {ok:true, cost, gold} 或 {ok:false, error:'no_gold'|'auth'|'network', need, have} async chargeBuild(code, count = 1) { try { const r0 = await fetch(_worldUrl('/yt_world/api/micro/build/charge'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, count }), }); if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } const j = await r0.json(); return j && j.ok === false ? j : (j || { ok: true }); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, // ---------- 世界数据 / 包裹(后端接口预留:yt_world 暂未实现路由,前端 mock 兜底) ---------- async worldData() { try { const r = await fetch(_worldUrl('/yt_world/api/data'), { credentials: _wcred }); if (!r.ok) return null; return await r.json(); } catch (e) { return null; } }, async inventory() { try { const r = await fetch(_worldUrl('/yt_world/api/inventory'), { credentials: _wcred }); if (!r.ok) return null; return await r.json(); } catch (e) { return null; } }, // ---------- 游戏化系统:角色 / 生物 / 订单 / 建筑交互 ---------- async char() { try { const r = await fetch(_worldUrl('/yt_world/api/char'), { credentials: _wcred }); if (!r.ok) return null; return await r.json(); } catch (e) { return null; } }, async saveChar(patch) { try { const r = await fetch(_worldUrl('/yt_world/api/char/save'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch || {}), }); return await r.json(); } catch (e) { return null; } }, async creatures() { try { const r = await fetch(_worldUrl('/yt_world/api/creatures'), { credentials: _wcred }); if (!r.ok) return null; return await r.json(); } catch (e) { return null; } }, async saveCreatures(list) { try { const r = await fetch(_worldUrl('/yt_world/api/creatures/save'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ creatures: list || [] }), }); return await r.json(); } catch (e) { return null; } }, async buildingAction(payload) { try { const r = await fetch(_worldUrl('/yt_world/api/building/action'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}), }); const j = await r.json(); return j && j.ok === false ? j : (j || { ok: true }); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, // ---------- 微观世界角色(玩家放置的种族角色,绑定用户持久化) ---------- async microChars() { try { const r = await fetch(_worldUrl('/yt_world/api/micro/chars'), { credentials: _wcred }); if (!r.ok) return { ok: false, error: 'http_' + r.status }; const j = await r.json(); return j && j.ok === false ? j : (j || { ok: false, error: 'empty' }); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, async saveMicroChars(chars, deleted) { try { const r = await fetch(_worldUrl('/yt_world/api/micro/chars/save'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chars: chars || [], deleted: deleted || [] }), }); const j = await r.json(); return j && j.ok === false ? j : (j || { ok: true }); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, // ---------- 图鉴(codex) ---------- async codexResources() { return (await fetch(_worldUrl('/yt_world/api/codex/resources'), { credentials: _wcred })).json(); }, async codexCreatures() { return (await fetch(_worldUrl('/yt_world/api/codex/creatures'), { credentials: _wcred })).json(); }, async codexMonsters() { return (await fetch(_worldUrl('/yt_world/api/codex/monsters'), { credentials: _wcred })).json(); }, async codexRaces() { return (await fetch(_worldUrl('/yt_world/api/codex/races'), { credentials: _wcred })).json(); }, async codexTalents() { return (await fetch(_worldUrl('/yt_world/api/codex/talents'), { credentials: _wcred })).json(); }, async charTalents() { const r = await fetch(_worldUrl('/yt_world/api/char/talents'), { credentials: _wcred }); if (!r.ok) return { ok: false, error: 'http_' + r.status }; return await r.json(); }, async activateTalent(code) { const r = await fetch(_worldUrl('/yt_world/api/char/talent/activate'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } return await r.json(); }, async upgradeTalent(code) { const r = await fetch(_worldUrl('/yt_world/api/char/talent/upgrade'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } return await r.json(); }, // ---------- 新手抽天赋 ---------- async talentDraw() { const r = await fetch(_worldUrl('/yt_world/api/char/talent/draw'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) return { ok: false, error: 'auth' }; return await r.json(); }, async talentDrawActivate(code) { const r = await fetch(_worldUrl('/yt_world/api/char/talent/draw/activate'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) return { ok: false, error: 'auth' }; return await r.json(); }, // ---------- 新手主线任务 ---------- async tasks() { const r = await fetch(_worldUrl('/yt_world/api/tasks'), { credentials: _wcred }); if (!r.ok) return { ok: false, error: 'http_' + r.status }; return await r.json(); }, async taskComplete(code) { try { const r = await fetch(_worldUrl('/yt_world/api/task/complete'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } const j = await r.json(); return j && j.ok === false ? j : (j || { ok: false, error: 'empty' }); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, // 发射游戏事件,推进任务进度(服务端事件总线) async taskProgress(event, payload) { try { const r = await fetch(_worldUrl('/yt_world/api/task/progress'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event, payload: payload || {} }), }); if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } const j = await r.json(); return j && j.ok === false ? j : (j || { ok: false, error: 'empty' }); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, // 暴露后端 base,供图片等静态资源 URL 拼接(本地跨域→8069;线上同域→'') base: API_CONFIG.base, // ---------- 资源系统(库存 / 结算 / 离线囤积回收) ---------- async worldResources(q, r) { try { const qs = (q !== undefined && r !== undefined) ? ('?q=' + q + '&r=' + r) : ''; const r0 = await fetch(_worldUrl('/yt_world/api/world/resources') + qs, { credentials: _wcred }); if (!r0.ok) return { ok: false, error: 'http_' + r0.status }; return await r0.json(); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, async worldSettle(auto_collect = false, force_collect = false) { try { const r0 = await fetch(_worldUrl('/yt_world/api/world/settle'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auto_collect, force_collect }), }); if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } return await r0.json(); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, async collectBuilding(q, r, i, j) { try { const r0 = await fetch(_worldUrl('/yt_world/api/building/collect'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ q, r, i, j }), }); if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) { return { ok: false, error: 'auth' }; } return await r0.json(); } catch (e) { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, };