From c2ffd14ed103827262408a79ecea32a9bf5deb71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 08:07:22 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E5=B0=8D=E5=B1=80=E5=B9=B3=E8=A1=A1?= =?UTF-8?q?=E9=87=8D=E5=81=9A=EF=BC=9A=E5=A3=93=E5=88=B6=E5=82=B7=E5=AE=B3?= =?UTF-8?q?=E6=94=B9=E6=88=90=E9=81=9E=E5=A2=9E=E3=80=81=E5=93=A8=E5=85=B5?= =?UTF-8?q?=E8=A3=9C=E5=88=B0=203=20=E6=94=BB=E3=80=81=E9=9B=99=E6=96=B9?= =?UTF-8?q?=E5=90=8C=E6=99=82=E5=87=BA=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用搜尋器把這局玩到底之後發現:對上會下棋的對手,玩家勝率是 0%。不是操作問題, 是兩個結構問題。 一、火力性價比不對稱 壓制傷害只看攻擊力總和,所以真正的貨幣是「每點算力換到多少攻擊力」。 憶哨兵 1 費 2 攻,遺忘者的雜訊 1 費 3 攻 —— 同樣的算力,它每條迴廊都比你高一截。 二、AI 是後手,而且看得到你這回合的部署 原本流程是玩家部署完才把盤面送去問 agent。在「逐條迴廊比火力」的規則下, 後手幾乎必勝:在你投重兵的那條放掉,另外兩條各補一點就淨賺。 而原本的壓制是贏者全拿(多 1 點攻擊力就拿滿 2 點),使得平衡是離散的: 把哨兵調到 3 攻,結果直接從「必輸」跳成 100% 和局(雙方火力永遠打平,誰都不受傷)。 這就是為什麼三件事得一起改 —— 遞增傷害不是解方,是讓數值可以被調整的前提。 改動: · 壓制傷害 min(2, ceil(火力差 / 2))。上限仍是 2 點,沒有任何一條迴廊變得比以前 更危險,但小優勢只換到小傷害,要拿滿得壓過 3 攻以上。 · 憶哨兵 2 → 3 攻(血量 3 維持,仍比雜訊的 2 血耐打)。 · 同時出牌:回合一開始就用「玩家部署前」的快照去問 agent,按下結束回合時才 await。 附帶好處是它的思考時間跟玩家重疊,回合更順。它從舊盤面挑的手若已不合法, applyAgentPlays 本來就每一手都 validate,會自動略過 —— 這正是戰爭迷霧該有的樣子。 · agent 的 system prompt 同步更新:新的傷害公式,以及「你看不到對方這回合的部署」。 · 簡報文案與 README 規則同步。 實測(每組 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 單獨改任何一項都沒用。 參數敏感度(三個一起改的前提下): min(2, ceil(差/2)) 勝 43 / 敗 35 / 和 21 ← 採用 min(3, ceil(差/2)) 勝 39 / 敗 37 / 和 24 min(3, ceil(差/1)) 勝 33 / 敗 37 / 和 30 min(3, ceil(差/3)) 勝 17 / 敗 61 / 和 22 失誤率從 10% 到 40%,勝率穩在 28–38%、核心差約 -1,不是踩在刀鋒上。 雙方都下得更好時(失誤率 10%)同時出牌的價值才明顯:勝率 29% → 37%, 和局率 35% → 28%。 npm run sim(隨機出牌)也跟著變健康:勝 30–33% / 敗 46–54%,平均 4.3–4.4 回合 (原本是勝約兩成、敗 59–69%,平均 3.7–3.8)。 已知待改善:和局率 21–27%,雙方越接近最佳解越高。滿 7 回合核心相同時要判誰贏 是設計取捨,還沒動。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3 --- README.md | 50 ++++++++++++++++++++++++++++++++++++++---- functions/api/agent.js | 9 ++++++-- public/js/main.js | 38 ++++++++++++++++++++++++++++---- public/js/rules.js | 25 +++++++++++++++++---- public/js/story.js | 4 ++-- 5 files changed, 110 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d42de2f..300aa11 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,9 @@ inline SVG:戰場、卡牌、遺忘者的遮罩、結果畫面都沒有圖檔 - 三條「記憶迴廊」,每條 3 格。你在左、遺忘者在右。 - 每回合 **2 點算力** —— 顧不了三條,**選哪條放掉就是勝負**。 -- 交戰**之前**先比火力:一條迴廊誰的攻擊力總和高,就啃對方核心 **2 點**。 +- 交戰**之前**先比火力:一條迴廊贏多少就啃多少 —— 多 **1~2 攻**啃 1 點,多 **3 攻以上**啃滿 2 點,打平不啃。 單位當回合陣亡也算數,所以「投入多少」比「殺幾隻」重要。 +- **雙方同時出牌。** AI agent 拿到的是你部署**之前**的盤面 —— 它看不到你這回合放了什麼。 - 貼身或迎面撞上的單位互砍;打贏還活著的會乘勝追擊繼續前進。 - 走出邊界的單位直接打核心。核心 12 點,共 7 回合,先歸零的一方輸。 @@ -127,9 +128,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**。這兩個數字是這樣調出來的 —— +> 詳見下面。 + +
+為什麼壓制傷害是遞增的,以及為什麼要同時出牌 + +原本的規則有兩個問題,模擬下來**對上會下棋的對手,玩家勝率是 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%。 +
--- diff --git a/functions/api/agent.js b/functions/api/agent.js index 1be468e..e3c58dd 100644 --- a/functions/api/agent.js +++ b/functions/api/agent.js @@ -66,8 +66,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 時要優先處理;三條迴廊的火力都要顧,被壓制的迴廊每回合都在扣血;算力沒用完等於浪費。 diff --git a/public/js/main.js b/public/js/main.js index 4688f7d..0d2f659 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -48,6 +48,7 @@ const game = { busy: false, agentInfo: { providerLabel: '尚未呼叫', provider: null, model: null }, agentTurns: [], + agentPending: null, ogStatus: null, wallet: null, shard: null, @@ -203,9 +204,10 @@ function startBattle() { game.agentTurns = []; game.shard = null; el.taunt.classList.add('is-empty'); - setAgentChip('AI agent 待命'); + game.agentPending = null; show('battle'); render(); + beginAgentThinking(); } function setAgentChip(text, thinking = false) { @@ -367,6 +369,33 @@ function playSelected(lane) { render(); } +/** + * 同時出牌:回合一開始就用「玩家還沒部署」的盤面去問 agent。 + * + * 原本是等玩家按下結束回合、才把當前盤面送過去 —— 那等於它拿到你的答案卷才作答。 + * 在「逐條迴廊比火力」的規則下後手幾乎必勝:只要在你投重兵的那條放掉, + * 另外兩條各補一點就淨賺。模擬顯示對上會下棋的對手,玩家勝率是 0%。 + * + * 改成回合開始就送出,還有一個附帶好處:它的思考時間跟你的重疊,回合更順。 + * 它從舊盤面挑的手可能已經不合法(例如你用溯憶術清掉了它的出兵格附近), + * applyAgentPlays 每一手都會 validate,不合法的自動略過 —— 這正是戰爭迷霧該有的樣子。 + */ +function beginAgentThinking() { + const snapshot = R.cloneState(game.state); + const summary = `第 ${snapshot.turn} 回合,我方核心 ${snapshot.core.forgetter},敵方核心 ${snapshot.core.hero}`; + setAgentChip('AI agent 讀盤中…', true); + // 刻意不 await:讓它在玩家思考的同時決策 + game.agentPending = askAgent(snapshot, summary).catch((err) => ({ + plays: [], + taunt: '', + reason: '', + providerLabel: '本地備援', + provider: null, + model: null, + error: String((err && err.message) || err), + })); +} + async function endTurn() { if (game.busy || game.state.over) return; game.busy = true; @@ -374,10 +403,10 @@ async function endTurn() { render(); // ── 遺忘者(AI agent)回合 ── - setAgentChip('AI agent 讀盤中…', true); + // 它在你部署之前就開始想了,這裡只是等它回來 sfx.agent(); - const summary = `第 ${game.state.turn} 回合,我方核心 ${game.state.core.forgetter},敵方核心 ${game.state.core.hero}`; - const decision = await askAgent(game.state, summary); + const decision = await (game.agentPending || Promise.resolve({ plays: [], taunt: '', reason: '', providerLabel: '本地備援' })); + game.agentPending = null; game.agentInfo = decision; const { applied } = applyAgentPlays(game.state, decision.plays, R.applyPlay); @@ -417,6 +446,7 @@ async function endTurn() { el.trayHint.textContent = '選一張牌,再點一條迴廊部署。'; game.busy = false; render(); + beginAgentThinking(); // 新回合:它跟你同時開始想 } /** 動畫刻意壓在 1 秒內:一分鐘的遊戲不能把時間花在等特效。 */ diff --git a/public/js/rules.js b/public/js/rules.js index fb897a5..716076d 100644 --- a/public/js/rules.js +++ b/public/js/rules.js @@ -29,7 +29,11 @@ export const HERO_CARDS = { name: '憶哨兵', en: 'Memory Sentinel', cost: 1, - atk: 2, + // 3 攻是刻意跟遺忘者的雜訊看齊的。壓制傷害只看攻擊力總和,所以真正的貨幣是 + // 「每點算力換到多少攻擊力」—— 哨兵原本 2 攻、雜訊 3 攻,等於玩家每條迴廊 + // 都用比較差的匯率在買,模擬下來對上會下棋的對手是 0% 勝率。拉平之後 + // 玩家的優勢改成體現在血量(3 vs 2)與零重刃的 4 攻爆發上。 + atk: 3, hp: 3, kind: 'unit', glyph: '哨', @@ -228,13 +232,25 @@ function sweepDead(state, events) { * 5) 壓制:交戰「之前」先比較每條迴廊雙方的總攻擊力,高的一方打對方核心。 * 「誰站得住」改成「誰的火力大」,是為了打破鏡像僵局 —— 只看有沒有人的話, * 雙方每回合各補一個擋路兵就能鎖死全部三條迴廊,整局零傷害收在和局。 - * 比火力就一定會分出高下,玩家也有明確的施力點:這條要壓過去就得投更重的牌。 * 先結算是因為交戰常常同歸於盡,等打完再看迴廊早就空了,投入多少都白費。 * + * 傷害隨火力差遞增,不是贏者全拿。原本只要多 1 點攻擊力就直接拿滿 2 點, + * 整個平衡因此變成「誰的算力換攻擊力效率高,誰就贏走一切」的離散跳躍: + * 模擬顯示調 1 點攻擊力,結果會在「必輸 / 必和 / 必贏」之間直接跳過去, + * 中間沒有可以調的地帶。改成 min(2, ceil(差 / 2)) 之後上限仍是 2 點 + * (沒有任何一條迴廊變得比以前更危險),但小優勢只換到小傷害, + * 要拿滿得壓過 3 攻以上 —— 「這條要壓過去就得投更重的牌」才真的有梯度回報。 + * * 推進刻意「同時」結算:先算出所有單位的意圖再一起套用, * 否則先掃描到的一方會白撿一格,形成不公平的先手優勢。 */ +/** 壓制傷害的上限。實際傷害是 min(CONTROL_DAMAGE, ceil(火力差 / CONTROL_STEP))。 */ export const CONTROL_DAMAGE = 2; +export const CONTROL_STEP = 2; + +/** 火力差 → 壓制傷害。差 1~2 給 1 點,差 3 以上給滿 2 點,打平不給。 */ +export const controlDamage = (diff) => + diff <= 0 ? 0 : Math.min(CONTROL_DAMAGE, Math.ceil(diff / CONTROL_STEP)); export function resolveCombat(state) { const events = []; @@ -252,8 +268,9 @@ export function resolveCombat(state) { if (heroAtk === foeAtk) continue; // 火力打平就沒有人拿到控制權 const winner = heroAtk > foeAtk ? 'hero' : 'forgetter'; const loser = winner === 'hero' ? 'forgetter' : 'hero'; - state.core[loser] -= CONTROL_DAMAGE; - events.push({ kind: 'control', side: winner, lane: l, amount: CONTROL_DAMAGE }); + const amount = controlDamage(Math.abs(heroAtk - foeAtk)); + state.core[loser] -= amount; + events.push({ kind: 'control', side: winner, lane: l, amount }); } // 2) 貼身交戰 diff --git a/public/js/story.js b/public/js/story.js index cfe25a5..888622e 100644 --- a/public/js/story.js +++ b/public/js/story.js @@ -39,9 +39,9 @@ export const BRIEFING = { lines: [ '三條「記憶迴廊」,你在左、遺忘者在右。', '每回合 2 點算力 —— 顧不了三條,選哪條放掉就是勝負。', - '交戰前先比火力:一條迴廊誰的攻擊力總和高,就啃對方核心 2 點。', + '交戰前先比火力:一條迴廊贏多少就啃多少 —— 多 1~2 攻啃 1 點,多 3 攻以上啃滿 2 點。', '核心 12 點,共 7 回合,先歸零的一方輸。', - '對面是真的 AI agent —— 它每回合重新讀盤、重新決定。', + '對面是真的 AI agent —— 它跟你同時出牌,看不到你這回合放了什麼。', ], }; From 8d49fe8fae719d6f3f3a9daac9560d12863e44e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 08:18:23 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E6=95=B5=E6=96=B9=20agent=20=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=200G=20Compute=EF=BC=8C=E4=B8=A6=E5=81=9A=E5=87=BA?= =?UTF-8?q?=E3=80=8C=E6=95=B4=E5=A0=B4=E9=83=BD=E6=98=AF=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=BD=8D=20agent=E3=80=8D=E7=9A=84=E8=AD=89=E6=93=9A=E9=8F=88?= =?UTF-8?q?=E8=88=87=E9=A9=97=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先講原本的問題:我們宣稱用了 TEE,但跟玩家對打的 agent 走 OpenAI,TEE 只掛在 戰後旁白那一段,而且沒有驗 attestation。也就是說「TEE 驗證了對手身分」這個說法 當時完全不成立。 要讓它成立,三件事缺一不可,這個 commit 三件都做了。 一、敵方 agent 預設改跑 0G Compute AI_PROVIDER 預設從 openai 改成 0g(agent.js 自己那份 providerConfig 與 _shared.js 兩處都要改,這支 Function 原本是自給自足、沒有 import 的)。設成 openai 仍可切回, 但畫面上的驗證會照實降級。 二、每回合封存證據,串成鏈,並封進記憶碎片 functions/api/og/tee.js 新增。每回合存: boardHash 它看到的盤面(送出去的原始內容算的雜湊) responseHash 供應商回的原始文字算的雜湊 —— 刻意不是解析後的結果, 解析後才算等於在證明我們自己的程式,沒有意義 signature / signer / measurement enclave 簽名與公鑰 鏈存進碎片的 teeChain,所以會被錨定上鏈的 SHA-256 蓋住:第三方拿鏈上摘要就能重驗 整場,而不是只能相信我們的伺服器。 三、驗證與分級 POST /api/og/same-agent 收下證據鏈與 attestation,回報能證明到哪一層: attested 全簽 + 同一把公鑰 + 對得上 attestation 的 measurement(enclave 等級) signed 全簽 + 同一把公鑰,但沒拿到 attestation changed 有簽名但公鑰中途換過 —— 這是否定結果,正是「agent 被換掉」的樣子 consistent 沒有(或只有部分)簽名,只有供應商與模型一致 —— 我們伺服器的紀錄而已 none 沒有紀錄,或供應商/模型本身就換過 GET /api/og/attest 取 attestation,端點吃 OG_COMPUTE_ATTESTATION_URL,沒設就照實 回報沒設。 刻意不回傳單純的 true/false。把「只是紀錄一致」講成「已驗證」是這個功能最容易犯、 也最不該犯的錯 —— 那比沒有這個功能更糟。 欄位名為什麼寫得這麼容忍:0G 把簽名與 attestation 放在哪個欄位、標頭叫什麼, 沒辦法在建置環境裡實際打一次確認(*.0g.ai 在那裡連不到)。所以標頭與 JSON 樹都 走訪一遍,看到像簽名/公鑰/measurement 的鍵就收。抽不到就回報抽不到。 前端 結果畫面新增「TEE 驗證 · 是同一位 agent 嗎」按鈕與面板:徽章按等級變色, 逐回合一格燈(同一把公鑰=藍、換過=橘、沒簽名=空心),下面攤開簽名覆蓋率、 模型、供應商、公鑰、measurement。只有 attested 拿得到藍燈。 測試 verifyChain 六種情境全過,包含修掉的一個訊息 bug:公鑰中途換過時原本會落到 consistent,訊息寫「供應商沒有回傳可驗證的簽名(2/2 回合有簽名)」—— 自相矛盾, 而且把最該警告的狀況講成中性。現在是獨立的 changed 等級。 抽取器對「標頭 + 巢狀 body」都撈得到,沒有材料時正確回報空的。 瀏覽器實跑三條路徑: 沒有金鑰 → none / 「沒有任何回合的紀錄」 七回合同一把 → attested / 逐回合 ok ok ok ok ok ok ok 第 5 回合換掉 → changed / ok ok ok ok changed ok ok Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3 --- .env.example | 18 +++- README.md | 12 ++- functions/api/agent.js | 34 ++++++- functions/api/og/_shared.js | 11 ++- functions/api/og/attest.js | 83 ++++++++++++++++ functions/api/og/same-agent.js | 62 ++++++++++++ functions/api/og/shard.js | 23 +++++ functions/api/og/tee.js | 176 +++++++++++++++++++++++++++++++++ public/css/style.css | 97 ++++++++++++++++++ public/index.html | 21 ++++ public/js/ai.js | 3 + public/js/main.js | 111 +++++++++++++++++++++ 12 files changed, 640 insertions(+), 11 deletions(-) create mode 100644 functions/api/og/attest.js create mode 100644 functions/api/og/same-agent.js create mode 100644 functions/api/og/tee.js diff --git a/.env.example b/.env.example index f6ac0ab..e8dd0da 100644 --- a/.env.example +++ b/.env.example @@ -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 或自架相容端點時才需要改 @@ -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), # 不需要在這裡設任何東西,伺服器也不碰私鑰。 diff --git a/README.md b/README.md index 300aa11..4ae11ef 100644 --- a/README.md +++ b/README.md @@ -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 ``` --- @@ -268,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 兩邊要設成同一個值** | diff --git a/functions/api/agent.js b/functions/api/agent.js index e3c58dd..8479d64 100644 --- a/functions/api/agent.js +++ b/functions/api/agent.js @@ -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'; @@ -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', @@ -236,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, @@ -250,6 +279,7 @@ export async function onRequestPost({ request, env }) { provider: cfg.id, providerLabel: cfg.label, model: cfg.model, + evidence, }); } catch (err) { return fallback( diff --git a/functions/api/og/_shared.js b/functions/api/og/_shared.js index 4fe76db..2323c23 100644 --- a/functions/api/og/_shared.js +++ b/functions/api/og/_shared.js @@ -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', diff --git a/functions/api/og/attest.js b/functions/api/og/attest.js new file mode 100644 index 0000000..e4b6244 --- /dev/null +++ b/functions/api/og/attest.js @@ -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); + } +} diff --git a/functions/api/og/same-agent.js b/functions/api/og/same-agent.js new file mode 100644 index 0000000..483cd0d --- /dev/null +++ b/functions/api/og/same-agent.js @@ -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); diff --git a/functions/api/og/shard.js b/functions/api/og/shard.js index 12ae97c..48331bd 100644 --- a/functions/api/og/shard.js +++ b/functions/api/og/shard.js @@ -52,6 +52,29 @@ function normalize(raw) { taunt: clampStr(t && t.taunt, 60), })) : [], + + /* + * 每回合的 TEE 證據鏈。 + * + * 這一段的存在理由是:碎片的 SHA-256 會被錨定到 0G Chain,所以把證據鏈封進碎片 + * 之後,「整場七回合都是同一位 agent」這件事就被鏈上摘要蓋住了 —— 第三方拿著 + * 鏈上那筆交易,就能重新驗證整場,而不是只能相信我們的伺服器。 + * + * 只存雜湊與簽名,不存盤面原文與模型回覆全文:碎片要小(要付儲存費), + * 而雜湊已經足以綁定內容。簽名長度夾在 400 字內,避免單一欄位撐爆碎片。 + */ + teeChain: Array.isArray(raw && raw.teeChain) + ? raw.teeChain.slice(0, 12).map((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), + signer: clampStr(t && t.signer, 200), + measurement: clampStr(t && t.measurement, 200), + model: clampStr(t && t.model, 60), + provider: clampStr(t && t.provider, 40), + })) + : [], createdAt: new Date().toISOString(), }; } diff --git a/functions/api/og/tee.js b/functions/api/og/tee.js new file mode 100644 index 0000000..904fcb1 --- /dev/null +++ b/functions/api/og/tee.js @@ -0,0 +1,176 @@ +/** + * TEE 證據的抽取與驗證。 + * + * 我們想證明的一件事:**跟你對打的 AI agent,整場七回合都是同一位,中途沒被換掉。** + * + * 0G Compute 的做法是:provider 啟動時在 enclave 內生成一組簽章金鑰,私鑰永遠不出 + * enclave;CPU/GPU 的 attestation 報告把那把公鑰綁定到這個 enclave 環境(含程式碼與 + * 模型的 measurement)。之後每一次推論的回應都用那把 enclave 私鑰簽名。 + * + * 於是「同一位」的證明就是: + * 1. 每回合的回應都有簽名,且 + * 2. 所有簽名都能用同一把公鑰驗過,且 + * 3. 那把公鑰綁定的 measurement 從頭到尾沒變。 + * + * ── 這支為什麼寫得這麼「容忍」── + * + * 0G 把驗證材料放在回應的哪個欄位、標頭叫什麼名字,我們沒有辦法在開發環境裡實際打一次 + * 確認(docs.0g.ai 在我們的建置環境連不到)。所以這裡不押寶在單一欄位名上: + * 標頭與 JSON 樹都走訪一遍,看到像簽名 / 公鑰 / measurement 的鍵就收下來。 + * + * 最重要的原則:**抽不到就回報抽不到**。寧可讓畫面顯示「供應商沒有回傳簽名」, + * 也不要因為欄位名猜錯就假裝驗過了 —— 那比沒有這個功能更糟。 + */ + +import { sha256Hex } from './_shared.js'; + +/** 這些鍵名任一出現(不分大小寫、忽略底線與連字號)就當成該類材料。 */ +const KEYS = { + signature: ['signature', 'sig', 'responsesignature', 'teesignature', 'ogsignature', 'proofsignature'], + signer: ['signeraddress', 'signingaddress', 'signer', 'publickey', 'pubkey', 'signingkey', 'teepublickey', 'providerpubkey'], + measurement: ['measurement', 'mrenclave', 'mrsigner', 'enclavemeasurement', 'codehash', 'imagehash', 'rtmr'], + quote: ['quote', 'attestation', 'attestationreport', 'rareport', 'teequote', 'evidence'], + proofId: ['proofid', 'proof', 'requestid', 'inferenceid'], +}; + +const norm = (k) => String(k).toLowerCase().replace(/[-_\s]/g, ''); + +/** 值要像個「證據」:夠長的字串或 hex,不是 true/1 這種佔位。 */ +function usable(v) { + if (typeof v === 'string') return v.trim().length >= 8; + if (v && typeof v === 'object') return Object.keys(v).length > 0; + return false; +} + +const shorten = (v) => { + const s = typeof v === 'string' ? v : JSON.stringify(v); + return s.length > 400 ? s.slice(0, 400) + '…' : s; +}; + +/** + * 走訪整棵 JSON 樹 + 回應標頭,把各類材料撈出來。 + * 同一類撈到多個時取第一個 —— 巢狀越淺的越可能是正主,所以用廣度優先。 + */ +export function extractEvidence(headers, body) { + const found = {}; + const take = (kind, value) => { + if (!found[kind] && usable(value)) found[kind] = typeof value === 'string' ? value.trim() : value; + }; + + if (headers && typeof headers.forEach === 'function') { + headers.forEach((value, name) => { + const n = norm(name).replace(/^x/, ''); + for (const [kind, names] of Object.entries(KEYS)) { + if (names.some((k) => n === k || n.endsWith(k))) take(kind, value); + } + }); + } + + const queue = [body]; + let guard = 0; + while (queue.length && guard++ < 500) { + const node = queue.shift(); + if (!node || typeof node !== 'object') continue; + if (Array.isArray(node)) { queue.push(...node.slice(0, 32)); continue; } + for (const [k, v] of Object.entries(node)) { + const n = norm(k); + for (const [kind, names] of Object.entries(KEYS)) { + if (names.includes(n)) take(kind, v); + } + if (v && typeof v === 'object') queue.push(v); + } + } + return found; +} + +/** 這一回合的證據封包。boardHash 綁住「它看到的盤面」,responseHash 綁住「它說了什麼」。 */ +export async function sealTurn({ turn, view, legal, rawResponse, headers, body, model, provider }) { + const evidence = extractEvidence(headers, body); + const enc = new TextEncoder(); + return { + turn, + // 盤面雜湊用送出去的原始內容算,之後可以重算比對,證明沒有被事後修改 + boardHash: await sha256Hex(enc.encode(JSON.stringify({ board: view, legalPlays: legal }))), + responseHash: await sha256Hex(enc.encode(String(rawResponse || ''))), + model: model || null, + provider: provider || null, + signature: evidence.signature ? shorten(evidence.signature) : null, + signer: evidence.signer ? shorten(evidence.signer) : null, + measurement: evidence.measurement ? shorten(evidence.measurement) : null, + proofId: evidence.proofId ? shorten(evidence.proofId) : null, + }; +} + +/** + * 驗一整場:所有回合是不是同一位 agent。 + * + * 回傳的 level 是刻意分級的,因為能證明到什麼程度取決於供應商給了什麼: + * + * attested 有簽名、同一把公鑰,而且公鑰對得上 attestation 的 measurement + * → 這才是「enclave 等級」的證明 + * signed 有簽名、同一把公鑰,但拿不到 attestation + * → 證明所有回應出自同一把金鑰,但沒證明那把金鑰真的長在 enclave 裡 + * changed 有簽名,但公鑰中途換過 + * → 這是**否定**的結果,不是中性的。它正是「agent 被換掉」的樣子, + * 必須顯眼地講出來,不能跟「沒拿到材料」混為一談 + * consistent 沒有(或只有部分)簽名,只有「每回合供應商與模型都相同」 + * → 這只是我們自己伺服器的紀錄,你得相信我們。不是密碼學證明。 + * none 連一回合的紀錄都沒有,或供應商/模型本身就換過 + * + * 前端必須照這個分級顯示,不可以把 consistent 畫成綠燈。 + */ +export function verifyChain(chain, attestation) { + const turns = Array.isArray(chain) ? chain : []; + if (turns.length === 0) return { level: 'none', ok: false, turns: 0, reason: '沒有任何回合的紀錄' }; + + const signers = new Set(turns.map((t) => t.signer).filter(Boolean)); + const models = new Set(turns.map((t) => t.model).filter(Boolean)); + const providers = new Set(turns.map((t) => t.provider).filter(Boolean)); + const signed = turns.filter((t) => t.signature).length; + + const sameModel = models.size <= 1; + const sameProvider = providers.size <= 1; + const sameSigner = signers.size === 1; + const allSigned = signed === turns.length; + + const attestedKey = attestation && (attestation.publicKey || attestation.signer) || null; + const keyMatches = + Boolean(attestedKey) && sameSigner && [...signers][0] + ? String([...signers][0]).toLowerCase().includes(String(attestedKey).toLowerCase().slice(0, 16)) + : false; + + let level = 'none'; + if (allSigned && sameSigner && keyMatches && attestation && attestation.measurement) level = 'attested'; + else if (allSigned && sameSigner) level = 'signed'; + else if (signed > 0 && signers.size > 1) level = 'changed'; + else if (sameModel && sameProvider) level = 'consistent'; + + const noAttest = attestation && attestation.error ? `(${attestation.error})` : ''; + const reason = + level === 'attested' + ? '每回合都有簽名、同一把 enclave 公鑰,且對得上 attestation 的 measurement' + : level === 'signed' + ? `每回合都有簽名且出自同一把公鑰,但沒取得 attestation${noAttest},無法證明那把金鑰長在 enclave 裡` + : level === 'changed' + ? `簽章公鑰中途換過(這 ${turns.length} 個回合出現了 ${signers.size} 把不同的公鑰)—— 這正是「agent 被換掉」的樣子,不能主張是同一位` + : level === 'consistent' + ? signed === 0 + ? '供應商沒有回傳任何可驗證的簽名。目前只能說每回合的供應商與模型相同 —— 這是我們伺服器的紀錄,不是密碼學證明' + : `只有 ${signed}/${turns.length} 個回合帶回簽名,其餘沒有,不足以主張整場都被簽過。供應商與模型本身是一致的,但那只是我們伺服器的紀錄` + : '每回合的供應商或模型不一致,無法主張是同一位 agent'; + + return { + level, + ok: level === 'attested', + turns: turns.length, + signedTurns: signed, + sameSigner, + sameModel, + sameProvider, + signer: sameSigner ? [...signers][0] : null, + models: [...models], + providers: [...providers], + measurement: (attestation && attestation.measurement) || null, + reason, + }; +} diff --git a/public/css/style.css b/public/css/style.css index 18aabdf..9057e2b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1229,3 +1229,100 @@ body { white-space: nowrap; border: 0; } + +/* + * TEE 驗證面板。 + * + * 燈號刻意分四級而不是「通過/不通過」:能證明到哪一層取決於供應商回傳了什麼。 + * 把「只是紀錄一致」畫成綠燈,比沒有這個功能更糟 —— 所以顏色由 data-level 決定, + * 只有 attested(有簽名、同一把公鑰、對得上 measurement)才拿得到藍燈。 + */ +.tee { + margin-top: 12px; + border: 2px solid var(--ink); + background: var(--paper); + padding: 10px 12px 12px; +} + +.tee-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.tee-title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.tee-badge { + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + border: 1.5px solid var(--ink); + white-space: nowrap; +} + +.tee-badge[data-level='attested'] { background: var(--blue); color: #fff; border-color: var(--blue); } +.tee-badge[data-level='signed'] { background: #F2C36B; color: var(--ink); } +.tee-badge[data-level='consistent'] { background: var(--paper-edge); color: var(--muted); } +.tee-badge[data-level='changed'] { background: var(--terracotta); color: #fff; border-color: var(--terracotta); } +.tee-badge[data-level='none'] { background: var(--terracotta); color: #fff; border-color: var(--terracotta); } + +.tee-reason { + margin: 0 0 8px; + font-size: 11px; + line-height: 1.6; + color: var(--muted); +} + +/* 每回合一格。玩家要一眼看出「七回合都是同一盞燈」。 */ +.tee-turns { + list-style: none; + margin: 0 0 8px; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tee-turn { + display: flex; + align-items: center; + gap: 5px; + border: 1.5px solid var(--ink); + padding: 3px 7px; + font-family: var(--mono); + font-size: 10px; +} + +.tee-turn-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--paper-edge); + border: 1.5px solid var(--ink); + flex: none; +} + +.tee-turn[data-state='ok'] .tee-turn-dot { background: var(--blue); border-color: var(--blue); } +.tee-turn[data-state='changed'] .tee-turn-dot { background: var(--terracotta); border-color: var(--terracotta); } +.tee-turn[data-state='unsigned'] .tee-turn-dot { background: transparent; } + +.tee-meta { + margin: 0; + font-family: var(--mono); + font-size: 10px; + line-height: 1.7; + color: var(--muted); + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 10px; +} + +.tee-meta dt { color: var(--ink); } +.tee-meta dd { margin: 0; overflow-wrap: anywhere; } diff --git a/public/index.html b/public/index.html index 7027c87..cf052a9 100644 --- a/public/index.html +++ b/public/index.html @@ -183,8 +183,29 @@

>在 0G Storage 上查看 ↗ + +
+ +
+

+ + +