Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ OG_STORAGE_INDEXER=https://indexer-storage-testnet-turbo.0g.ai
# 不設的話前端走同源的 /api/og/zg,在 Cloudflare 上會失敗。
OG_ZG_PROXY_BASE=

# 代理用來簽節點網址的金鑰。擋的是「有人拿這個網域當跳板」,不是機密資料,
# 沒設會用內建常數。要更嚴的話這裡與 proxy 那台服務要設成同一個值。
OG_PROXY_SECRET=
Comment on lines +36 to +38

# 選用:想讓玩家完全不用付儲存費的話,自己架一個 0g-storage-client gateway,
# 設了這個端點就改由 Function 轉發上傳(那台 gateway 才需要持有私鑰)。
OG_STORAGE_UPLOAD_URL=
Expand All @@ -41,3 +45,8 @@ OG_STORAGE_TOKEN=
# ══ 賽道三:0G Chain(Galileo 測試網 16602)══════════════════════
# 不設就用公開節點。錨定交易由玩家自己的錢包送出,伺服器不保管任何私鑰。
OG_RPC_URL=https://evmrpc-testnet.0g.ai

# ══ 代理服務(proxy/)的變數不在這裡 ═══════════════════════════
# 那是另一個部署(Vercel),變數設在它自己的專案上:
# OG_PROXY_SECRET / OG_STORAGE_INDEXER / ALLOWED_ORIGINS / PUBLIC_BASE_URL
# 說明見 proxy/README.md。
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ jobs:
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public --project-name=conssswars-web
command: pages deploy public --project-name=conssswars-web --branch=main
91 changes: 70 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@
|---|---|---|
| **0G Compute Network**<br>TEE 可驗證推論 | 戰報要永久存檔,寫它的那個 agent 就不能是黑箱。金鑰在 pc.0g.ai 選 **Private(TEE enclave)** 開的,推論實際跑在 enclave 裡,不是只呼叫一個 OpenAI 相容端點。 | [`functions/api/narrate.js#L55-L134`](https://github.com/ConsssLab/web/blob/main/functions/api/narrate.js#L55-L134) |
| **0G Compute Network**<br>Router 設定 | 敵方 agent 與旁白 agent 共用同一份供應商工廠,把 `AI_PROVIDER` 改成 `0g` 就能整支切過去,不用改程式碼。 | [`functions/api/og/_shared.js#L22-L23`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L22-L23) · [`functions/api/og/_shared.js#L82-L90`](https://github.com/ConsssLab/web/blob/main/functions/api/og/_shared.js#L82-L90) |
| **0G Storage**<br>真實寫入 | 記憶碎片要「永久保存」就必須真的落地。用官方 SDK 走完整協議:切 256-byte chunk 算 merkle root → 對 Flow 合約送 submit(付儲存費)→ 把 segment 傳給 storage node。**全程用玩家自己的錢包簽,伺服器不持有私鑰。** | [`public/js/storage.js#L65-L108`](https://github.com/ConsssLab/web/blob/main/public/js/storage.js#L65-L108)<br>節點代理(Node):[`proxy/api/index.js`](https://github.com/ConsssLab/web/blob/main/proxy/api/index.js) —— 節點是裸 IP + 非標準埠,Cloudflare Workers 打不到(error 1003 / 521),這一段必須跑在 Node 上 |
| **0G Storage**<br>indexer 唯讀查詢 | 「上傳沒報錯」不等於存進去了。拿 root hash 回頭問 indexer,確認 storage node 真的收下並 finalized 才敢標成已存檔。唯讀、不需金鑰,評審可自行查證。 | [`functions/api/og/storage.js#L76-L114`](https://github.com/ConsssLab/web/blob/main/functions/api/og/storage.js#L76-L114) |
| **0G Storage**<br>真實寫入 | 記憶碎片要「永久保存」就必須真的落地。用官方 SDK 走完整協議:切 256-byte chunk 算 merkle root → 對 Flow 合約送 submit(付儲存費)→ 把 segment 傳給 storage node。**全程用玩家自己的錢包簽,伺服器不持有私鑰。** | [`public/js/storage.js#L72-L116`](https://github.com/ConsssLab/web/blob/main/public/js/storage.js#L72-L116)<br>節點代理(Node):[`proxy/api/index.js`](https://github.com/ConsssLab/web/blob/main/proxy/api/index.js) —— 節點是裸 IP + 非標準埠,Cloudflare Workers 打不到(error 1003 / 521),這一段必須跑在 Node 上 |
| **0G Storage**<br>indexer 唯讀查詢 | 「上傳沒報錯」不等於存進去了。拿 root hash 回頭問 indexer,確認 storage node 真的收下並 finalized 才敢標成已存檔。唯讀、不需金鑰,評審可自行查證。 | [`functions/api/og/storage.js#L117-L159`](https://github.com/ConsssLab/web/blob/main/functions/api/og/storage.js#L117-L159) |
| **0G Storage**<br>節點活性探測 | 結果畫面的燈號要照實反映網路狀態,不能寫死成綠燈。 | [`functions/api/og/status.js#L34-L57`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L34-L57) |
| **0G Chain**<br>錨定寫入 | 戰報需要一個不可竄改、有時間戳的存在證明。40 bytes 結構化 calldata(魔術字 `CSSW` + 版本 + 戰績 + SHA-256),用合約建立交易送出,chainscan 上一眼認得出來。 | [`functions/api/og/shard.js#L62-L84`](https://github.com/ConsssLab/web/blob/main/functions/api/og/shard.js#L62-L84) · [`public/js/og.js#L174-L199`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L174-L199) |
| **0G Chain**<br>錨定寫入 | 戰報需要一個不可竄改、有時間戳的存在證明。40 bytes 結構化 calldata(魔術字 `CSSW` + 版本 + 戰績 + SHA-256),用合約建立交易送出,chainscan 上一眼認得出來。 | [`functions/api/og/shard.js#L62-L84`](https://github.com/ConsssLab/web/blob/main/functions/api/og/shard.js#L62-L84) · [`public/js/og.js#L235-L260`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L235-L260) |
| **0G Chain**<br>鏈上回驗 | **這是整個專案的重點**:按了按鈕、錢包沒報錯,不代表資料真的在鏈上。所以再用 `eth_getTransactionByHash` 把交易讀回來、反解 calldata、跟本地碎片逐欄比對,四項全對才打勾。 | [`functions/api/og/verify.js#L19-L47`](https://github.com/ConsssLab/web/blob/main/functions/api/og/verify.js#L19-L47) · [`functions/api/og/verify.js#L49-L104`](https://github.com/ConsssLab/web/blob/main/functions/api/og/verify.js#L49-L104) |
| **0G Chain**<br>鏈況與網路切換 | 標題頁即時顯示 Galileo 區塊高度;chainId **從鏈上實際讀回來**校準而不是寫死(0G 換過 chain ID 16601→16602,寫死會讓切鏈整個失敗)。 | [`functions/api/og/status.js#L59-L150`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L59-L150) · [`public/js/og.js#L116-L159`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L116-L159) |
| **0G Chain**<br>鏈況與網路切換 | 標題頁即時顯示 Galileo 區塊高度;chainId **從鏈上實際讀回來**校準而不是寫死(0G 換過 chain ID 16601→16602,寫死會讓切鏈整個失敗)。 | [`functions/api/og/status.js#L59-L157`](https://github.com/ConsssLab/web/blob/main/functions/api/og/status.js#L59-L157) · [`public/js/og.js#L167-L220`](https://github.com/ConsssLab/web/blob/main/public/js/og.js#L167-L220) |
| — **敵方 AI agent**<br>(OpenAI,非 0G) | 對戰時每回合都要叫一次,需要低延遲,所以另外走 OpenAI。模型輸出一律當不可信資料重新過濾。 | [`functions/api/agent.js#L173-L256`](https://github.com/ConsssLab/web/blob/main/functions/api/agent.js#L173-L256) · [`public/js/ai.js#L16-L65`](https://github.com/ConsssLab/web/blob/main/public/js/ai.js#L16-L65) |

### 評審可以自己打的端點
Expand Down Expand Up @@ -102,8 +102,10 @@ curl "https://conssswars-web.pages.dev/api/og/verify?tx=0x<你的交易hash>" |
> 所以結果畫面有一顆「鏈上回驗」按鈕,會實際去讀那筆交易的 calldata,
> 重新解析、跟本地碎片的 SHA-256 比對,比對相符才敢標成 ✓。

美術與音樂全部是程式現畫、現合成的 —— 沒有任何圖檔、沒有任何音檔。
`art.js` 是 inline SVG,`audio.js` 是程序化 WebAudio。
音樂與音效**沒有任何音檔** —— `audio.js` 是程序化 WebAudio,全部現合成。
美術除了三位主角的形象照(`public/images/heroes.jpeg`)之外,也全是 `art.js` 現畫的
inline SVG:戰場、卡牌、遺忘者的遮罩、結果畫面都沒有圖檔。把形象照抽掉照樣能玩,
角色卡會自動退回 SVG 版本。

---

Expand All @@ -125,8 +127,9 @@ curl "https://conssswars-web.pages.dev/api/og/verify?tx=0x<你的交易hash>" |
npm run sim # 跑 300 場隨機對戰,印出勝負分布與平均回合數
```

> ⚠️ **改任何數值前請先重跑模擬。** 現行數值是調出來的:
> 合理玩法約第 6 回合收掉,亂打約七成會輸(實測 300 場:71% 落敗,平均 3.7 回合)。
> ⚠️ **改任何數值前請先重跑模擬。** 現行數值是調出來的:合理玩法約第 6 回合收掉,
> 亂打大多會輸。模擬用的是隨機出牌,每次結果會抖動 —— 連跑五輪的實測範圍是
> **落敗 59–69%、平均 3.7–3.8 回合**(其餘多為和局,隨機亂打贏面約兩成)。

---

Expand All @@ -138,8 +141,10 @@ npm run sim # 跑 300 場隨機對戰,印出勝負分布與平均回合數
| 蕙 Hue | 見證者 | The Witness |
| 刃 Ren | 零重刃 | Blade of Gravity |

形象照放 `public/images/heroes.{png,jpg,jpeg,webp}`(**一張**三格拼版,左到右 零→蕙→刃,
單格比例約 454×787)就會自動採用 —— 程式不裁圖,整張載入後用 CSS 取三等分之一。
形象照放 `public/images/heroes.{png,jpg,jpeg,webp}`(**一張**三格拼版,左到右 零→蕙→刃)
就會自動採用 —— 程式不裁圖,整張載入後用 CSS 取三等分之一。
現行那張是 **1361×768**,所以單格約 **454×768**;CSS 的 `aspect-ratio` 直接寫成
`1361 / 2304`(= 整張寬 ÷ 單格高×3),換圖時記得一起改,比例不對臉會被縱向拉伸。
沒放則退回 `art.js` 現畫的 inline SVG,兩種情況都能正常運作。細節見
[`public/images/README.md`](public/images/README.md)。

Expand Down Expand Up @@ -167,9 +172,12 @@ Dashboard → **Workers & Pages** → **Create** → **Pages** → **Connect to

```bash
npx wrangler login
npx wrangler pages deploy public --project-name conssswars-web
npx wrangler pages deploy public --project-name conssswars-web --branch main
```

> `--branch main` 不能省 —— 少了它會部署成 Preview,只拿得到一個 hash 網址,
> 正式網址不會更新。

### 方式 C · GitHub Actions(推 main 就自動部署)

[`.github/workflows/deploy.yml`](.github/workflows/deploy.yml) 已經寫好。
Expand All @@ -182,6 +190,24 @@ npx wrangler pages deploy public --project-name conssswars-web

> A 和 C 擇一即可,兩個都開會讓同一個 commit 部署兩次。

### 賽道二還需要一台 Node 代理

**這一步不做的話,「存進 0G Storage」一定失敗。**

0G 的 storage node 是**裸 IP + 明文 http + 5678 埠**(實測 `http://34.19.125.196:5678`)。
Cloudflare Workers 兩樣都打不到 —— 對裸 IP 會回 `error 1003`,非標準埠會回 `error 521` ——
所以「把 segment 轉發給節點」這一小段必須跑在一般 Node 上。

```bash
cd proxy && npx vercel --prod # 記下輸出的 Production 網址
cd .. && npx wrangler pages secret put OG_ZG_PROXY_BASE --project-name conssswars-web
# 貼上:https://<剛剛的網址>/api
```

其餘部分照舊留在 Cloudflare。前端不寫死代理網址 —— `/api/og/status` 會把
`OG_ZG_PROXY_BASE` 當成 `storage.zgProxy` 下發,換代理只要改這個變數。
完整說明在 [proxy/README.md](proxy/README.md)。

### 綁定 web.conssswars.com

專案 → **Custom domains** → **Set up a custom domain** → 輸入 `web.conssswars.com`。
Expand All @@ -206,10 +232,15 @@ API 金鑰請選 **Secret**(加密),不要用一般變數。
| `OG_COMPUTE_API_KEY` | 賽道一 | 建議設 | 0G Compute Router 金鑰,從 [pc.0g.ai](https://pc.0g.ai) 取得 |
| `OG_COMPUTE_MODEL` | 賽道一 | 選用 | 預設 `deepseek-chat-v3-0324` |
| `OG_STORAGE_INDEXER` | 賽道二 | 選用 | 預設 Turbo indexer;唯讀查詢不需金鑰 |
| `OG_STORAGE_UPLOAD_URL` | 賽道二 | 上傳才需要 | 指向 `0g-storage-client` gateway;沒設就只產生摘要 |
| `OG_ZG_PROXY_BASE` | 賽道二 | **上傳必要** | 節點代理的網址(結尾要有 `/api`)。沒設的話前端會走同源的 `/api/og/zg`,那條路在 Cloudflare 上必定失敗 —— 見上面「賽道二還需要一台 Node 代理」 |
| `OG_PROXY_SECRET` | 賽道二 | 選用 | 代理用來簽節點網址的金鑰。擋的是「有人拿這個網域當跳板」,不是機密資料;沒設會用內建常數。要更嚴的話**主站與 proxy 兩邊要設成同一個值** |
| `OG_STORAGE_UPLOAD_URL` | 賽道二 | 選用 | 想讓玩家不用付儲存費才需要:指向自架的 `0g-storage-client` gateway,改由 Function 轉發上傳。預設是玩家用自己的錢包上傳,不需要設 |
| `OG_STORAGE_TOKEN` | 賽道二 | 選用 | 上述 gateway 需要的 Bearer token |
| `OG_RPC_URL` | 賽道三 | 選用 | 預設 `https://evmrpc-testnet.0g.ai` |

代理那台服務有自己的環境變數(`ALLOWED_ORIGINS`、`PUBLIC_BASE_URL` 等),
列在 [proxy/README.md](proxy/README.md)。

**一個金鑰都沒設也能玩** —— AI agent 退到本地啟發式對手,畫面上照實標示
「本地備援(未接上模型)」,不會假裝有接。

Expand Down Expand Up @@ -240,39 +271,57 @@ cd public && python3 -m http.server 8788
```
public/ 建置輸出目錄(直接就是成品,無建置步驟)
├─ index.html 五個畫面:標題 / 劇情 / 簡報 / 戰鬥 / 結果
├─ images/ logo、demo 縮圖、角色形象照
├─ 404.html 找不到檔案時的真 404(沒有 SPA 後備,缺檔會現形)
├─ _headers 快取與安全標頭;js/css 每次重新驗證
├─ _routes.json 哪些路徑交給 Pages Functions(/api/*)
├─ manifest.webmanifest PWA 描述
├─ images/ logo、角色形象拼版圖
├─ css/style.css 紙白平塗風,iPhone 直式 390×844 為基準
├─ vendor/ 釘死版本的第三方 bundle(0G Storage SDK、ethers),按下按鈕才載
└─ js/
├─ rules.js 純規則層,無 DOM 無網路,可直接跑對戰模擬
├─ story.js 劇情腳本、角色資料與結局文案
├─ art.js 全部美術(inline SVG,現畫,無圖檔)
├─ art.js 全部美術(inline SVG,現畫)+角色形象照切版
├─ audio.js 程序化 BGM 與音效(WebAudio,無音檔)
├─ ai.js 敵方 AI agent 前端客戶端 + 離線備援
├─ og.js 0G 前端整合:錢包、切鏈、錨定、回驗、Storage 查詢
├─ storage.js 賽道二:用玩家錢包跑官方 SDK 把碎片寫進 0G Storage
└─ main.js 主流程、動畫節奏、0G 三賽道狀態面板

functions/api/
functions/api/ Cloudflare Pages Functions
├─ agent.js 敵方 AI agent 大腦(OpenAI,唯一持有該金鑰的地方)
├─ narrate.js 賽道一:0G Compute 上的旁白 agent
└─ og/
├─ _shared.js 三支共用的網路常數與 JSON-RPC
├─ _shared.js 共用的網路常數與 JSON-RPC
├─ status.js 三賽道即時狀態
├─ shard.js 賽道二寫入 + 賽道三 calldata 組裝
├─ storage.js 賽道二唯讀查詢(indexer)
└─ verify.js 賽道三鏈上回驗
├─ shard.js 碎片正規化、SHA-256、賽道三 calldata 組裝
├─ storage.js 賽道二唯讀查詢(indexer)+ storagescan 連結
├─ verify.js 賽道三鏈上回驗
└─ zg/[[path]].js 節點代理的 Cloudflare 版。留著是因為 0G 之後若改回
傳網域名稱它就能用;目前實際生效的是下面那個

proxy/ 節點代理(Node,部署到 Vercel)
├─ api/index.js 轉發 indexer 與 storage node,HMAC 簽章綁住目標
└─ README.md 為什麼這段不能跑在 Cloudflare 上

scripts/
├─ set-key.sh 互動式寫入 .env 的金鑰
└─ simulate.mjs 對戰模擬,改數值前先跑這個

slides/ 給評審的 5 頁簡報(PDF)
```

---

## 安全性

- **API 金鑰只存在 Function 端。** 前端拿不到,也不會出現在 bundle 裡。
- **不保管任何私鑰。** 上鏈那步走玩家自己的錢包;0G Storage 的寫入交給外部 gateway,
Worker 裡沒有私鑰可偷。
- **不保管任何私鑰。** 上鏈與 0G Storage 的寫入都走玩家自己的錢包 —— 算 merkle root、
對 Flow 合約送 submit(付儲存費)、傳 segment,整段在瀏覽器完成,伺服器沒有私鑰可偷。
- **節點代理不是開放代理。** 節點是裸 IP,沒有網域可以白名單,所以改用 HMAC 簽章綁住:
只有代理自己從 indexer 回應吐出來的網址才轉發得動,外人塞任意網址進來拿 403。
另外擋掉內網 / link-local / 雲端 metadata 位址,也不把 `cookie`、`authorization`
轉給第三方主機。
- **模型輸出一律當成不可信資料。** 只取白名單欄位、比對合法手牌清單、夾在算力預算內
——[`sanitizePlays()`](functions/api/agent.js),前端 `rules.js` 還會再 validate 一次。
台詞用 `textContent` 進 DOM,不走 `innerHTML`。
Expand Down
5 changes: 3 additions & 2 deletions public/js/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ export async function upload(shard, { proxyBase, rpc, onStep } = {}) {
const signer = await provider.getSigner();

step('送出 Flow 合約 submit 並上傳 segment…');
// 這裡刻意忽略呼叫端傳進來的真實 indexer 網址,一律走代理 ——
// 直連的話 segment 那步會被 storage node 的 CORS 擋掉。
// indexer 一律走代理(proxyBase 由 /api/og/status 下發)。
// 直連的話 segment 那步會死在節點端:明文 http 是 mixed content,
// 而且那些主機沒為瀏覽器開 CORS。
const client = new zg.Indexer(indexerUrl(proxyBase));
const [tx, err] = await client.upload(data, rpc || DEFAULT_RPC, signer);
if (err) throw new Error(String(err && err.message ? err.message : err));
Expand Down