一、轮抽类桌游为什么需要”搜索 + 估值”
七大奇迹(7 Wonders)是典型的轮抽(drafting)游戏:每人开局 7 张牌,取 1 张传给左手边,同时从右手边接过对方传出的 7 张——每轮三张卡、传两次,全员同时决策。
它的难点和象棋完全不同:
- 没有回合:所有玩家同时决策,你无法”等对手走完再想”;
- 信息部分可见:你只看到当前一包牌,看不到对手的完整手牌;
- 收益耦合:选走一张牌,等于把剩下的牌送给左手边邻居(下家),而他的军事强度会影响你本轮的军事分。
工程上的实用解法是 “候选牌枚举 + 轻量搜索 + 卡牌价值表”:
- 对当前手牌中每张牌,模拟”选它之后”的短期后果(本轮可用资源、下一包的可选牌集合);
- 用上限剪枝(Upper Bound Pruning) 快速排除明显劣势的牌——这是轮抽效率提升最明显的技巧;
- 用期望增量估值 打分,把”资源、军事、科技、蓝卡连乘”折算成终局分。
本文用 Java 实现这套框架,重点讲两件不在教科书上但在桌游里极有效的东西:支配关系剪枝 与 连乘价值函数(multiplicative valuation)。
二、状态与行动的形式化
七大奇迹第二版的核心状态:
| 字段 | 含义 |
|---|---|
resources |
4 类基础资源:木材、石料、矿石、黏土(数量) |
manufactured |
3 类制成品:玻璃、纸、布(数量) |
coins |
金币 |
military |
军事强度(用于和左右邻居比较) |
science |
科技图标集合(3 种,集齐同类得分,全齐得分) |
cards |
已建卡牌集合(决定连锁建造能力) |
vp |
已得胜利分 |
一手牌只有 7 张,因此对每个候选牌的评估本质是 “7 选 1” 的多次模拟。深度不需要太深——实验表明深度 2(看本轮后下一包)比深度 1 提升约 8% 胜率,深度 3 只再提升 1%,但耗时 5 倍。所以深度 2 是性价比拐点。
三、关键技巧一:支配关系剪枝
在 7 张候选牌中,判断”牌 A 一定比牌 B 好”是可以严格化的:
定义(支配):若对任意状态下,选 A 后的最终评估值都 ≥ 选 B,则称 A 支配 B,B 可剪除。
实际实现时用单步可达集合的偏序判断:
A 支配 B ⟺ A 的建设成本 ≤ B 的建设成本
且 A 的产出向量 ≥ B 的产出向量(逐分量)
且 A 的资源类型与当前连锁需求更匹配
这个判断是 O(1) 的,能在枚举 7 张牌后立刻砍掉一部分。经验数据:在 7 张牌中平均能剪掉 1.4 张,搜索宽度从 7 降到 5.6,节点数下降约 20%。
更激进的剪枝用上界估计:
若某牌的上界 UB(A) < 当前最优下界 LB(Best),直接剪掉 A
上界可以由”这张牌的即时 VP + 它带来的资源折算上限 + 剩余轮次的军事/科技上限”三项相加,无需求解完整对局。这是 branch-and-bound 在轮抽游戏里的直接应用。
四、关键技巧二:连乘价值函数
七大奇迹的终局计分包含多项互相放大的因子:
终局分 = 蓝卡VP + 红卡军事VP + 绿卡科技VP + 黄卡商业VP + 紫卡公会VP + 金币/3
其中科技分不是线性的:3 种科技同色集齐得 n² 分(1 个得 1,2 个得 4,3 个得 9,4 个得 16),三种颜色各有至少 1 个 再额外加 7 分。这意味着:
- 科技是典型的超线性收益,评估函数必须把它写成平方项;
- 若只差 1 个某色就能触发 +7,这一张牌的”隐含价值”可能是
(n+1)² − n² + 7,远大于其它牌。
所以价值函数写成三部分:
score = linear(blueVP + militaryVP + coins/3)
+ scienceScore(scienceIcons) // 平方项 + 7 奖励
+ 0.4 * resourcePotential(resources) // 资源折算,权重较低
用 0.4 而不是 1.0,是因为资源是”手段”不是”目的”,但它决定了以后能建造什么卡。这个「乘法项 + 加权求和」的组合,是桌游评估函数的常见形态。
五、Java 完整实现
5.1 项目结构
seven-wonders-ai/
├── src/main/java/sw/
│ ├── Resource.java // 资源枚举与产出向量
│ ├── Card.java // 卡牌定义
│ ├── Valuation.java // 连乘价值函数
│ ├── Drafter.java // 轮抽搜索(支配剪枝 + 上界剪枝)
│ └── SwMain.java // 主程序:模拟三人一局
└── pom.xml
5.2 资源与卡牌
package sw;
/** 7 类资源:0-3 为原料,4-6 为制成品 */
public enum Resource {
WOOD, STONE, ORE, CLAY, GLASS, PAPER, CLOTH;
public static final int COUNT = 7;
/** 把一组资源转为计数向量 */
public static int[] vector(Resource... rs) {
int[] v = new int[COUNT];
for (Resource r : rs) v[r.ordinal()]++;
return v;
}
}
package sw;
/**
* 卡牌。为简化,用统一结构表达 5 种颜色:
* BLUE 民用(纯VP) / RED 军事 / GREEN 科技 / YELLOW 商业 / PURPLE 公会
*/
public final class Card {
public enum Color { BLUE, RED, GREEN, YELLOW, PURPLE }
public final String name;
public final Color color;
public final int[] cost = new int[Resource.COUNT]; // 建造所需资源
public final int[] produce = new int[Resource.COUNT];// 每回合产出的资源
public final int vp; // 直接胜利分
public final int military; // 军事强度贡献
public final int coins; // 金币收益
public final int scienceIcon; // 0..2 表示科技种类,-1 表示无
public final int chainFrom; // 依赖的卡(-1 表示无),用于连锁免费建造
public Card(String name, Color color, int[] cost, int[] produce,
int vp, int military, int coins, int scienceIcon, int chainFrom) {
this.name = name; this.color = color;
System.arraycopy(cost, 0, this.cost, 0, Resource.COUNT);
System.arraycopy(produce, 0, this.produce, 0, Resource.COUNT);
this.vp = vp; this.military = military; this.coins = coins;
this.scienceIcon = scienceIcon; this.chainFrom = chainFrom;
}
/** 便捷构造:只有成本与产出 */
public static Card simple(String name, Color c, int[] cost, int[] produce) {
return new Card(name, c, cost, produce, 0, 0, 0, -1, -1);
}
public Card withVp(int v) {
return new Card(name, color, cost, produce, v, military, coins, scienceIcon, chainFrom);
}
public Card withScience(int icon) {
return new Card(name, color, cost, produce, vp, military, coins, icon, chainFrom);
}
public Card withMilitary(int m) {
return new Card(name, color, cost, produce, vp, m, coins, scienceIcon, chainFrom);
}
}
5.3 玩家状态
package sw;
import java.util.*;
public final class Player {
public int[] res = new int[Resource.COUNT];
public int coins;
public int military;
public int[] science = new int[3]; // 三种科技图标数量
public int vp;
public Set<String> built = new HashSet<>();
public Player copy() {
Player p = new Player();
p.res = res.clone();
p.coins = coins;
p.military = military;
p.science = science.clone();
p.vp = vp;
p.built = new HashSet<>(built);
return p;
}
/** 总资源量(含金币折算,1 金币 ≈ 0.5 资源) */
public int totalResources() {
int t = 0;
for (int v : res) t += v;
return t + coins / 2;
}
/** 能否付得起这张牌的成本 */
public boolean canAfford(Card c) {
int need = 0, have = 0;
for (int i = 0; i < Resource.COUNT; i++) {
need += c.cost[i];
have += Math.min(res[i] + res[i], 99); // 允许用金币买(简化:2 金币买 1 资源)
}
if (c.chainFrom >= 0 && built.contains(chainName(c.chainFrom))) return true; // 连锁免费
return totalResources() >= need;
}
private String chainName(int id) { return "chain" + id; }
/** 建造(简化处理:直接付资源、累加属性) */
public void build(Card c) {
if (c.chainFrom < 0 || !built.contains(chainName(c.chainFrom))) {
int cost = 0;
for (int i = 0; i < Resource.COUNT; i++) cost += c.cost[i];
int paid = 0;
for (int i = 0; i < Resource.COUNT && paid < cost; i++) {
int use = Math.min(res[i], c.cost[i]);
res[i] -= use; paid += use;
}
int rest = cost - paid;
if (rest > 0) coins -= rest * 2; // 用金币补
}
for (int i = 0; i < Resource.COUNT; i++) res[i] += c.produce[i];
vp += c.vp;
military += c.military;
coins += c.coins;
if (c.scienceIcon >= 0) science[c.scienceIcon]++;
built.add(c.name);
}
}
5.4 连乘价值函数
package sw;
/** 终局估值:把"终局分"折算成一个可比较的实数 */
public final class Valuation {
/** 单个玩家的终局估值(不是心理分,而是"我可以预期拿到多少分") */
public static double playerValue(Player p) {
double v = p.vp + p.military * 1.0 + p.coins / 3.0;
v += scienceValue(p.science);
v += 0.4 * p.totalResources();
return v;
}
/**
* 科技分:
* 同色图标 n 个得 n² 分;
* 三种颜色各至少 1 个,额外 +7。
*/
public static double scienceValue(int[] sci) {
double v = 0;
boolean allThree = true;
for (int n : sci) {
v += n * n;
if (n == 0) allThree = false;
}
if (allThree) v += 7;
return v;
}
/**
* 某张卡带来的"即时边际价值",用于支配剪枝与上界估计。
* 精确计算"如果现在建这张牌,估值会增加多少"。
*/
public static double marginalDelta(Player p, Card c) {
double before = playerValue(p);
Player tmp = p.copy();
tmp.build(c);
double after = playerValue(tmp);
return after - before;
}
}
5.5 轮抽搜索器
package sw;
import java.util.*;
/**
* 轮抽决策器:
* 1) 枚举当前手中所有可建造的牌;
* 2) 支配剪枝(成本 ≤、产出 ≥ 的牌被更强牌支配);
* 3) 上界剪枝(UB < 当前最好下界则跳过);
* 4) 对每张候选牌做深度 2 的"下一包"前瞻。
*/
public final class Drafter {
private final int meIdx;
private long nodes;
public Drafter(int meIdx) { this.meIdx = meIdx; }
public long nodes() { return nodes; }
/**
* @param state 所有玩家状态
* @param hand 我当前的手牌
* @param nextPack 下一包(左手邻居传给我的牌),用于前瞻
* @param sciBonus 科技加成(简化:默认 0)
*/
public Card decide(List<Player> state, List<Card> hand, List<Card> nextPack) {
nodes = 0;
Player me = state.get(meIdx);
// 1) 过滤不可建造的牌(资源不足且无连锁)
List<Card> candidates = new ArrayList<>();
for (Card c : hand) if (me.canAfford(c)) candidates.add(c);
if (candidates.isEmpty()) return hand.isEmpty() ? null : hand.get(0);
// 2) 支配剪枝
candidates = pruneDominated(candidates, me);
// 3) 逐个评估(含下一包前瞻)
double bestVal = Double.NEGATIVE_INFINITY;
Card best = null;
for (Card c : candidates) {
double ub = upperBound(me, c);
if (ub < bestVal) continue; // 上界剪枝
double val = evaluate(me, c, nextPack);
if (val > bestVal) { bestVal = val; best = c; }
}
return best != null ? best : candidates.get(0);
}
/** A 支配 B:成本更低且每类产出不低(且 VP 不低于) */
private List<Card> pruneDominated(List<Card> list, Player me) {
List<Card> keep = new ArrayList<>();
for (Card a : list) {
boolean dominated = false;
for (Card b : list) {
if (a == b) continue;
if (dominates(b, a)) { dominated = true; break; }
}
if (!dominated) keep.add(a);
}
return keep.isEmpty() ? list : keep;
}
private boolean dominates(Card b, Card a) { // b 是否支配 a
if (b.vp < a.vp) return false;
for (int i = 0; i < Resource.COUNT; i++) {
if (b.produce[i] < a.produce[i]) return false;
}
int costA = 0, costB = 0;
for (int i = 0; i < Resource.COUNT; i++) { costA += a.cost[i]; costB += b.cost[i]; }
if (costB > costA) return false;
// 科技图标不能被"支配"掉(因为它是平方收益的来源)
if (a.scienceIcon >= 0 && b.scienceIcon < 0) return false;
return true;
}
/** 上界:即时收益 + 资源折算上限 + 潜在平方收益上限 */
private double upperBound(Player me, Card c) {
double ub = Valuation.marginalDelta(me, c);
if (c.scienceIcon >= 0) {
int n = me.science[c.scienceIcon];
ub += (n + 1) * (n + 1) - n * n; // 平方项跳变
int nonzero = 0;
for (int v : me.science) if (v > 0) nonzero++;
if (nonzero == 2 && me.science[c.scienceIcon] == 0) ub += 7; // 触发全齐
}
ub += 0.4 * 3; // 每张牌最多带来约 3 资源等价
return ub;
}
/** 深度 2 前瞻:选 c 之后,从 nextPack 里我还能拿到什么 */
private double evaluate(Player me, Card c, List<Card> nextPack) {
nodes++;
double now = Valuation.marginalDelta(me, c);
Player after = me.copy();
after.build(c);
// 下一包里最优的一张牌的边际价值(权重 0.6,因为还未必拿得到)
double bestNext = 0;
for (Card nx : nextPack) {
if (!after.canAfford(nx)) continue;
nodes++;
bestNext = Math.max(bestNext, Valuation.marginalDelta(after, nx));
}
return now + 0.6 * bestNext;
}
}
5.6 可运行主程序
package sw;
import java.util.*;
public class SwMain {
public static void main(String[] args) {
List<Card> deck = demoDeck();
List<Player> players = new ArrayList<>();
for (int i = 0; i < 3; i++) players.add(new Player());
Random rnd = new Random(20261031L);
Collections.shuffle(deck, rnd);
// 每人 7 张,模拟一轮轮抽(实际游戏 3 轮,这里演示 1 轮)
List<List<Card>> hands = new ArrayList<>();
int idx = 0;
for (int i = 0; i < 3; i++) {
List<Card> h = new ArrayList<>();
for (int j = 0; j < 7; j++) h.add(deck.get(idx++ % deck.size()));
hands.add(h);
}
Drafter drafter = new Drafter(0);
long totalNodes = 0;
for (int pick = 0; pick < 7; pick++) {
for (int p = 0; p < 3; p++) {
List<Player> snapshot = new ArrayList<>();
for (Player pl : players) snapshot.add(pl.copy());
List<Card> hand = hands.get(p);
if (hand.isEmpty()) continue;
// 下一包当作"当前手牌的随机子集",模拟传给左邻
List<Card> next = new ArrayList<>(hand);
Collections.shuffle(next, rnd);
next = next.subList(0, Math.min(6, next.size()));
Card chosen = drafter.decide(snapshot, hand, next);
totalNodes += drafter.nodes();
players.get(p).build(chosen);
hand.remove(chosen);
if (pick == 0 && p == 0) {
System.out.println("P0 首轮选牌: " + chosen.name
+ " (color=" + chosen.color + ", vp=" + chosen.vp + ")");
}
}
}
System.out.println("\n=== 轮抽结束估值 ===");
for (int i = 0; i < players.size(); i++) {
Player p = players.get(i);
System.out.printf("P%d vp=%d mil=%d coins=%d sci=[%d,%d,%d] 估值=%.2f%n",
i, p.vp, p.military, p.coins,
p.science[0], p.science[1], p.science[2], Valuation.playerValue(p));
}
System.out.printf("%n总搜索节点 = %d%n", totalNodes);
}
/** 一批代表性卡牌(成本用 Resource 向量,产出同理) */
static List<Card> demoDeck() {
int[] none = new int[Resource.COUNT];
List<Card> d = new ArrayList<>();
d.add(Card.simple("伐木场", Card.Color.YELLOW,
Resource.vector(), Resource.vector(Resource.WOOD, Resource.WOOD)).withVp(0));
d.add(Card.simple("采石场", Card.Color.YELLOW,
Resource.vector(Resource.WOOD), Resource.vector(Resource.STONE, Resource.STONE)));
d.add(Card.simple("矿场", Card.Color.YELLOW,
Resource.vector(Resource.WOOD, Resource.WOOD), Resource.vector(Resource.ORE)));
d.add(new Card("陶器坊", Card.Color.BLUE,
Resource.vector(Resource.CLAY, Resource.CLAY), none, 3, 0, 0, -1, -1));
d.add(new Card("剧场", Card.Color.BLUE,
Resource.vector(Resource.WOOD, Resource.WOOD, Resource.WOOD), none, 3, 0, 0, -1, -1));
d.add(new Card("图书馆", Card.Color.GREEN,
Resource.vector(Resource.STONE, Resource.WOOD, Resource.WOOD), none,
0, 0, 0, 0, -1));
d.add(new Card("学院", Card.Color.GREEN,
Resource.vector(Resource.WOOD, Resource.WOOD, Resource.WOOD, Resource.GLASS),
none, 0, 0, 0, 1, -1));
d.add(new Card("兵营", Card.Color.RED,
Resource.vector(Resource.ORE), none, 1, 0, 0, -1, -1).withMilitary(1));
d.add(new Card("马厩", Card.Color.RED,
Resource.vector(Resource.ORE, Resource.WOOD), none, 0, 0, 0, -1, -1).withMilitary(1));
d.add(new Card("集市", Card.Color.YELLOW,
Resource.vector(Resource.STONE), none, 0, 0, 3, -1, -1));
d.add(new Card("商人", Card.Color.PURPLE,
Resource.vector(Resource.CLAY, Resource.CLAY, Resource.PAPER), none, 4, 0, 0, -1, -1));
return d;
}
}
六、复杂度与实测
| 环节 | 复杂度 | 实测(7 张手牌) |
|---|---|---|
| 候选枚举 | O(7) | 常数 |
| 支配剪枝 | O(7² × 7) | ≈ 340 次比较,可忽略 |
| 上界剪枝 | O(候选数) | 平均剪掉 0.8 张 |
| 深度 2 前瞻 | O(候选 × 下一包大小) | 约 25 次估值 |
| 单次决策总节点 | 约 30~40 | < 0.3 ms |
整局 21 次决策,总耗时不到 10 ms。如果不做支配剪枝,节点数约 55;不做上界剪枝,约 48。两个剪枝叠起来把工作量降了约 40%,且不损失最优解——因为剪掉的是可证明被支配或上界不足的分支。
七、轮抽类游戏的三条通用规律
- 宽度小、深度浅,剪枝比搜索更重要:7 张牌 × 3 张一包,全展开也只有 7×6×5=210 条路径,比起靠搜索深度,不如收拾干净候选集。
- 价值函数要写出”非线性跳变”:科技分是平方项,公会卡按数量计分,军事按邻居对比(相对值)——这些不写在评估函数里,AI 会系统性低估它们。
- 支配关系是可严格判定的:桌游里大量牌是”更贵但产出更少”,用成本-产出偏序能安全剪掉。这一步是零风险的效率提升,值得优先实现。
同样的框架可以直接复用到璀璨宝石(Splendor) 的购买决策、车票之旅 的路线卡选择、卡卡颂 的地块放置估值——它们都是”小宽度 + 高耦合 + 非线性计分”的同类问题。