Combining the build frontier with subgraph-driven trade search to create a complete Monopoly decision engine.
Links: Monopoly, Frontier Trade Theory, Subgraph Investment Optimization, Bilateral Trade Valuation, Nash Bargaining Problem, Multiplayer Coalition Problem
The existing AI has two pieces that work independently:
What’s missing is a trade search engine — the AI can evaluate a trade that’s offered to it, but it can’t generate the right trade to propose. The subgraph framework turns the AI from reactive (evaluate offers) to proactive (identify what it needs, find who has it, propose the deal).
This also solves the 3+ player trade valuation problem. By computing post-trade subgraphs for ALL players (not just the two trading), the engine can assess whether a trade creates an unbalanced state that the non-trading players can’t answer.
PlayerSubgraph {
properties: Property[] // nodes this player owns
completeSets: ColorSet[] // cliques where building is possible
partialSets: PartialSet[] // 1 or 2 of N in a color group
isolatedNodes: Property[] // no path to completion (opponents own the rest)
cash: number
buildings: Map<Property, 0-5> // house level per property
buildFrontier: FrontierCurve // EPT vs cash curve for build-only path
frontierSlope: number // is frontier steep (grow) or flat (capped)?
}
PartialSet {
owned: Property[] // what we have
needed: Property[] // what we need to complete
heldBy: Map<Property, Player> // who holds each missing piece
}
CandidateTrade {
give: Property[] // nodes leaving our subgraph
receive: Property[] // nodes entering our subgraph
counterparty: Player
cashDelta: number // net cash exchanged (positive = we receive)
postTradeSubgraphs: Map<Player, PlayerSubgraph> // ALL players' subgraphs after
bilateralEV: number // trajectory sim result for both traders
thirdPartyImpact: number[] // trajectory delta for non-trading players
riskAdjustedEV: number // bilateralEV × P(acceptance)
}
On each decision point, compute the current player’s subgraph:
assessPosition(player) → {
state: GROWING | CAPPED | LOSING
buildROI: number // best available build EPT/dollar
ceiling: number // max EPT achievable by building only
turnsToMaxDev: number // how many turns to reach ceiling
}
Decision gate: If state = GROWING and buildROI exceeds a threshold, just build. Skip trade search entirely. This is the fast path — most turns, the right move is to build your best house.
When the frontier is flat or the player is losing, search for trades that restructure the subgraph.
For each opponent, enumerate trades that could complete a partial set:
generateCandidates(player, opponent) → CandidateTrade[]
For each partialSet in player.partialSets:
neededFromOpponent = partialSet.needed ∩ opponent.properties
if neededFromOpponent is empty: skip
For each subset of neededFromOpponent: // what we want
For each offer from player.tradeableProperties: // what we give
create CandidateTrade(give=offer, receive=subset, ...)
Tradeable properties = partials and isolated nodes. Never trade away complete sets or properties in sets you’re actively building.
Before running any simulations, eliminate dominated candidates:
Expected pruning: From ~100-200 raw candidates per opponent down to ~5-20 serious candidates total.
For each surviving candidate:
evaluateTrade(trade) → {
myWinDelta: number // my win% after - my win% before
theirWinDelta: number // their win% after - their win% before
feasible: myWinDelta > 0 AND theirWinDelta > 0
}
For each feasible bilateral trade, check the non-trading players:
thirdPartyCheck(trade, nonTraders) → {
balanced: boolean // no single player dominates post-trade
leaderWinProb: number // highest post-trade win% among all players
selfBalancing: boolean // do non-traders have counter-moves?
}
Why this matters: A trade where both traders improve but one ends up at 55%+ win probability will trigger the self-balancing dynamic — the other players will coalition against the leader. The AI should prefer trades that improve its position without painting a target on itself.
If no bilateral trade is in the Nash bargaining zone (my improvement requires giving the opponent something too valuable), search for 3-way cycles:
A gives X to B
B gives Y to C
C gives Z to A
The constraint: each player must improve their subgraph. This is graph matching on cycles rather than pairs.
Enumeration:
Pruning is aggressive here — the cycle must close (Z from C must actually be useful to A), so most combinations fail immediately. Expected feasible 3-way trades: 0-5 per game state.
Evaluation: Run full trajectory sim with all three post-trade subgraphs. Same Nash bargaining criterion: all three players must improve.
Property valuation falls out of the framework naturally:
valueOfProperty(prop, player) =
frontierWithProp(player) - frontierWithoutProp(player)
A property’s value to a specific player is the delta between their subgraph frontier with and without that node. This is position-dependent:
This replaces the static EPT valuation tables with dynamic, position-aware valuation.
Trade pricing: The Nash bargaining price for a property is somewhere between the buyer’s valuation (their frontier delta) and the seller’s valuation (their frontier delta from losing it). The exact split depends on outside options (BATNAs) per the existing Nash bargaining framework.
The engine slots in as a new decision layer:
onDecisionPoint(player, gameState):
subgraph = analyzeSubgraph(player, gameState)
// Phase 1: Can we just build?
if subgraph.state == GROWING:
bestBuild = subgraph.buildFrontier.bestROI()
if bestBuild.roi > BUILD_THRESHOLD:
return BuildAction(bestBuild)
// Phase 2: Search for bilateral trades
candidates = []
for opponent in gameState.otherPlayers:
raw = generateCandidates(player, opponent)
pruned = applyPruningRules(raw, gameState)
evaluated = pruned.map(t => evaluateTrade(t, gameState))
feasible = evaluated.filter(t => t.feasible)
candidates.push(...feasible)
// Phase 2d: Third-party check
checked = candidates.map(t => thirdPartyCheck(t, gameState))
balanced = checked.filter(t => t.balanced)
// Phase 3: 3-way search if needed
if balanced.isEmpty():
cycles = search3WayCycles(player, gameState)
balanced = cycles.filter(t => t.feasible && t.balanced)
// Decision
if balanced.isEmpty():
// No good trade exists — build best available or hold cash
return subgraph.buildFrontier.bestROI() ?? HoldCash()
bestTrade = balanced.maxBy(t => t.riskAdjustedEV)
// Compare trade to build
if bestTrade.riskAdjustedEV > subgraph.buildFrontier.bestROI():
return ProposeTradeAction(bestTrade)
else:
return BuildAction(subgraph.buildFrontier.bestROI())
| Phase | Candidates | Sims per candidate | Total sims | Time est. |
|---|---|---|---|---|
| Build frontier | 5-10 | 0 (analytic) | 0 | <1ms |
| Trade generation + pruning | 100-200 → 15-60 | 0 | 0 | <10ms |
| Bilateral evaluation | 15-60 | 1 trajectory sim | 15-60 | ~1s |
| Third-party check | 5-20 feasible | 2 sims (non-traders) | 10-40 | ~0.5s |
| 3-way cycle search | 0-5 feasible | 1 full sim | 0-5 | <0.5s |
| Total | 25-105 | ~2-3s |
Tractable for game AI. May need optimization for Richup.io bot (tighter time constraints), but the pruning is where most savings come from — better pruning rules reduce sim count without changing the algorithm.
Run the subgraph engine on known game states from existing simulation logs. Check:
Run SubgraphTradeAI vs StrategicTradeAI (current best) over 10,000 games. Win rate improvement is the metric. The theory predicts improvement should be largest in mid-game states where trade opportunities exist but the heuristic AI’s static valuations misjudge them.
Track how many candidate trades survive each pruning step. If too many survive (>100), pruning rules need tightening. If too few survive (0 in most states), the rules are too aggressive and may be eliminating good trades.
Specifically test game states where no bilateral trade helps but a 3-way cycle does. These are the states where the framework should show its biggest advantage over the existing AI, which can’t even consider 3-way trades.