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
18 changes: 15 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
# 線上部署:Dashboard → Settings → Variables and Secrets,金鑰請選 Secret(加密)。

# ══ 賽道一之一:敵方 AI agent「遺忘者」 ══════════════════════════
# 每回合讀盤下決策的那個對手。預設走 OpenAI。
# 想讓敵方 agent 也跑在 0G Compute 上,把這行改成 AI_PROVIDER=0g 即可,程式碼不用動。
AI_PROVIDER=openai
# 每回合讀盤下決策的那個對手。**預設跑在 0G Compute 上**。
#
# 為什麼不是 OpenAI:要主張「整場七回合都是同一位 agent」,那個 agent 就必須跑在
# enclave 裡並帶回可驗證的簽名。跑在 OpenAI 上的話 TEE 只是掛在旁白那一段,
# 跟玩家實際對打的對手無關,那個主張就不成立。
# 想切回 OpenAI(例如想要更低延遲)就設成 openai —— 但「同一位 agent」的驗證會降級。
AI_PROVIDER=0g
OPENAI_API_KEY=sk-your-openai-key
OPENAI_MODEL=gpt-4o-mini
# 走 Azure OpenAI 或自架相容端點時才需要改
Expand All @@ -20,6 +24,14 @@ OG_COMPUTE_API_KEY=sk-your-0g-router-key
OG_COMPUTE_MODEL=deepseek-chat-v3-0324
# OG_COMPUTE_BASE_URL=https://router-api.0g.ai/v1

# provider 的 attestation(遠端證明)端點。
#
# 這是「同一位 agent」證明鏈的第一環:報告裡有 enclave 的 measurement(程式碼與模型
# 的雜湊)與一把 enclave 內生成的公鑰。有它才能主張「每回合的簽名確實出自這個 enclave」,
# 而不只是「出自某把金鑰」。沒設的話畫面會照實顯示「未取得 attestation」並降一級,
# 不會假裝驗過。
OG_COMPUTE_ATTESTATION_URL=

# ══ 賽道二:0G Storage ══════════════════════════════════════════
# 上傳預設由前端用玩家自己的錢包完成(public/js/storage.js + 官方 SDK),
# 不需要在這裡設任何東西,伺服器也不碰私鑰。
Expand Down
62 changes: 54 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ curl "https://conssswars-web.pages.dev/api/og/storage?root=0x<你的root>" | jq

# 賽道三:拿任何一筆錨定交易的 hash 回來重驗
curl "https://conssswars-web.pages.dev/api/og/verify?tx=0x<你的交易hash>" | jq

# 賽道一:provider 的 attestation(enclave measurement 與公鑰)
curl https://conssswars-web.pages.dev/api/og/attest | jq
```

---
Expand Down Expand Up @@ -113,8 +116,9 @@ inline SVG:戰場、卡牌、遺忘者的遮罩、結果畫面都沒有圖檔

- 三條「記憶迴廊」,每條 3 格。你在左、遺忘者在右。
- 每回合 **2 點算力** —— 顧不了三條,**選哪條放掉就是勝負**。
- 交戰**之前**先比火力:一條迴廊誰的攻擊力總和高,就啃對方核心 **2 點**。
- 交戰**之前**先比火力:一條迴廊贏多少就啃多少 —— 多 **1~2 攻**啃 1 點,多 **3 攻以上**啃滿 2 點,打平不啃。
單位當回合陣亡也算數,所以「投入多少」比「殺幾隻」重要。
- **雙方同時出牌。** AI agent 拿到的是你部署**之前**的盤面 —— 它看不到你這回合放了什麼。
- 貼身或迎面撞上的單位互砍;打贏還活著的會乘勝追擊繼續前進。
- 走出邊界的單位直接打核心。核心 12 點,共 7 回合,先歸零的一方輸。

Expand All @@ -127,9 +131,50 @@ inline SVG:戰場、卡牌、遺忘者的遮罩、結果畫面都沒有圖檔
npm run sim # 跑 300 場隨機對戰,印出勝負分布與平均回合數
```

> ⚠️ **改任何數值前請先重跑模擬。** 現行數值是調出來的:合理玩法約第 6 回合收掉,
> 亂打大多會輸。模擬用的是隨機出牌,每次結果會抖動 —— 連跑五輪的實測範圍是
> **落敗 59–69%、平均 3.7–3.8 回合**(其餘多為和局,隨機亂打贏面約兩成)。
> ⚠️ **改任何數值前請先重跑模擬。** `npm run sim` 用隨機出牌,每次會抖動;連跑三輪的
> 實測範圍是**勝 30–33%、敗 46–54%、平均 4.3–4.4 回合**。
>
> 另外用「雙方都下最好的一手、各 25% 機率失誤」的模型測過對局平衡:
> **勝 34% / 敗 40% / 和 27%,平均核心差 −0.7**。這兩個數字是這樣調出來的 ——
> 詳見下面。

<details>
<summary><b>為什麼壓制傷害是遞增的,以及為什麼要同時出牌</b></summary>

原本的規則有兩個問題,模擬下來**對上會下棋的對手,玩家勝率是 0%**:

1. **火力性價比不對稱。** 壓制傷害只看攻擊力總和,所以真正的貨幣是「每點算力換到多少
攻擊力」。憶哨兵原本 1 費 2 攻,遺忘者的雜訊是 1 費 3 攻 —— 同樣的算力,它每條迴廊
都比你高一截。這不是操作問題,是算術問題。
2. **AI 是後手,而且看得到你這回合的部署。** 在「逐條迴廊比火力」的規則下後手幾乎必勝:
只要在你投重兵的那條放掉,另外兩條各補一點就淨賺。

而原本的壓制是**贏者全拿**(多 1 點攻擊力就直接拿滿 2 點),導致平衡是離散的 ——
把哨兵調到 3 攻,結果直接從「必輸」跳成「**100% 和局**」(雙方火力永遠打平 → 誰都不受傷)。

所以三件事要一起改:

| 改動 | 作用 |
| --- | --- |
| 壓制傷害改成 `min(2, ⌈差/2⌉)` | 讓平衡從離散變連續,數值才調得動;上限仍是 2 點,沒有任何迴廊變得更危險 |
| 憶哨兵 2 → 3 攻 | 補上算術劣勢 |
| 改成同時出牌 | 拔掉 AI 的後手優勢 |

實測(每組 300 場,雙方各 25% 機率失誤):

| 設定 | 勝 / 敗 / 和 | 核心差 |
| --- | --- | --- |
| 原本 | 6% / 84% / 10% | −7.4 |
| 只改成同時出牌 | 8% / 86% / 6% | −7.8 |
| 只改傷害公式 | 3% / 85% / 12% | −8.3 |
| 傷害公式 + 同時出牌 | 5% / 89% / 6% | −10.1 |
| **三個一起改** | **34% / 40% / 27%** | **−0.7** |

單獨改任何一項都沒用 —— 遞增傷害不是解方,而是**讓數值可以被調整的前提**。

雙方都下得更好時(失誤率 10%),同時出牌的價值才明顯:玩家勝率 29% → **37%**,
和局率 35% → 28%。
</details>

---

Expand Down Expand Up @@ -226,11 +271,12 @@ API 金鑰請選 **Secret**(加密),不要用一般變數。

| 變數 | 賽道 | 必要性 | 說明 |
| --- | --- | --- | --- |
| `OPENAI_API_KEY` | 敵方 agent | 建議設 | OpenAI 金鑰。**只存在 Function 端**,不會進前端 bundle。 |
| `OPENAI_MODEL` | 敵方 agent | 選用 | 預設 `gpt-4o-mini` |
| `AI_PROVIDER` | 敵方 agent | 選用 | `openai`(預設)或 `0g`(敵方 agent 也切到 0G Compute) |
| `OG_COMPUTE_API_KEY` | 賽道一 | 建議設 | 0G Compute Router 金鑰,從 [pc.0g.ai](https://pc.0g.ai) 取得 |
| `OG_COMPUTE_API_KEY` | 賽道一 | **建議設** | 0G Compute Router 金鑰,從 [pc.0g.ai](https://pc.0g.ai) 取得。敵方 agent 與旁白 agent 都吃這把 |
| `OG_COMPUTE_MODEL` | 賽道一 | 選用 | 預設 `deepseek-chat-v3-0324` |
| `OG_COMPUTE_ATTESTATION_URL` | 賽道一 | 想驗到 enclave 等級才需要 | provider 的 attestation 端點。沒設的話「同一位 agent」最多只能驗到 `signed`(同一把金鑰),畫面會照實降級顯示 |
| `AI_PROVIDER` | 敵方 agent | 選用 | `0g`(**預設**)或 `openai`。切回 OpenAI 會讓「同一位 agent」的驗證降級 |
| `OPENAI_API_KEY` | 敵方 agent | 只有切回 OpenAI 才要 | **只存在 Function 端**,不會進前端 bundle |
| `OPENAI_MODEL` | 敵方 agent | 選用 | 預設 `gpt-4o-mini` |
| `OG_STORAGE_INDEXER` | 賽道二 | 選用 | 預設 Turbo indexer;唯讀查詢不需金鑰 |
| `OG_ZG_PROXY_BASE` | 賽道二 | **上傳必要** | 節點代理的網址(結尾要有 `/api`)。沒設的話前端會走同源的 `/api/og/zg`,那條路在 Cloudflare 上必定失敗 —— 見上面「賽道二還需要一台 Node 代理」 |
| `OG_PROXY_SECRET` | 賽道二 | 選用 | 代理用來簽節點網址的金鑰。擋的是「有人拿這個網域當跳板」,不是機密資料;沒設會用內建常數。要更嚴的話**主站與 proxy 兩邊要設成同一個值** |
Expand Down
43 changes: 39 additions & 4 deletions functions/api/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* 前端 rules.js 還會再 validate 一次。台詞用 textContent 塞進 DOM,不走 innerHTML。
*/

import { sealTurn } from './og/tee.js';

const OPENAI_BASE = 'https://api.openai.com/v1';
const OG_ROUTER_BASE = 'https://router-api.0g.ai/v1';

Expand All @@ -28,8 +30,16 @@ const json = (data, status = 200) =>
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
});

/**
* 預設 **0G Compute**,設 AI_PROVIDER=openai 才切回 OpenAI。
*
* 一開始是反過來的(預設 OpenAI,理由是每回合都要叫、要低延遲)。但那讓「TEE」
* 變成空話:跟玩家對打的 agent 根本不在 enclave 裡,就無從證明「整場都是同一位」。
* 要讓那個主張成立,敵方 agent 必須跑在 0G Compute 上並帶回可驗證的簽名。
* 沒設金鑰時仍會退回本地啟發式,並照實標示「未接上模型」。
*/
function providerConfig(env) {
const choice = String(env.AI_PROVIDER || 'openai').toLowerCase();
const choice = String(env.AI_PROVIDER || '0g').toLowerCase();
if (choice === '0g' || choice === '0g-compute') {
return {
id: '0g-compute',
Expand Down Expand Up @@ -66,8 +76,13 @@ const SYSTEM = `你是回合制策略遊戲《鏈州英雄傳 ConSSS Wars》裡
- VOID 虛數殼:cost 2,攻 2 血 7。很難殺,適合卡住迴廊。
- PURGE 清算:cost 3,法術,對指定迴廊的所有敵方單位造成 3 傷,並回復自己核心 3 點。

重要:每回合交戰「之前」會先比較每條迴廊雙方的總攻擊力,高的一方對敵方核心造成 2 點壓制傷害。
所以「在哪條迴廊投入多少火力」比單純殺兵更重要 —— 即使單位當回合就陣亡,投入的火力一樣算數。
重要:每回合交戰「之前」會先比較每條迴廊雙方的總攻擊力,高的一方對敵方核心造成壓制傷害。
傷害隨差距遞增:多 1~2 攻造成 1 點,多 3 攻以上造成 2 點(上限 2),打平不造成傷害。
所以「在哪條迴廊投入多少火力」比單純殺兵更重要 —— 即使單位當回合就陣亡,投入的火力一樣算數;
而且贏一條迴廊要贏得夠多才拿得滿,勉強超過只拿一半。

重要:你和敵人是「同時出牌」。你看到的盤面是敵人這回合部署「之前」的狀態,
所以你不知道他這回合會放什麼、放在哪一條。請照他過去的習慣與盤面威脅去推測,不要假設某條迴廊一定空著。

策略提示:敵方單位推進到 cell 1 或 2 時要優先處理;三條迴廊的火力都要顧,被壓制的迴廊每回合都在扣血;算力沒用完等於浪費。

Expand Down Expand Up @@ -231,12 +246,31 @@ export async function onRequestPost({ request, env }) {
return fallback(`${cfg.label} ${res.status}: ${detail.slice(0, 160)}`);
}

const data = await res.json();
// 原始文字要留著:證據鏈裡的 responseHash 算的是「供應商實際回了什麼」,
// 不是我們解析後的結果 —— 解析後才算就等於在證明我們自己的程式,沒有意義。
const rawText = await res.text();
let data = null;
try {
data = JSON.parse(rawText);
} catch {
return fallback(`${cfg.label} 回傳不是 JSON`);
}
const message = data && data.choices && data.choices[0] && data.choices[0].message;
const parsed = extractJson(message && message.content);
if (!parsed) return fallback(`${cfg.label} 回傳無法解析`);

const plays = sanitizePlays(parsed.plays, legal, view.yourCompute);
const evidence = await sealTurn({
turn: Number.isFinite(Number(view.turn)) ? Math.max(1, Math.min(99, Math.trunc(Number(view.turn)))) : 1,
view,
legal,
rawResponse: rawText,
headers: res.headers,
body: data,
model: cfg.model,
provider: cfg.id,
});

return json({
plays: plays.length ? plays : heuristic(view, legal),
usedFallbackPlays: plays.length === 0,
Expand All @@ -245,6 +279,7 @@ export async function onRequestPost({ request, env }) {
provider: cfg.id,
providerLabel: cfg.label,
model: cfg.model,
evidence,
});
} catch (err) {
return fallback(
Expand Down
11 changes: 9 additions & 2 deletions functions/api/og/_shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,16 @@ export async function rpc(url, method, params = [], timeoutMs = 6000) {
export const rpcUrlOf = (env) => env.OG_RPC_URL || GALILEO.rpcUrl;
export const indexerOf = (env) => env.OG_STORAGE_INDEXER || STORAGE_INDEXER;

/** 敵方 agent 的供應商。預設 OpenAI;AI_PROVIDER=0g 就整支切到 0G Compute。 */
/**
* 敵方 agent 的供應商。**預設 0G Compute**,設 AI_PROVIDER=openai 才會切回 OpenAI。
*
* 一開始是反過來的(預設 OpenAI,理由是低延遲)。但那讓「TEE」這件事變成空話:
* 跟玩家對打的 agent 根本不在 enclave 裡,也就無從證明「整場都是同一位」。
* 要讓那個主張成立,敵方 agent 必須跑在 0G Compute 上並帶回可驗證的簽名,
* 所以預設改過來。沒設金鑰時仍會退回本地啟發式,並照實標示。
*/
export function providerConfig(env) {
const choice = String(env.AI_PROVIDER || 'openai').toLowerCase();
const choice = String(env.AI_PROVIDER || '0g').toLowerCase();
if (choice === '0g' || choice === '0g-compute') return ogComputeConfig(env);
return {
id: 'openai',
Expand Down
83 changes: 83 additions & 0 deletions functions/api/og/attest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* GET /api/og/attest —— 取得 0G Compute provider 的 attestation(遠端證明)。
*
* 這是「同一位 agent」證明鏈的第一環:attestation 報告裡有 enclave 的 measurement
* (程式碼與模型的雜湊)與一把 enclave 內生成的公鑰,由硬體廠商與 0G 的驗證服務背書。
* 有了它,才能主張「後面每回合的簽名確實出自這個 enclave」,而不只是「出自某把金鑰」。
*
* ── 為什麼要吃環境變數 ──
*
* attestation 的實際端點與回應格式,我們沒有辦法在建置環境裡打一次確認
* (*.0g.ai 在那裡連不到)。與其猜一個網址寫死、然後在評審面前 404,
* 不如讓它可設定:OG_COMPUTE_ATTESTATION_URL 指到哪就打哪。
*
* 沒設定時**照實回報沒設定**,不編造。前端會把它顯示成「未取得 attestation」,
* 而不是綠燈 —— 這個功能的價值完全建立在「不說謊」上面。
*/

import { json, ogComputeConfig } from './_shared.js';
import { extractEvidence } from './tee.js';

const TIMEOUT_MS = 8000;

export async function onRequestGet({ env }) {
const cfg = ogComputeConfig(env);
const url = env.OG_COMPUTE_ATTESTATION_URL;

if (!url) {
return json({
configured: false,
endpoint: null,
model: cfg.model,
router: cfg.base,
error:
'未設定 OG_COMPUTE_ATTESTATION_URL。沒有 attestation 就無法證明簽章金鑰長在 enclave 裡 —— ' +
'這種情況下最多只能證明「所有回合出自同一把金鑰」,畫面會照實標示。',
});
}

const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ac.signal,
headers: {
accept: 'application/json',
...(cfg.key ? { authorization: `Bearer ${cfg.key}` } : {}),
},
});
const text = await res.text();
let body = null;
try {
body = JSON.parse(text);
} catch {
body = { raw: text.slice(0, 600) };
}
if (!res.ok) {
return json({ configured: true, endpoint: url, ok: false, status: res.status, error: text.slice(0, 200) });
}

// 欄位名不押寶,跟每回合的簽名用同一套容忍抽取
const found = extractEvidence(res.headers, body);
return json({
configured: true,
endpoint: url,
ok: Boolean(found.measurement || found.quote),
measurement: found.measurement || null,
publicKey: found.signer || null,
quotePresent: Boolean(found.quote),
model: cfg.model,
router: cfg.base,
raw: body,
});
} catch (err) {
return json({
configured: true,
endpoint: url,
ok: false,
error: String(err && err.name === 'AbortError' ? '逾時' : (err && err.message) || err).slice(0, 200),
});
} finally {
clearTimeout(timer);
}
}
62 changes: 62 additions & 0 deletions functions/api/og/same-agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* POST /api/og/same-agent —— 驗證整場對戰是不是同一位 AI agent。
*
* 收下前端累積的每回合證據鏈(盤面雜湊、回應雜湊、簽名、簽章公鑰、模型),
* 加上 /api/og/attest 拿到的 attestation,回報能證明到什麼程度。
*
* 這支刻意**不會**回傳單純的 true/false。能證明到哪一層取決於供應商給了什麼材料,
* 把「只是紀錄一致」講成「已驗證」是這整個功能最容易犯、也最不該犯的錯。
* 分級的定義見 tee.js 的 verifyChain。
*/

import { json, clampStr, clampInt } from './_shared.js';
import { verifyChain } from './tee.js';

const MAX_BODY = 32 * 1024;
const MAX_TURNS = 12;

/** 只留白名單欄位,長度夾住 —— 這些字串會直接進畫面。 */
const normalizeTurn = (t) => ({
turn: clampInt(t && t.turn, 1, 99, 1),
boardHash: clampStr(t && t.boardHash, 70),
responseHash: clampStr(t && t.responseHash, 70),
signature: clampStr(t && t.signature, 400) || null,
signer: clampStr(t && t.signer, 200) || null,
measurement: clampStr(t && t.measurement, 200) || null,
proofId: clampStr(t && t.proofId, 120) || null,
model: clampStr(t && t.model, 60) || null,
provider: clampStr(t && t.provider, 40) || null,
});

export async function onRequestPost({ request }) {
let payload;
try {
const raw = await request.text();
if (raw.length > MAX_BODY) return json({ error: 'payload too large' }, 413);
payload = JSON.parse(raw);
} catch {
return json({ error: 'invalid json' }, 400);
}

const chain = Array.isArray(payload && payload.chain)
? payload.chain.slice(0, MAX_TURNS).map(normalizeTurn)
: [];
const attestation = payload && typeof payload.attestation === 'object' ? payload.attestation : null;

const verdict = verifyChain(chain, attestation);
return json({
...verdict,
// 逐回合攤開,讓畫面可以一格一格點亮,而不是只給一句結論
perTurn: chain.map((t) => ({
turn: t.turn,
boardHash: t.boardHash,
responseHash: t.responseHash,
signed: Boolean(t.signature),
signer: t.signer,
model: t.model,
sameAsFirst: !t.signer || !chain[0].signer ? null : t.signer === chain[0].signer,
})),
});
}

export const onRequestGet = () => json({ error: 'use POST' }, 405);
Loading