Skip to content
Open
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 examples/demo-state/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# demo-state — sample fixtures for demo mode

Everything under this directory is **hand-written sample data**, not the output of a model run.
It mirrors the on-disk layout of `~/.civagent/` (tournaments/, matches/) so that
`frontend/scripts/build-demo-fixtures.mjs` can run the *real* server routes against it and
snapshot their JSON responses into `frontend/src/demo/fixtures.json`.

Every record carries `demo: true`, the backend id `demo-fixture`, and a `【示例数据】`/`[DEMO]` prefix
in its text so it can never be mistaken for a real run.
7 changes: 7 additions & 0 deletions examples/demo-state/matches/demo-m-athens/events.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{"matchId": "demo-m-athens", "regime": "global/athens", "backend": "demo-fixture", "ts": 1790000002000, "seq": 0, "type": "match_start", "actor": "system", "text": "[DEMO] sample match start"}
{"matchId": "demo-m-athens", "regime": "global/athens", "backend": "demo-fixture", "ts": 1790000011000, "seq": 1, "type": "turn", "actor": "athens#boule", "text": "[DEMO] Council of 500 drafts a probouleuma: rotate hoplite garrisons at frontier demes; fund watch-towers from the silver reserve."}
{"matchId": "demo-m-athens", "regime": "global/athens", "backend": "demo-fixture", "ts": 1790000020000, "seq": 2, "type": "turn", "actor": "athens#rhetor", "text": "[DEMO] Rhetor argues the assembly must vote the garrison budget each season, not grant it once."}
{"matchId": "demo-m-athens", "regime": "global/athens", "backend": "demo-fixture", "ts": 1790000029000, "seq": 3, "type": "turn", "actor": "athens#ekklesia", "text": "[DEMO] Assembly vote: garrison rotation passes; permanent tower fund amended to a seasonal vote."}
{"matchId": "demo-m-athens", "regime": "global/athens", "backend": "demo-fixture", "ts": 1790000038000, "seq": 4, "type": "turn", "actor": "athens#strategos", "text": "[DEMO] Strategoi accept the rotation but warn seasonal votes may delay response to early raids."}
{"matchId": "demo-m-athens", "regime": "global/athens", "backend": "demo-fixture", "ts": 1790000047000, "seq": 5, "type": "skill", "status": "staged", "actor": "system", "reason": "[DEMO] sample: skill proposal staged for audit, not saved"}
{"matchId": "demo-m-athens", "regime": "global/athens", "backend": "demo-fixture", "ts": 1790000056000, "seq": 6, "type": "match_end", "status": "done", "exitCode": 0, "actor": "system", "mechanisms": {"vetoes": 0, "impeachments": 0, "edicts": 0}}
11 changes: 11 additions & 0 deletions examples/demo-state/matches/demo-m-athens/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"matchId": "demo-m-athens",
"regime": "global/athens",
"backend": "demo-fixture",
"task": "Establish secure and robust border defense policies for agricultural frontiers facing seasonal tribal raids.",
"tsStart": 1790000000000,
"tsEnd": 1790000120000,
"exitCode": 0,
"demo": true,
"demoNote": "Sample fixture bundled for demo mode. Not produced by a model run."
}
8 changes: 8 additions & 0 deletions examples/demo-state/matches/demo-m-ming/events.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{"matchId": "demo-m-ming", "regime": "china/ming", "backend": "demo-fixture", "ts": 1790000000000, "seq": 0, "type": "match_start", "actor": "system", "text": "[DEMO] sample match start"}
{"matchId": "demo-m-ming", "regime": "china/ming", "backend": "demo-fixture", "ts": 1790000009000, "seq": 1, "type": "turn", "actor": "ming#shoufu", "text": "【示例数据】内阁票拟:沿边九镇分段守御,先修宣府、大同两段墩台;屯田以军户自给为主,户部仅补缺额。"}
{"matchId": "demo-m-ming", "regime": "china/ming", "backend": "demo-fixture", "ts": 1790000018000, "seq": 2, "type": "turn", "actor": "ming#silijian", "text": "【示例数据】司礼监批红:准票拟前两条;第三条“开马市”留中,待兵部复核边情后再议。"}
{"matchId": "demo-m-ming", "regime": "china/ming", "backend": "demo-fixture", "ts": 1790000027000, "seq": 3, "type": "veto_triggered", "actor": "system", "reason": "【示例数据】封驳:“留中不发”没有时限,容易变成拖延;要求十日内复奏。"}
{"matchId": "demo-m-ming", "regime": "china/ming", "backend": "demo-fixture", "ts": 1790000036000, "seq": 4, "type": "turn", "actor": "ming#bingbu", "text": "【示例数据】兵部复议:秋防期(八至十月)增调游兵三营轮戍;烽燧五里一台,夜举火、昼举烟,统一号令。"}
{"matchId": "demo-m-ming", "regime": "china/ming", "backend": "demo-fixture", "ts": 1790000045000, "seq": 5, "type": "turn", "actor": "ming#hubu", "text": "【示例数据】户部核算:屯田可覆盖约六成军粮,余额以开中盐引招商输粟补足,不另加田赋。"}
{"matchId": "demo-m-ming", "regime": "china/ming", "backend": "demo-fixture", "ts": 1790000054000, "seq": 6, "type": "skill", "status": "staged", "actor": "system", "reason": "[DEMO] sample: skill proposal staged for audit, not saved"}
{"matchId": "demo-m-ming", "regime": "china/ming", "backend": "demo-fixture", "ts": 1790000063000, "seq": 7, "type": "match_end", "status": "done", "exitCode": 0, "actor": "system", "mechanisms": {"vetoes": 1, "impeachments": 0, "edicts": 0}}
11 changes: 11 additions & 0 deletions examples/demo-state/matches/demo-m-ming/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"matchId": "demo-m-ming",
"regime": "china/ming",
"backend": "demo-fixture",
"task": "Establish secure and robust border defense policies for agricultural frontiers facing seasonal tribal raids.",
"tsStart": 1790000002000,
"tsEnd": 1790000122000,
"exitCode": 0,
"demo": true,
"demoNote": "Sample fixture bundled for demo mode. Not produced by a model run."
}
7 changes: 7 additions & 0 deletions examples/demo-state/matches/demo-m-tang/events.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{"matchId": "demo-m-tang", "regime": "china/tang", "backend": "demo-fixture", "ts": 1790000001000, "seq": 0, "type": "match_start", "actor": "system", "text": "[DEMO] sample match start"}
{"matchId": "demo-m-tang", "regime": "china/tang", "backend": "demo-fixture", "ts": 1790000010000, "seq": 1, "type": "turn", "actor": "tang#zhongshu", "text": "【示例数据】中书省起草:设节度使统辖沿边诸军,兼领屯田与营田,秋防由节度使临机调度。"}
{"matchId": "demo-m-tang", "regime": "china/tang", "backend": "demo-fixture", "ts": 1790000019000, "seq": 2, "type": "turn", "actor": "tang#menxia", "text": "【示例数据】门下省封驳:节度使兼领兵、财、民三权,恐成尾大不掉之势。驳回,请分设营田使。"}
{"matchId": "demo-m-tang", "regime": "china/tang", "backend": "demo-fixture", "ts": 1790000028000, "seq": 3, "type": "turn", "actor": "tang#zhongshu", "text": "【示例数据】中书省改拟:营田使另设,隶户部;节度使只掌兵,三年一迁转。"}
{"matchId": "demo-m-tang", "regime": "china/tang", "backend": "demo-fixture", "ts": 1790000037000, "seq": 4, "type": "turn", "actor": "tang#shangshu", "text": "【示例数据】尚书省执行:兵部定戍期、户部拨营田种粮、工部修烽堠,分三批下达各州。"}
{"matchId": "demo-m-tang", "regime": "china/tang", "backend": "demo-fixture", "ts": 1790000046000, "seq": 5, "type": "skill", "status": "staged", "actor": "system", "reason": "[DEMO] sample: skill proposal staged for audit, not saved"}
{"matchId": "demo-m-tang", "regime": "china/tang", "backend": "demo-fixture", "ts": 1790000055000, "seq": 6, "type": "match_end", "status": "done", "exitCode": 0, "actor": "system", "mechanisms": {"vetoes": 0, "impeachments": 0, "edicts": 0}}
11 changes: 11 additions & 0 deletions examples/demo-state/matches/demo-m-tang/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"matchId": "demo-m-tang",
"regime": "china/tang",
"backend": "demo-fixture",
"task": "Establish secure and robust border defense policies for agricultural frontiers facing seasonal tribal raids.",
"tsStart": 1790000001000,
"tsEnd": 1790000121000,
"exitCode": 0,
"demo": true,
"demoNote": "Sample fixture bundled for demo mode. Not produced by a model run."
}
62 changes: 62 additions & 0 deletions examples/demo-state/tournaments/demo-t-001/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"id": "demo-t-001",
"task": "Establish secure and robust border defense policies for agricultural frontiers facing seasonal tribal raids.",
"createdAt": 1790000600000,
"demo": true,
"demoNote": "Sample fixture bundled for demo mode. Not produced by a model run.",
"civs": [
{
"regime": "china/ming",
"backend": "demo-fixture",
"matchId": "demo-m-ming",
"exitCode": 0,
"events": "matches/demo-m-ming/events.jsonl"
},
{
"regime": "china/tang",
"backend": "demo-fixture",
"matchId": "demo-m-tang",
"exitCode": 0,
"events": "matches/demo-m-tang/events.jsonl"
},
{
"regime": "global/athens",
"backend": "demo-fixture",
"matchId": "demo-m-athens",
"exitCode": 0,
"events": "matches/demo-m-athens/events.jsonl"
}
],
"judge": {
"provider": "demo-fixture (no model)",
"resultPath": "result.md",
"scores": [
{
"regime": "china/tang",
"score": 7.9,
"reason": "【示例数据】样例评分,仅用于演示界面。"
},
{
"regime": "china/ming",
"score": 7.4,
"reason": "【示例数据】样例评分,仅用于演示界面。"
},
{
"regime": "global/athens",
"score": 6.6,
"reason": "【示例数据】样例评分,仅用于演示界面。"
}
],
"topRegime": "china/tang",
"swap": true,
"passes": 2,
"rubric": {
"scale": "1-10",
"dimensions": [
"legality",
"feasibility",
"resilience"
]
}
}
}
13 changes: 13 additions & 0 deletions examples/demo-state/tournaments/demo-t-001/result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# [DEMO] 示例锦标赛结果 / Sample tournament result

> 本文件是 demo 模式内置的样例数据,**不是**任何模型运行的产物。评分只用于演示界面布局。

| Civilization | Score /10 |
|---|---|
| china/tang | 7.9 |
| china/ming | 7.4 |
| global/athens | 6.6 |

## Verdict

[DEMO] 样例评语:唐制“门下封驳”在本样例中拦下了兵财合一的草案;明制的“留中”被给事中限期复奏。以上为示意文本。
22 changes: 22 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,25 @@ export default defineConfig([
},
])
```

## Demo mode (no backend)

Open `http://localhost:5173/?demo=1` (or build with `VITE_DEMO=1 npm run build`) to run the
dashboard with **no server**. `/api/*` GETs are answered from `src/demo/fixtures.json` and Live
Court replays a finished sample transcript over a fake EventSource.

- Regime catalog / identity / topology / skill stats / scenarios are real `regimes/` data.
- Tournament, matches, Live Court transcript and rankings are **hand-written samples** from
`examples/demo-state/` — every record is `demo: true`, backend `demo-fixture`, text prefixed
`【示例数据】`/`[DEMO]`.
- A fixed "DEMO · 示例数据" banner is always shown and the sidebar says "未连接 (fixtures)"
instead of claiming a live server. Writes are refused (403); "launch" does not run anything.

Regenerate fixtures after changing regimes or demo-state (from repo root, after `npm ci`):

```sh
node frontend/scripts/build-demo-fixtures.mjs
```

The script runs the real server routes in-process against a throwaway `HOME`, so your own
`~/.civagent` is never read.
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Expand Down
90 changes: 90 additions & 0 deletions frontend/scripts/build-demo-fixtures.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env node
// build-demo-fixtures.mjs — snapshot the REAL server's GET responses into a
// static fixture file for the frontend's demo mode (?demo=1 / VITE_DEMO=1).
//
// Regime catalog / identity / topology / skill stats / scenarios come from the
// repo's own regimes/ tree (real definitions). Tournament, match and ranking
// payloads come from examples/demo-state/ — hand-written SAMPLE data, every
// record flagged demo:true / backend "demo-fixture". The server is run with
// HOME pointed at a throwaway dir whose .civagent/ is a copy of demo-state, so
// your real ~/.civagent is never read.
//
// Usage (from repo root, after `npm ci`):
// node frontend/scripts/build-demo-fixtures.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
const OUT = path.join(ROOT, 'frontend/src/demo/fixtures.json');

const home = fs.mkdtempSync(path.join(os.tmpdir(), 'civagent-demo-home-'));
fs.cpSync(path.join(ROOT, 'examples/demo-state'), path.join(home, '.civagent'), { recursive: true });
process.env.HOME = home;
// Deterministic mtimes (the list endpoints sort by them): stamp each match's
// meta.json with its own tsEnd so re-running the script yields the same file.
for (const dir of fs.readdirSync(path.join(home, '.civagent/matches'))) {
const metaPath = path.join(home, '.civagent/matches', dir, 'meta.json');
const t = new Date(JSON.parse(fs.readFileSync(metaPath, 'utf8')).tsEnd);
fs.utimesSync(metaPath, t, t);
}

const { createApp } = await import(path.join(ROOT, 'server/index.mjs'));
const server = createApp().listen(0);
await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;

const responses = {};
async function snap(url) {
const res = await fetch(base + url);
const type = res.headers.get('content-type') || '';
let body;
if (type.includes('text/event-stream')) {
// First SSE frame carries the whole events array; the demo shim replays it.
const reader = res.body.getReader();
const { value } = await reader.read();
reader.cancel();
const m = /data: (.*)\n/.exec(new TextDecoder().decode(value));
body = m ? JSON.parse(m[1]) : [];
} else {
body = type.includes('json') ? await res.json() : await res.text();
}
responses[url] = { status: res.status, body };
return body;
}

try {
await snap('/api/regimes');
const summaries = await snap('/api/regimes?summary=1');
await snap('/api/scenarios');
for (const { id } of summaries) {
const [region, rid] = id.split('/');
await snap(`/api/regimes/${region}/${rid}/identity`);
await snap(`/api/regimes/${region}/${rid}/topology`);
await snap(`/api/skills/${region}/${rid}/stats`);
await snap(`/api/history/${rid}`);
await snap(`/api/analytics/radar/${rid}`);
}
await snap('/api/analytics/trends');
const matches = await snap('/api/matches');
for (const m of matches) {
await snap(`/api/matches/${m.id}`);
await snap(`/api/matches/${m.id}/stream`);
}
await snap('/api/tournaments');
await snap('/api/stats/rankings');
} finally {
server.close();
fs.rmSync(home, { recursive: true, force: true });
}

// Scrub the throwaway HOME path out of payloads (e.g. rankings warnings).
const json = JSON.stringify({
generatedBy: 'frontend/scripts/build-demo-fixtures.mjs',
note: 'DEMO fixtures. Regime definitions are real repo data; tournaments/matches/rankings are hand-written samples from examples/demo-state/.',
responses,
}).split(home).join('~');
fs.writeFileSync(OUT, json + '\n');
console.log(`wrote ${path.relative(ROOT, OUT)} — ${Object.keys(responses).length} responses, ${(json.length / 1024).toFixed(0)} KiB`);
19 changes: 7 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { SkillLibrary } from './components/SkillLibrary';
import { RegimeEditor } from './components/RegimeEditor';
import { PlayCircle, AlertCircle, RefreshCw } from 'lucide-react';
import type { RegimeDetail } from './types/api';
import { TAB_TITLES } from './components/layout/tabs';
import { DemoBanner } from './demo/DemoBanner';
import { isDemoMode } from './demo/demoMode';

export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState('overview');
Expand Down Expand Up @@ -44,18 +47,9 @@ export const App: React.FC = () => {

<main className="main-content">
<header style={{ padding: '24px 40px', borderBottom: '1px solid var(--border-light)', background: 'var(--bg-glass)', backdropFilter: 'blur(16px)', position: 'sticky', top: 0, zIndex: 5 }}>
<h1 className="text-gradient" style={{ margin: 0, fontSize: '24px' }}>
{activeTab === 'overview' && 'Empire Overview'}
{activeTab === 'regimes' && 'Regime Browser'}
{activeTab === 'analytics' && 'Analytics Dashboard'}
{activeTab === 'memory' && 'Episodic Memory'}
{activeTab === 'veto' && 'Constitution Monitor'}
{activeTab === 'launch' && 'Tournament Launcher'}
{activeTab === 'editor' && 'Regime Editor'}
{activeTab === 'skills' && 'Skill Library'}
{activeTab === 'archive' && 'Match Archive'}
{activeTab === 'live' && 'Live Court'}
{activeTab === 'rankings' && 'Cross-Tournament Rankings'}
<h1 className="page-title">
{TAB_TITLES[activeTab]?.zh}
<span className="page-title-en">{TAB_TITLES[activeTab]?.en}</span>
</h1>
</header>

Expand Down Expand Up @@ -143,6 +137,7 @@ export const App: React.FC = () => {
)}
</div>
</main>
{isDemoMode() && <DemoBanner />}
</div>
);
};
Expand Down
38 changes: 23 additions & 15 deletions frontend/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React from 'react';
import { TAB_TITLES } from './tabs';
import { isDemoMode } from '../../demo/demoMode';
import { Layers, Activity, History, ShieldAlert, Cpu, Globe, Trophy, Archive, Rocket, BookOpen, Edit3 } from 'lucide-react';

interface SidebarProps {
Expand All @@ -8,17 +10,17 @@ interface SidebarProps {

export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) => {
const tabs = [
{ id: 'overview', label: 'Empire Overview', icon: <Layers size={20} /> },
{ id: 'launch', label: 'Launch', icon: <Rocket size={20} /> },
{ id: 'regimes', label: 'Regime Browser', icon: <Globe size={20} /> },
{ id: 'analytics', label: 'Analytics Dashboard', icon: <Activity size={20} /> },
{ id: 'memory', label: 'Episodic Memory', icon: <History size={20} /> },
{ id: 'editor', label: 'Editor', icon: <Edit3 size={20} /> },
{ id: 'skills', label: 'Skills', icon: <BookOpen size={20} /> },
{ id: 'veto', label: 'Constitution', icon: <ShieldAlert size={20} /> },
{ id: 'archive', label: 'Match Archive', icon: <Archive size={20} /> },
{ id: 'live', label: 'Live Court', icon: <Cpu size={20} /> },
{ id: 'rankings', label: 'Rankings', icon: <Trophy size={20} /> },
{ id: 'overview', icon: <Layers size={20} /> },
{ id: 'launch', icon: <Rocket size={20} /> },
{ id: 'regimes', icon: <Globe size={20} /> },
{ id: 'analytics', icon: <Activity size={20} /> },
{ id: 'memory', icon: <History size={20} /> },
{ id: 'editor', icon: <Edit3 size={20} /> },
{ id: 'skills', icon: <BookOpen size={20} /> },
{ id: 'veto', icon: <ShieldAlert size={20} /> },
{ id: 'archive', icon: <Archive size={20} /> },
{ id: 'live', icon: <Cpu size={20} /> },
{ id: 'rankings', icon: <Trophy size={20} /> },
];

return (
Expand Down Expand Up @@ -64,19 +66,25 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{React.cloneElement(tab.icon as React.ReactElement<{ color?: string }>, {
color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-muted)'
})}
{tab.label}
<span className="nav-label">
<span className="nav-label-zh">{TAB_TITLES[tab.id].zh}</span>
<span className="nav-label-en">{TAB_TITLES[tab.id].en}</span>
</span>
</button>
))}
</nav>

<div style={{ marginTop: 'auto', padding: '16px', borderRadius: '12px', background: 'rgba(255,255,255,0.03)', fontSize: '12px', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span>Status</span>
<span style={{ color: 'var(--accent-emerald)' }}>● ONLINE</span>
<span>Data</span>
{/* Demo mode must never claim a live server. */}
<span style={{ color: isDemoMode() ? 'var(--accent-gold)' : 'var(--accent-emerald)' }}>
{isDemoMode() ? '● DEMO 示例' : '● LIVE'}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Server</span>
<span>localhost:3001</span>
<span>{isDemoMode() ? '未连接 (fixtures)' : 'localhost:3001'}</span>
</div>
</div>
</div>
Expand Down
Loading
Loading