diff --git a/.gitignore b/.gitignore
index f693cc2..4bfa67c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -146,11 +146,11 @@ cython_debug/
*.toc
*.out
-# TinyTeX installation
-.pytinytex/
-
# Temporary files
tmp*
temp*
.tmp*
-.temp*
\ No newline at end of file
+.temp*
+
+# .ef files generated from .efg files (the test suite)
+games/efg/*.ef
\ No newline at end of file
diff --git a/games/2s2x2x2.ef b/games/2s2x2x2.ef
new file mode 100644
index 0000000..57b5c26
--- /dev/null
+++ b/games/2s2x2x2.ef
@@ -0,0 +1,36 @@
+player 1 name Player~1
+player 2 name Player~2
+player 3 name Player~3
+level 0 node 1 player 1
+level 2 node 1 xshift -3.58 from 0,1 move U1
+level 2 node 2 xshift 3.58 from 0,1 move D1
+level 6 node 1 xshift -1.9 from 2,2 move U2
+level 6 node 2 xshift 1.9 from 2,2 move D2
+level 8 node 1 xshift -0.90 from 6,2 move U3 payoffs 9 8 2
+level 8 node 2 xshift 0.90 from 6,2 move D3 payoffs 0 0 0
+level 8 node 3 xshift -0.90 from 6,1 move U3 payoffs 0 0 0
+level 8 node 4 xshift 0.90 from 6,1 move D3 payoffs 3 4 6
+level 6 node 3 xshift -1.9 from 2,1 move U2
+level 6 node 4 xshift 1.9 from 2,1 move D2
+level 8 node 5 xshift -0.90 from 6,4 move U3 payoffs 0 0 0
+level 8 node 6 xshift 0.90 from 6,4 move D3 payoffs 3 4 6
+level 10 node 1 player 1 xshift -1.65 from 6,3 move U3
+level 8 node 7 xshift 0.90 from 6,3 move D3 payoffs 0 0 0
+level 14 node 1 xshift -2.205 from 10,1 move U1
+level 14 node 2 xshift 2.205 from 10,1 move D1
+level 18 node 1 xshift -1.095 from 14,2 move U2
+level 18 node 2 xshift 1.095 from 14,2 move D2
+level 20 node 1 xshift -0.73 from 18,2 move U3 payoffs 9 8 2
+level 20 node 2 xshift 0.73 from 18,2 move D3 payoffs 0 0 0
+level 20 node 3 xshift -0.73 from 18,1 move U3 payoffs 0 0 0
+level 20 node 4 xshift 0.73 from 18,1 move D3 payoffs 3 4 6
+level 18 node 3 xshift -1.095 from 14,1 move U2
+level 18 node 4 xshift 1.095 from 14,1 move D2
+level 20 node 5 xshift -0.73 from 18,4 move U3 payoffs 0 0 0
+level 20 node 6 xshift 0.73 from 18,4 move D3 payoffs 3 4 6
+level 20 node 7 xshift -0.73 from 18,3 move U3 payoffs 9 8 12
+level 20 node 8 xshift 0.73 from 18,3 move D3 payoffs 0 0 0
+iset 2,2 2,1 player 2
+iset 6,4 6,3 6,2 6,1 player 3
+iset 14,2 14,1 player 2
+iset 18,4 18,3 18,2 18,1 player 3
diff --git a/games/2smp.ef b/games/2smp.ef
new file mode 100644
index 0000000..c7e7f50
--- /dev/null
+++ b/games/2smp.ef
@@ -0,0 +1,38 @@
+player 1 name Player~1
+player 2 name Player~2
+level 0 node 1 player 1
+level 2 node 1 xshift -3.58 from 0,1 move H
+level 2 node 2 xshift 3.58 from 0,1 move T
+level 6 node 1 player 1 xshift -1.9 from 2,2 move H
+level 6 node 2 player 1 xshift 1.9 from 2,2 move T
+level 8 node 1 xshift -0.90 from 6,2 move H
+level 8 node 2 xshift 0.90 from 6,2 move T
+level 13 node 1 xshift -0.45 from 8,2 move H payoffs -1 1
+level 13 node 2 xshift 0.45 from 8,2 move T payoffs 1 -1
+level 13 node 3 xshift -0.45 from 8,1 move H payoffs 1 -1
+level 13 node 4 xshift 0.45 from 8,1 move T payoffs -1 1
+level 9 node 3 xshift -0.90 from 6,1 move H
+level 9 node 4 xshift 0.90 from 6,1 move T
+level 13 node 5 xshift -0.45 from 9,4 move H payoffs -1 1
+level 13 node 6 xshift 0.45 from 9,4 move T payoffs 1 -1
+level 13 node 7 xshift -0.45 from 9,3 move H payoffs 1 -1
+level 13 node 8 xshift 0.45 from 9,3 move T payoffs -1 1
+level 6 node 3 player 1 xshift -1.9 from 2,1 move H
+level 6 node 4 player 1 xshift 1.9 from 2,1 move T
+level 11 node 5 xshift -0.90 from 6,4 move H
+level 11 node 6 xshift 0.90 from 6,4 move T
+level 13 node 9 xshift -0.45 from 11,6 move H payoffs -1 1
+level 13 node 10 xshift 0.45 from 11,6 move T payoffs 1 -1
+level 13 node 11 xshift -0.45 from 11,5 move H payoffs 1 -1
+level 13 node 12 xshift 0.45 from 11,5 move T payoffs -1 1
+level 10 node 7 xshift -0.90 from 6,3 move H
+level 10 node 8 xshift 0.90 from 6,3 move T
+level 13 node 13 xshift -0.45 from 10,8 move H payoffs -1 1
+level 13 node 14 xshift 0.45 from 10,8 move T payoffs 1 -1
+level 13 node 15 xshift -0.45 from 10,7 move H payoffs 1 -1
+level 13 node 16 xshift 0.45 from 10,7 move T payoffs -1 1
+iset 2,2 2,1 player 2
+iset 8,2 8,1 player 2
+iset 9,4 9,3 player 2
+iset 11,6 11,5 player 2
+iset 10,8 10,7 player 2
diff --git a/games/cent2.ef b/games/cent2.ef
new file mode 100644
index 0000000..b74958f
--- /dev/null
+++ b/games/cent2.ef
@@ -0,0 +1,41 @@
+player 1 name Player~1
+player 2 name Player~2
+level 0 node 1 player 0
+level 2 node 1 player 0 xshift -7.16 from 0,1 move 1=rational~(1)
+level 2 node 2 player 0 xshift 7.16 from 0,1 move 1=altruist~(\frac{19}{20})
+level 7 node 1 xshift -0.62 from 2,2 move 2=rational~(2)
+level 7 node 2 xshift 0.62 from 2,2 move 2=altruist~(\frac{19}{20})
+level 11 node 1 xshift -0.90 from 7,2 move p
+level 15 node 1 xshift 0.00 from 11,1 move p
+level 19 node 1 xshift 0.00 from 15,1 move p
+level 21 node 1 xshift 0.00 from 19,1 move p payoffs 12.80 3.20
+level 10 node 2 xshift -0.90 from 7,1 move p
+level 12 node 1 xshift -0.45 from 10,2 move t payoffs 0.40 1.60
+level 15 node 2 xshift 0.41 from 10,2 move p
+level 18 node 2 xshift -1.095 from 15,2 move p
+level 21 node 2 xshift -0.55 from 18,2 move t payoffs 1.60 6.40
+level 21 node 3 xshift 0.55 from 18,2 move p payoffs 12.80 3.20
+level 6 node 3 xshift -4.18 from 2,1 move 2=rational~(2)
+level 6 node 4 xshift 4.18 from 2,1 move 2=altruist~(\frac{19}{20})
+level 8 node 1 xshift -0.90 from 6,4 move t payoffs 0.80 0.20
+level 11 node 3 xshift 0.90 from 6,4 move p
+level 14 node 3 xshift -2.205 from 11,3 move p
+level 16 node 1 xshift -0.55 from 14,3 move t payoffs 3.20 0.80
+level 19 node 3 xshift 0.27 from 14,3 move p
+level 21 node 4 xshift 0.00 from 19,3 move p payoffs 12.80 3.20
+level 8 node 2 xshift -0.90 from 6,3 move t payoffs 0.80 0.20
+level 10 node 4 xshift 0.90 from 6,3 move p
+level 12 node 2 xshift -0.45 from 10,4 move t payoffs 0.40 1.60
+level 14 node 4 xshift 2.205 from 10,4 move p
+level 16 node 2 xshift -0.82 from 14,4 move t payoffs 3.20 0.80
+level 18 node 4 xshift 1.095 from 14,4 move p
+level 21 node 5 xshift -0.55 from 18,4 move t payoffs 1.60 6.40
+level 21 node 6 xshift 0.55 from 18,4 move p payoffs 12.80 3.20
+iset 7,2 7,1 player 1
+iset 11,3 11,1 player 2
+iset 15,2 15,1 player 1
+iset 19,3 19,1 player 2
+iset 10,4 10,2 player 2
+iset 18,4 18,2 player 2
+iset 6,4 6,3 player 1
+iset 14,4 14,3 player 1
diff --git a/games/efg/2s2x2x2.efg b/games/efg/2s2x2x2.efg
new file mode 100644
index 0000000..0e34add
--- /dev/null
+++ b/games/efg/2s2x2x2.efg
@@ -0,0 +1,32 @@
+EFG 2 R "Two stage McKelvey McLennan game with 9 equilibria each stage" { "Player 1" "Player 2" "Player 3" }
+""
+
+p "" 1 1 "Infoset2" { "U1" "D1" } 0
+p "" 2 1 ":1" { "U2" "D2" } 0
+p "" 3 1 ":1" { "U3" "D3" } 0
+p "" 1 2 "" { "U1" "D1" } 1 "Outcome 2" { 9, 8, 12 }
+p "" 2 2 "Infoset3" { "U2" "D2" } 0
+p "" 3 2 "Infoset3" { "U3" "D3" } 0
+t "" 1 "Outcome 2" { 9, 8, 12 }
+t "" 2 "Outcome 1" { 0, 0, 0 }
+p "" 3 2 "Infoset3" { "U3" "D3" } 0
+t "" 2 "Outcome 1" { 0, 0, 0 }
+t "" 3 "Outcome 4" { 3, 4, 6 }
+p "" 2 2 "Infoset3" { "U2" "D2" } 0
+p "" 3 2 "Infoset3" { "U3" "D3" } 0
+t "" 2 "Outcome 1" { 0, 0, 0 }
+t "" 3 "Outcome 4" { 3, 4, 6 }
+p "" 3 2 "Infoset3" { "U3" "D3" } 0
+t "" 4 "Outcome 3" { 9, 8, 2 }
+t "" 2 "Outcome 1" { 0, 0, 0 }
+t "" 2 "Outcome 1" { 0, 0, 0 }
+p "" 3 1 ":1" { "U3" "D3" } 0
+t "" 2 "Outcome 1" { 0, 0, 0 }
+t "" 3 "Outcome 4" { 3, 4, 6 }
+p "" 2 1 ":1" { "U2" "D2" } 0
+p "" 3 1 ":1" { "U3" "D3" } 0
+t "" 2 "Outcome 1" { 0, 0, 0 }
+t "" 3 "Outcome 4" { 3, 4, 6 }
+p "" 3 1 ":1" { "U3" "D3" } 0
+t "" 4 "Outcome 3" { 9, 8, 2 }
+t "" 2 "Outcome 1" { 0, 0, 0 }
diff --git a/games/efg/2smp.efg b/games/efg/2smp.efg
new file mode 100644
index 0000000..07c48a3
--- /dev/null
+++ b/games/efg/2smp.efg
@@ -0,0 +1,34 @@
+EFG 2 R "Two-stage matching pennies game" { "Player 1" "Player 2" }
+""
+
+p "" 1 1 "" { "H" "T" } 0
+p "" 2 1 "" { "H" "T" } 0
+p "" 1 2 "" { "H" "T" } 1 "Match" { 1, -1 }
+p "" 2 2 "" { "H" "T" } 0
+t "" 1 "Match" { 1, -1 }
+t "" 2 "Mismatch" { -1, 1 }
+p "" 2 2 "" { "H" "T" } 0
+t "" 2 "Mismatch" { -1, 1 }
+t "" 1 "Match" { 1, -1 }
+p "" 1 3 "" { "H" "T" } 2 "Mismatch" { -1, 1 }
+p "" 2 3 "" { "H" "T" } 0
+t "" 1 "Match" { 1, -1 }
+t "" 2 "Mismatch" { -1, 1 }
+p "" 2 3 "" { "H" "T" } 0
+t "" 2 "Mismatch" { -1, 1 }
+t "" 1 "Match" { 1, -1 }
+p "" 2 1 "" { "H" "T" } 0
+p "" 1 4 "" { "H" "T" } 2 "Mismatch" { -1, 1 }
+p "" 2 4 "" { "H" "T" } 0
+t "" 1 "Match" { 1, -1 }
+t "" 2 "Mismatch" { -1, 1 }
+p "" 2 4 "" { "H" "T" } 0
+t "" 2 "Mismatch" { -1, 1 }
+t "" 1 "Match" { 1, -1 }
+p "" 1 5 "" { "H" "T" } 1 "Match" { 1, -1 }
+p "" 2 5 "" { "H" "T" } 0
+t "" 1 "Match" { 1, -1 }
+t "" 2 "Mismatch" { -1, 1 }
+p "" 2 5 "" { "H" "T" } 0
+t "" 2 "Mismatch" { -1, 1 }
+t "" 1 "Match" { 1, -1 }
diff --git a/games/efg/cent2.efg b/games/efg/cent2.efg
new file mode 100644
index 0000000..9e08df9
--- /dev/null
+++ b/games/efg/cent2.efg
@@ -0,0 +1,34 @@
+EFG 2 R "Centipede game. Two inning, with probability of altruists. " { "Player 1" "Player 2" }
+""
+
+c "" 1 "(0,1)" { "1=rational" 19/20 "1=altruist" 1/20 } 0
+c "" 2 "(0,2)" { "2=rational" 19/20 "2=altruist" 1/20 } 0
+p "" 1 1 "(1,1)" { "t" "p" } 0
+t "" 1 "Outcome 1" { .80, .20 }
+p "" 2 1 "(2,1)" { "t" "p" } 0
+t "" 2 "Outcome 2" { .40, 1.60 }
+p "" 1 2 "(1,2)" { "t" "p" } 0
+t "" 3 "Outcome 3" { 3.20, .80 }
+p "" 2 2 "(2,2)" { "t" "p" } 0
+t "" 4 "Outcome 4" { 1.60, 6.40 }
+t "" 5 "Outcome 5" { 12.80, 3.20 }
+p "" 1 1 "(1,1)" { "t" "p" } 0
+t "" 6 "Outcome 11" { .80, .20 }
+p "" 2 3 "(2,3)" { "p" } 0
+p "" 1 2 "(1,2)" { "t" "p" } 0
+t "" 7 "Outcome 13" { 3.20, .80 }
+p "" 2 4 "(2,4)" { "p" } 0
+t "" 5 "Outcome 5" { 12.80, 3.20 }
+c "" 3 "(0,3)" { "2=rational" 19/20 "2=altruist" 1/20 } 0
+p "" 1 3 "(1,3)" { "p" } 0
+p "" 2 1 "(2,1)" { "t" "p" } 0
+t "" 8 "Outcome 22" { .40, 1.60 }
+p "" 1 4 "(1,4)" { "p" } 0
+p "" 2 2 "(2,2)" { "t" "p" } 0
+t "" 9 "Outcome 24" { 1.60, 6.40 }
+t "" 5 "Outcome 5" { 12.80, 3.20 }
+p "" 1 3 "(1,3)" { "p" } 0
+p "" 2 3 "(2,3)" { "p" } 0
+p "" 1 4 "(1,4)" { "p" } 0
+p "" 2 4 "(2,4)" { "p" } 0
+t "" 5 "Outcome 5" { 12.80, 3.20 }
diff --git a/games/efg/cross.efg b/games/efg/cross.efg
new file mode 100644
index 0000000..bc39047
--- /dev/null
+++ b/games/efg/cross.efg
@@ -0,0 +1,22 @@
+EFG 2 R "Criss-crossing infosets" { "Player 1" "Player 2" }
+""
+
+p "ROOT" 1 1 "" { "1" "2" } 0
+p "" 2 1 "" { "1" "2" } 0
+p "" 1 2 "" { "1" "2" } 0
+p "" 2 2 "" { "1" "2" } 0
+t "" 0
+t "" 0
+t "" 0
+p "" 1 2 "" { "1" "2" } 0
+t "" 0
+t "" 0
+p "" 2 2 "" { "1" "2" } 0
+p "" 1 3 "" { "1" "2" } 0
+p "" 2 1 "" { "1" "2" } 0
+t "" 0
+t "" 0
+t "" 0
+p "" 1 3 "" { "1" "2" } 0
+t "" 0
+t "" 0
diff --git a/games/efg/holdout.efg b/games/efg/holdout.efg
new file mode 100644
index 0000000..1a7c97b
--- /dev/null
+++ b/games/efg/holdout.efg
@@ -0,0 +1,139 @@
+EFG 2 R "McKelvey-Palfrey (JET 77), 7 stage version of holdout game" { "Player 1" "Player 2" }
+""
+
+c "" 1 "Choose 1's type" { "High" 1/2 "Low" 1/2 } 0
+c "" 2 "Choose 2's type" { "High" 3/5 "Low" 2/5 } 0
+p "" 1 1 "" { "H" "G" } 0
+p "" 2 1 "" { "H" "G" } 0
+c "" 3 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 2 "" { "H" "G" } 0
+p "" 2 2 "" { "H" "G" } 0
+c "" 4 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 3 "" { "H" "G" } 0
+p "" 2 3 "" { "H" "G" } 0
+c "" 5 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 4 "" { "H" "G" } 0
+p "" 2 4 "" { "H" "G" } 0
+c "" 6 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 5 "" { "H" "G" } 0
+p "" 2 5 "" { "H" "G" } 0
+c "" 7 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 6 "" { "H" "G" } 0
+p "" 2 6 "" { "H" "G" } 0
+c "" 8 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 7 "" { "H" "G" } 0
+p "" 2 7 "" { "H" "G" } 0
+t "" 1 "" { 0, 0 }
+t "" 2 "" { 1, 1/2 }
+p "" 2 7 "" { "H" "G" } 0
+t "" 3 "" { 1/2, 1 }
+t "" 4 "" { 1/2, 1/2 }
+t "" 2 "" { 1, 1/2 }
+p "" 2 6 "" { "H" "G" } 0
+t "" 3 "" { 1/2, 1 }
+t "" 4 "" { 1/2, 1/2 }
+t "" 2 "" { 1, 1/2 }
+p "" 2 5 "" { "H" "G" } 0
+t "" 3 "" { 1/2, 1 }
+t "" 4 "" { 1/2, 1/2 }
+t "" 2 "" { 1, 1/2 }
+p "" 2 4 "" { "H" "G" } 0
+t "" 3 "" { 1/2, 1 }
+t "" 4 "" { 1/2, 1/2 }
+t "" 2 "" { 1, 1/2 }
+p "" 2 3 "" { "H" "G" } 0
+t "" 3 "" { 1/2, 1 }
+t "" 4 "" { 1/2, 1/2 }
+t "" 2 "" { 1, 1/2 }
+p "" 2 2 "" { "H" "G" } 0
+t "" 3 "" { 1/2, 1 }
+t "" 4 "" { 1/2, 1/2 }
+t "" 2 "" { 1, 1/2 }
+p "" 2 1 "" { "H" "G" } 0
+t "" 3 "" { 1/2, 1 }
+t "" 4 "" { 1/2, 1/2 }
+p "" 1 1 "" { "H" "G" } 0
+p "" 2 8 "" { "H" } 0
+c "" 9 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 2 "" { "H" "G" } 0
+p "" 2 8 "" { "H" } 0
+c "" 10 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 3 "" { "H" "G" } 0
+p "" 2 8 "" { "H" } 0
+c "" 11 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 4 "" { "H" "G" } 0
+p "" 2 8 "" { "H" } 0
+c "" 12 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 5 "" { "H" "G" } 0
+p "" 2 8 "" { "H" } 0
+c "" 13 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 6 "" { "H" "G" } 0
+p "" 2 8 "" { "H" } 0
+c "" 14 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 7 "" { "H" "G" } 0
+p "" 2 8 "" { "H" } 0
+t "" 1 "" { 0, 0 }
+p "" 2 8 "" { "H" } 0
+t "" 3 "" { 1/2, 1 }
+p "" 2 8 "" { "H" } 0
+t "" 3 "" { 1/2, 1 }
+p "" 2 8 "" { "H" } 0
+t "" 3 "" { 1/2, 1 }
+p "" 2 8 "" { "H" } 0
+t "" 3 "" { 1/2, 1 }
+p "" 2 8 "" { "H" } 0
+t "" 3 "" { 1/2, 1 }
+p "" 2 8 "" { "H" } 0
+t "" 3 "" { 1/2, 1 }
+p "" 2 8 "" { "H" } 0
+t "" 3 "" { 1/2, 1 }
+c "" 15 "Choose 2's type" { "High" 3/5 "Low" 2/5 } 0
+p "" 1 8 "" { "H" } 0
+p "" 2 1 "" { "H" "G" } 0
+c "" 16 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 8 "" { "H" } 0
+p "" 2 2 "" { "H" "G" } 0
+c "" 17 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 8 "" { "H" } 0
+p "" 2 3 "" { "H" "G" } 0
+c "" 18 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 8 "" { "H" } 0
+p "" 2 4 "" { "H" "G" } 0
+c "" 19 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 8 "" { "H" } 0
+p "" 2 5 "" { "H" "G" } 0
+c "" 20 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 8 "" { "H" } 0
+p "" 2 6 "" { "H" "G" } 0
+c "" 21 "Discount" { "" 1/10 "" 9/10 } 1 "" { 0, 0 }
+t "" 0
+p "" 1 8 "" { "H" } 0
+p "" 2 7 "" { "H" "G" } 0
+t "" 1 "" { 0, 0 }
+t "" 2 "" { 1, 1/2 }
+t "" 2 "" { 1, 1/2 }
+t "" 2 "" { 1, 1/2 }
+t "" 2 "" { 1, 1/2 }
+t "" 2 "" { 1, 1/2 }
+t "" 2 "" { 1, 1/2 }
+t "" 2 "" { 1, 1/2 }
+p "" 1 8 "" { "H" } 0
+p "" 2 8 "" { "H" } 0
+t "" 1 "" { 0, 0 }
diff --git a/games/efg/one_card_poker.efg b/games/efg/one_card_poker.efg
new file mode 100644
index 0000000..6d09a1a
--- /dev/null
+++ b/games/efg/one_card_poker.efg
@@ -0,0 +1,14 @@
+EFG 2 R "One card poker game, after Myerson (1991)" { "Alice" "Bob" }
+""
+
+c "" 1 "" { "King" 1/2 "Queen" 1/2 } 0
+p "" 1 1 "" { "Raise" "Fold" } 0
+p "" 2 1 "" { "Meet" "Pass" } 0
+t "" 1 "Alice wins big" { 2, -2 }
+t "" 2 "Alice wins" { 1, -1 }
+t "" 4 "Bob wins" { -1, 1 }
+p "" 1 2 "" { "Raise" "Fold" } 0
+p "" 2 1 "" { "Meet" "Pass" } 0
+t "" 3 "Bob wins big" { -2, 2 }
+t "" 2 "Alice wins" { 1, -1 }
+t "" 4 "Bob wins" { -1, 1 }
diff --git a/games/efg/trust_game.efg b/games/efg/trust_game.efg
new file mode 100644
index 0000000..5b85cac
--- /dev/null
+++ b/games/efg/trust_game.efg
@@ -0,0 +1,8 @@
+EFG 2 R "One-shot trust game, after Kreps (1990)" { "Buyer" "Seller" }
+""
+
+p "" 1 1 "" { "Trust" "Not trust" } 0
+p "Trust" 2 1 "" { "Honor" "Abuse" } 0
+t "Honor" 1 "Trustworthy" { 1, 1 }
+t "Abuse" 2 "Untrustworthy" { -1, 2 }
+t "Not trust" 3 "Opt-out" { 0, 0 }
diff --git a/games/one_card_poker.ef b/games/one_card_poker.ef
index 7dc0817..73405b3 100644
--- a/games/one_card_poker.ef
+++ b/games/one_card_poker.ef
@@ -1,8 +1,8 @@
player 1 name Alice
player 2 name Bob
level 0 node 1 player 0
-level 2 node 1 player 1 xshift -3.58 from 0,1 move \frac{1}{2}
-level 2 node 2 player 1 xshift 3.58 from 0,1 move \frac{1}{2}
+level 2 node 1 player 1 xshift -3.58 from 0,1 move King~(\frac{1}{2})
+level 2 node 2 player 1 xshift 3.58 from 0,1 move Queen~(\frac{1}{2})
level 6 node 1 xshift -4.18 from 2,2 move Raise
level 4 node 1 xshift 1.79 from 2,2 move Fold payoffs -1 1
level 8 node 1 xshift -1.19 from 6,1 move Meet payoffs -2 2
diff --git a/src/draw_tree/__init__.py b/src/draw_tree/__init__.py
index c8917a3..dd60563 100644
--- a/src/draw_tree/__init__.py
+++ b/src/draw_tree/__init__.py
@@ -13,7 +13,8 @@
generate_pdf,
generate_png,
ef_to_tex,
- latex_wrapper
+ latex_wrapper,
+ efg_to_ef
)
__all__ = [
@@ -22,5 +23,6 @@
"generate_pdf",
"generate_png",
"ef_to_tex",
- "latex_wrapper"
+ "latex_wrapper",
+ "efg_to_ef"
]
\ No newline at end of file
diff --git a/src/draw_tree/core.py b/src/draw_tree/core.py
index 37fe811..39c46af 100644
--- a/src/draw_tree/core.py
+++ b/src/draw_tree/core.py
@@ -11,6 +11,7 @@
import math
import subprocess
import tempfile
+import re
from pathlib import Path
from typing import List, Optional
@@ -1261,6 +1262,16 @@ def draw_tree(ef_file: str, scale_factor: float = 1.0, show_grid: bool = False)
Returns:
Complete TikZ code ready for use in Jupyter notebooks or LaTeX documents.
"""
+ # If user supplied an EFG file, convert it to .ef first so the existing
+ # ef-based pipeline can be reused. efg_to_ef returns a path string when
+ # it successfully writes the .ef file.
+ if isinstance(ef_file, str) and ef_file.lower().endswith('.efg'):
+ try:
+ ef_file = efg_to_ef(ef_file)
+ except Exception:
+ # fall through and let ef_to_tex raise a clearer error later
+ pass
+
# Step 1: Generate the tikzpicture content using ef_to_tex logic
tikz_picture_content = ef_to_tex(ef_file, scale_factor, show_grid)
@@ -1373,6 +1384,13 @@ def generate_tex(ef_file: str, output_tex: Optional[str] = None, scale_factor: f
ef_path = Path(ef_file)
output_tex = ef_path.with_suffix('.tex').name
+ # If input is an EFG file, convert it first
+ if isinstance(ef_file, str) and ef_file.lower().endswith('.efg'):
+ try:
+ ef_file = efg_to_ef(ef_file)
+ except Exception:
+ pass
+
# Generate TikZ content using draw_tree
tikz_content = draw_tree(ef_file, scale_factor, show_grid)
@@ -1583,4 +1601,739 @@ def generate_png(ef_file: str, output_png: Optional[str] = None, scale_factor: f
# Re-raise PDF generation errors
raise
except Exception as e:
- raise RuntimeError(f"PNG generation failed: {e}")
\ No newline at end of file
+ raise RuntimeError(f"PNG generation failed: {e}")
+
+
+class DefaultLayout:
+ """Encapsulate layout heuristics and emission for .ef generation.
+
+ Accepts a list of descriptor dicts (in preorder) and optional
+ player names, and produces the list of `.ef` lines via `to_lines()`.
+ """
+
+ class Node:
+ def __init__(self, desc=None, move_name=None, prob=None):
+ self.desc = desc
+ self.move = move_name
+ self.prob = prob
+ self.children: List['DefaultLayout.Node'] = []
+ self.parent: Optional['DefaultLayout.Node'] = None
+ self.x = 0.0
+ self.level = 0
+
+ def __init__(self, descriptors: List[dict], player_names: List[str]):
+ self.descriptors = descriptors
+ self.player_names = player_names
+ self.root: Optional[DefaultLayout.Node] = None
+ self.leaves: List[DefaultLayout.Node] = []
+ self.node_ids = {}
+ self.iset_groups = {}
+ self.counters_by_level = {}
+
+ def build_tree(self):
+ def build_node(i):
+ if i >= len(self.descriptors):
+ return None, i
+ d = self.descriptors[i]
+ node = DefaultLayout.Node(desc=d)
+ i += 1
+ if d['kind'] in ('c', 'p'):
+ for m_i, mv in enumerate(d['moves']):
+ prob = None
+ if m_i < len(d['probs']):
+ prob = d['probs'][m_i]
+ child, i = build_node(i)
+ if child is None:
+ child = DefaultLayout.Node(desc={'kind': 't', 'payoffs': []})
+ child.move = mv
+ child.prob = prob
+ child.parent = node
+ node.children.append(child)
+ return node, i
+
+ self.root, _ = build_node(0)
+
+ def collect_leaves(self):
+ self.leaves = []
+
+ def collect(n):
+ if not n.children:
+ self.leaves.append(n)
+ else:
+ for c in n.children:
+ collect(c)
+
+ if self.root:
+ collect(self.root)
+
+ def assign_x(self):
+ BASE_LEAF_UNIT = 3.58
+ if len(self.leaves) > 1:
+ total = (len(self.leaves) - 1) * BASE_LEAF_UNIT
+ for i, leaf in enumerate(self.leaves):
+ leaf.x = -total / 2 + i * BASE_LEAF_UNIT
+ elif self.leaves:
+ self.leaves[0].x = 0.0
+
+ def set_internal_x(self, n: 'DefaultLayout.Node'):
+ if n.children:
+ for c in n.children:
+ self.set_internal_x(c)
+ n.x = sum(c.x for c in n.children) / len(n.children)
+
+ def assign_levels(self):
+ if not self.root:
+ return
+ self.root.level = 0
+
+ def assign(n):
+ for c in n.children:
+ if n.level == 0:
+ step = 2
+ else:
+ step = 4 if c.children else 2
+ c.level = n.level + step
+ assign(c)
+
+ assign(self.root)
+
+ def compute_scale_and_mult(self):
+ BASE_LEAF_UNIT = 3.58
+ emit_scale = 1.0
+ try:
+ if self.root and self.root.children:
+ max_offset = max(abs(c.x - self.root.x) for c in self.root.children)
+ if max_offset > 1e-9:
+ emit_scale = BASE_LEAF_UNIT / max_offset
+ except Exception:
+ emit_scale = 1.0
+ num_leaves = len(self.leaves)
+ try:
+ adaptive_mult = max(0.5, min(1.167, 6.0 / float(num_leaves)))
+ except Exception:
+ adaptive_mult = 1.0
+ # compute root-child imbalance ratio for selective top-level widening
+ ratio = 1.0
+ try:
+ root_desc = getattr(self.root, 'desc', None)
+ if root_desc is not None and root_desc.get('kind') == 'c' and self.root and self.root.children:
+ def count_leaves(n: 'DefaultLayout.Node') -> int:
+ if not n.children:
+ return 1
+ s = 0
+ for ch in n.children:
+ s += count_leaves(ch)
+ return s
+ counts = [count_leaves(ch) for ch in self.root.children]
+ if counts and min(counts) > 0:
+ ratio = max(counts) / float(min(counts))
+ else:
+ ratio = 1.0
+ except Exception:
+ ratio = 1.0
+ # store ratio for emit_node to use
+ self._root_child_ratio = ratio
+ return emit_scale, adaptive_mult
+
+ def _separate_iset_levels(self):
+ """Relocate colliding information-set groups to distinct integer levels.
+
+ For each info-set group that shares an integer level with other groups,
+ deterministically move the later groups to the nearest available
+ integer level that is strictly greater than all their parents' levels
+ and strictly less than all their children's levels. Update
+ self.node_ids, node.level and entries in self.iset_groups.
+ """
+ if not self.iset_groups:
+ return
+
+ # Build quick lookup from (int_level, local_id) -> node_obj
+ lookup = {}
+ for node_obj, (lvl, lid) in list(self.node_ids.items()):
+ try:
+ il = int(round(lvl))
+ except Exception:
+ il = int(lvl)
+ lookup[(il, lid)] = node_obj
+
+ # Treat levels that contain terminal nodes as unavailable for iset placement.
+ # Find levels of terminal nodes and mark them occupied so we never
+ # relocate an info-set into a level that already holds terminals.
+ terminal_levels = set()
+ for nobj, (lv, lid) in list(self.node_ids.items()):
+ desc = getattr(nobj, 'desc', None)
+ if desc and desc.get('kind') == 't':
+ terminal_levels.add(int(round(lv)))
+
+ # Only consider info-set groups that actually have multiple members.
+ # Singleton iset entries should not be treated as colliding groups or
+ # as occupied levels — they are emitted as normal nodes.
+ filtered_iset_groups = {k: v for k, v in self.iset_groups.items() if len(v) >= 2}
+
+ # iset levels collected only from filtered groups
+ iset_levels = set()
+ for lst in filtered_iset_groups.values():
+ for lv, _ in lst:
+ iset_levels.add(int(round(lv)))
+
+ # Occupied levels are terminal levels plus existing multi-member iset levels.
+ occupied = set()
+ occupied.update(terminal_levels)
+ occupied.update(iset_levels)
+
+ # Map integer level -> groups present there (only multi-member groups)
+ level_groups = {}
+ for group_key, lst in filtered_iset_groups.items():
+ for lv, nid in lst:
+ il = int(round(lv))
+ level_groups.setdefault(il, set()).add(group_key)
+
+ # Process levels in increasing order deterministically
+ for il in sorted(level_groups.keys()):
+ groups = sorted(level_groups[il], key=lambda k: (k[0], k[1]))
+ if len(groups) <= 1:
+ continue
+ # keep the first group, move others
+ for group_key in groups[1:]:
+ # find nodes of this group at this integer level
+ entries = [ (lv, nid) for (lv, nid) in list(self.iset_groups.get(group_key, [])) if int(round(lv)) == il ]
+ node_objs = []
+ for lv, nid in entries:
+ n = lookup.get((il, nid))
+ if n is not None:
+ node_objs.append((n, nid))
+ if not node_objs:
+ continue
+
+ # Also consider all nodes that belong to this iset group (not just those at il).
+ full_group_nodes = []
+ for glv, gid in list(self.iset_groups.get(group_key, [])):
+ gnode = lookup.get((int(round(glv)), gid))
+ if gnode is not None:
+ full_group_nodes.append((gnode, gid))
+
+ # compute bounds: must be > all parents' levels and < all childrens' levels
+ # Use full_group_nodes for bounds so we don't miss children/parents
+ parents = []
+ children_mins = []
+ source_nodes = full_group_nodes if full_group_nodes else node_objs
+ for (nnode, _) in source_nodes:
+ if nnode.parent is not None:
+ parents.append(int(round(nnode.parent.level)))
+ if nnode.children:
+ children_mins.append(min(int(round(ch.level)) for ch in nnode.children))
+ parent_max = max(parents) if parents else -100000
+ child_min = min(children_mins) if children_mins else 100000
+ min_allowed = parent_max + 1
+ max_allowed = child_min - 1
+
+ # search nearest free integer level within [min_allowed, max_allowed]
+ candidate = None
+ if min_allowed <= il <= max_allowed and il not in occupied:
+ candidate = il
+ else:
+ # try offsets 1, -1, 2, -2 ... within allowed window
+ for offset in range(1, 201):
+ # prefer shifting outward (il+offset) then inward (il-offset)
+ for cand in (il + offset, il - offset):
+ if cand < min_allowed or cand > max_allowed:
+ continue
+ if cand not in occupied:
+ candidate = cand
+ break
+ if candidate is not None:
+ break
+
+ # if still not found, try any free slot from min_allowed upward
+ if candidate is None:
+ for cand in range(min_allowed, max_allowed + 1):
+ if cand not in occupied:
+ candidate = cand
+ break
+
+ if candidate is None:
+ # try to find next free integer >= min_allowed (may exceed max_allowed)
+ cand = max(min_allowed, il + 1)
+ while cand in occupied:
+ cand += 1
+ desired = cand
+ # If desired would be below children (i.e., > max_allowed),
+ # shift the subtrees of these nodes' children upward so we can
+ # insert the info-set level without placing it under terminals.
+ if max_allowed is not None and desired > max_allowed:
+ shift_needed = desired - max_allowed
+
+ # collect descendants (exclude the group nodes themselves)
+ def collect_subtree(n: 'DefaultLayout.Node', acc: set):
+ if n in acc:
+ return
+ acc.add(n)
+ for ch in n.children:
+ collect_subtree(ch, acc)
+
+ descendant_nodes = set()
+ for n_obj, _ in full_group_nodes:
+ for ch in n_obj.children:
+ collect_subtree(ch, descendant_nodes)
+
+ # shift levels for descendant nodes (lift children/terminals upward)
+ for nshift in descendant_nodes:
+ old_level = int(round(nshift.level))
+ nshift.level = int(round(nshift.level)) + shift_needed
+ if nshift in self.node_ids:
+ _, lid = self.node_ids[nshift]
+ self.node_ids[nshift] = (nshift.level, lid)
+ # update any iset_groups entries that reference this node
+ for gkey, glst in self.iset_groups.items():
+ for j, (olv, oid) in enumerate(list(glst)):
+ if int(round(olv)) == old_level and oid == self.node_ids.get(nshift, (nshift.level, None))[1]:
+ glst[j] = (nshift.level, oid)
+
+ # update occupied set to include new levels
+ occupied.update(int(round(n.level)) for n in descendant_nodes)
+ # also ensure we don't select terminal levels later
+ occupied.update(terminal_levels)
+ candidate = desired
+ else:
+ candidate = desired
+
+ # apply candidate to all members of the full info-set group
+ for node_obj, nid in full_group_nodes:
+ node_obj.level = int(candidate)
+ self.node_ids[node_obj] = (int(candidate), nid)
+ # update lookup
+ lookup[(int(candidate), nid)] = node_obj
+ occupied.add(int(candidate))
+ # update iset_groups stored levels for this group to the candidate
+ lst = self.iset_groups.get(group_key, [])
+ for i, (oldlv, idn) in enumerate(list(lst)):
+ lst[i] = (int(candidate), idn)
+
+ # Phase 2 unification was removed to preserve canonical example layouts
+
+ def to_lines(self) -> List[str]:
+ # Build tree and layout
+ self.build_tree()
+ if self.root is None:
+ return []
+ self.collect_leaves()
+ self.assign_x()
+ self.set_internal_x(self.root)
+ self.assign_levels()
+ # Post-process: ensure every connected parent->child pair has at least
+ # two integer-levels of separation. This enforces the invariant
+ # child.level >= parent.level + 2 for every edge, repeating until
+ # stable so transitive adjustments propagate deterministically.
+ def enforce_spacing():
+ changed = True
+ while changed:
+ changed = False
+ def walk(n):
+ nonlocal changed
+ for c in n.children:
+ try:
+ plevel = int(round(n.level))
+ clevel = int(round(c.level))
+ except Exception:
+ plevel = int(n.level)
+ clevel = int(c.level)
+ if clevel < plevel + 2:
+ c.level = plevel + 2
+ changed = True
+ # always continue walking to enforce transitive constraints
+ if c.children:
+ walk(c)
+ if self.root:
+ walk(self.root)
+
+ enforce_spacing()
+ emit_scale, adaptive_mult = self.compute_scale_and_mult()
+
+ LEVEL_XSHIFT = {
+ 2: 3.58,
+ 6: 1.9,
+ 8: 0.90,
+ 9: 0.90,
+ 10: 0.90,
+ 11: 0.90,
+ 12: 0.45,
+ 14: 2.205,
+ 18: 1.095,
+ 20: 0.73,
+ }
+
+ out_lines: List[str] = []
+ for i, name in enumerate(self.player_names, start=1):
+ pname = name.replace(' ', '~')
+ out_lines.append(f"player {i} name {pname}")
+
+ # First pass to allocate ids deterministically
+ self.node_ids = {}
+ self.iset_groups = {}
+ self.counters_by_level = {}
+
+ def alloc_local_id(level: float) -> int:
+ self.counters_by_level.setdefault(level, 0)
+ self.counters_by_level[level] += 1
+ return self.counters_by_level[level]
+
+ def alloc_ids(n: 'DefaultLayout.Node'):
+ if n not in self.node_ids:
+ lid = alloc_local_id(n.level)
+ self.node_ids[n] = (n.level, lid)
+ if n.desc and n.desc.get('iset_id') is not None and n.desc.get('player') is not None:
+ key = (n.desc['player'], n.desc['iset_id'])
+ self.iset_groups.setdefault(key, []).append((n.level, lid))
+ for c in n.children:
+ if c not in self.node_ids:
+ clid = alloc_local_id(c.level)
+ self.node_ids[c] = (c.level, clid)
+ if c.desc and c.desc.get('iset_id') is not None and c.desc.get('player') is not None:
+ key = (c.desc['player'], c.desc['iset_id'])
+ self.iset_groups.setdefault(key, []).append((c.level, clid))
+ for c in reversed(n.children):
+ alloc_ids(c)
+
+ alloc_ids(self.root)
+
+ # After ids are allocated, ensure info-set groups do not collide
+ # on the same integer level by relocating groups if necessary.
+ try:
+ self._separate_iset_levels()
+ except Exception:
+ pass
+
+ # Final spacing enforcement: _separate_iset_levels may have moved
+ # nodes around; ensure now that every connected parent->child pair
+ # has at least two integer levels separation. Update self.node_ids
+ # entries to match any changed node.level and rebuild iset_groups so
+ # subsequent emission uses consistent integer levels.
+ def enforce_spacing_after_separation():
+ changed = True
+ # Repeat until stable because raising one child can require
+ # raising its children as well.
+ while changed:
+ changed = False
+ # iterate over node objects deterministically
+ for node_obj in list(self.node_ids.keys()):
+ if node_obj.parent is None:
+ continue
+ try:
+ plevel = int(round(node_obj.parent.level))
+ clevel = int(round(node_obj.level))
+ except Exception:
+ plevel = int(node_obj.parent.level)
+ clevel = int(node_obj.level)
+ if clevel < plevel + 2:
+ node_obj.level = plevel + 2
+ # update node_ids to the new integer level, keep lid
+ lid = self.node_ids[node_obj][1]
+ self.node_ids[node_obj] = (int(node_obj.level), lid)
+ changed = True
+
+ # rebuild iset_groups deterministically from node_ids and descriptors
+ new_iset = {}
+ for nobj, (lv, lid) in list(self.node_ids.items()):
+ if nobj.desc and nobj.desc.get('iset_id') is not None and nobj.desc.get('player') is not None:
+ key = (nobj.desc['player'], nobj.desc['iset_id'])
+ new_iset.setdefault(key, []).append((int(round(nobj.level)), lid))
+ # sort entries for determinism
+ for k in new_iset:
+ new_iset[k] = sorted(new_iset[k], key=lambda t: (int(t[0]), int(t[1])))
+ self.iset_groups = new_iset
+
+ try:
+ enforce_spacing_after_separation()
+ except Exception:
+ pass
+
+ # Unify terminal levels by tree depth: ensure all leaves at the same
+ # tree depth share the same integer level. If any leaf at a given
+ # depth is higher (larger integer level) than its peers, raise the
+ # others to match that level and update node_ids/isets.
+ try:
+ # compute depth (distance from root) for every node
+ node_depth = {}
+ def compute_depth(n, d=0):
+ node_depth[n] = d
+ for ch in n.children:
+ compute_depth(ch, d+1)
+ if self.root:
+ compute_depth(self.root, 0)
+
+ # group leaves by depth
+ depth_groups = {}
+ for leaf in self.leaves:
+ d = node_depth.get(leaf, 0)
+ depth_groups.setdefault(d, []).append(leaf)
+
+ changed = False
+ for d, leaves in depth_groups.items():
+ # find maximum integer level among these leaves
+ maxlvl = max(int(round(leaf.level)) for leaf in leaves)
+ for leaf in leaves:
+ if int(round(leaf.level)) < maxlvl:
+ leaf.level = int(maxlvl)
+ # update node_ids if present
+ if leaf in self.node_ids:
+ lid = self.node_ids[leaf][1]
+ self.node_ids[leaf] = (int(maxlvl), lid)
+ changed = True
+
+ if changed:
+ # rebuild iset_groups deterministically
+ new_iset = {}
+ for nobj, (lv, lid) in list(self.node_ids.items()):
+ if nobj.desc and nobj.desc.get('iset_id') is not None and nobj.desc.get('player') is not None:
+ key = (nobj.desc['player'], nobj.desc['iset_id'])
+ new_iset.setdefault(key, []).append((int(round(nobj.level)), lid))
+ for k in new_iset:
+ new_iset[k] = sorted(new_iset[k], key=lambda t: (int(t[0]), int(t[1])))
+ self.iset_groups = new_iset
+ except Exception:
+ pass
+
+ nodes_in_isets = set()
+ for nodes_list in self.iset_groups.values():
+ if len(nodes_list) >= 2:
+ for lv, nid in nodes_list:
+ nodes_in_isets.add((lv, nid))
+
+ def emit_node(n: 'DefaultLayout.Node'):
+ lvl, lid = self.node_ids[n]
+ if n.parent is None:
+ if n.desc and n.desc.get('kind') == 'c':
+ out_lines.append(f"level {lvl} node {lid} player 0 ")
+ elif n.desc and n.desc.get('kind') == 'p':
+ pl = n.desc.get('player') if n.desc.get('player') is not None else 1
+ out_lines.append(f"level {lvl} node {lid} player {pl}")
+
+ for c in n.children:
+ if c not in self.node_ids:
+ clid = alloc_local_id(c.level)
+ self.node_ids[c] = (c.level, clid)
+ # guard descriptor access - some nodes may have None desc
+ if c.desc and c.desc.get('iset_id') is not None and c.desc.get('player') is not None:
+ key = (c.desc['player'], c.desc['iset_id'])
+ self.iset_groups.setdefault(key, []).append((c.level, clid))
+ nodes_in_isets.add((c.level, clid))
+ clvl, clid = self.node_ids[c]
+ base = (c.x - n.x) * emit_scale
+ if n.level == 0:
+ mult = 1.0
+ else:
+ mult = adaptive_mult if c.children else 1.0
+ fallback = base * mult
+ chosen_candidate = False
+ if clvl in LEVEL_XSHIFT:
+ xmag = LEVEL_XSHIFT[clvl]
+ root_desc = getattr(self.root, 'desc', None)
+ # Apply a controlled widening for top-level branches when
+ # root is a chance node and the child-subtrees are imbalanced.
+ # Use the precomputed self._root_child_ratio capped at 2.0 and
+ # only apply when ratio indicates meaningful imbalance.
+ if n.parent is None and root_desc is not None and root_desc.get('kind') == 'c':
+ try:
+ ratio = float(getattr(self, '_root_child_ratio', 1.0))
+ except Exception:
+ ratio = 1.0
+ if ratio >= 1.5:
+ factor = min(2.0, max(1.0, ratio))
+ xmag *= factor
+ if clvl == 6 and ((root_desc is not None and root_desc.get('kind') == 'c') or len(self.leaves) <= 4):
+ xmag = 4.18
+ candidate = xmag if base > 0 else -xmag
+ tol_candidate = 0.25 * abs(candidate) + 0.05
+ if (
+ abs(fallback) < 1.0
+ or abs(candidate - fallback) <= tol_candidate
+ or (abs(fallback) > 1e-9 and abs(candidate) > 1.5 * abs(fallback))
+ or (abs(fallback) > 3.0 * abs(candidate))
+ ):
+ xshift = candidate
+ chosen_candidate = True
+ else:
+ xshift = fallback
+ chosen_candidate = False
+ else:
+ xshift = fallback
+ chosen_candidate = False
+
+ # formatting
+ if chosen_candidate:
+ if abs(xshift) < 1.0:
+ xs = f"{xshift:.2f}"
+ else:
+ s = f"{xshift:.3f}"
+ if '.' in s:
+ s = s.rstrip('0').rstrip('.')
+ xs = s
+ else:
+ if abs(xshift) < 1.0:
+ xs = f"{xshift:.2f}"
+ else:
+ s = f"{xshift:.2f}"
+ if '.' in s:
+ s = s.rstrip('0').rstrip('.')
+ xs = s
+
+ # prepare move label and attach chance probability if parent is a chance node
+ mv = c.move if c.move else ''
+ if c.prob and n.desc and n.desc.get('kind') == 'c':
+ if '/' in c.prob:
+ num, den = c.prob.split('/')
+ mv = f"{mv}~(\\frac{{{num}}}{{{den}}})"
+ else:
+ mv = f"{mv}~({c.prob})"
+
+ if c.desc and (c.desc.get('kind') == 'p' or c.desc.get('kind') == 'c'):
+ # For chance nodes emit player 0; for player nodes emit the
+ # declared player number (default 1). This fixes cases like
+ # `cent2` where internal chance nodes must be printed as player 0.
+ if c.desc.get('kind') == 'c':
+ pl = 0
+ else:
+ pl = c.desc.get('player') if c.desc.get('player') is not None else 1
+ if clvl == 2:
+ emit_player_field = True
+ else:
+ emit_player_field = (c.desc.get('player') is not None)
+ if c.desc and c.desc.get('iset_id') is not None and c.desc.get('player') is not None:
+ key = (c.desc['player'], c.desc['iset_id'])
+ if len(self.iset_groups.get(key, [])) >= 2:
+ emit_player_field = False
+ if emit_player_field:
+ out_lines.append(f"level {clvl} node {clid} player {pl} xshift {xs} from {lvl},{lid} move {mv}")
+ else:
+ out_lines.append(f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move {mv}")
+ else:
+ pay = ''
+ if c.desc and c.desc.get('payoffs'):
+ pay = ' '.join(str(x) for x in c.desc['payoffs'])
+ # use the prepared move label (which may include probability)
+ mvname = mv
+ if mvname:
+ out_lines.append(f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move {mvname} payoffs {pay}")
+ else:
+ out_lines.append(f"level {clvl} node {clid} xshift {xs} from {lvl},{lid} move payoffs {pay}")
+
+ for c in reversed(n.children):
+ emit_node(c)
+
+ emit_node(self.root)
+
+ # emit isets
+ for (player, iset_id), nodes_list in self.iset_groups.items():
+ if len(nodes_list) >= 2:
+ nodes_sorted = sorted(nodes_list, key=lambda t: -t[1])
+ parts = ' '.join(f"{lv},{nid}" for lv, nid in nodes_sorted)
+ out_lines.append(f"iset {parts} player {player}")
+
+ return out_lines
+
+
+def efg_to_ef(efg_file: str) -> str:
+ """Convert a Gambit .efg file to the `.ef` format used by draw_tree.
+
+ The function implements a focused parser and deterministic layout
+ heuristics for producing `.ef` directives from a conservative subset of
+ EFG records (chance nodes `c`, player nodes `p`, and terminals `t`). It
+ emits node level/position lines and information-set (`iset`) groupings.
+
+ Args:
+ efg_file: Path to the input .efg file.
+
+ Returns:
+ Path to the written `.ef` file as a string.
+ """
+
+ lines = readfile(efg_file)
+
+
+ # Extract players from header if present.
+ header = "\n".join(lines[:5])
+ m_players = re.search(r"\{\s*([\s\S]*?)\s*\}", header)
+ player_names = []
+ if m_players:
+ player_names = re.findall(r'"([^\"]+)"', m_players.group(1))
+
+ # Parse EFG records into descriptor objects.
+ descriptors = []
+ for raw in lines:
+ line = raw.strip()
+ if not line or line.startswith('%') or line.startswith('#'):
+ continue
+ tokens = line.split()
+ if not tokens:
+ continue
+ kind = tokens[0]
+ # extract moves in braces
+ brace = re.search(r"\{([^}]*)\}", line)
+ moves = []
+ probs = []
+ payoffs = []
+ player = None
+ if kind == 'c' or kind == 'p':
+ if brace:
+ moves = re.findall(r'"([^"\\]*)"', brace.group(1))
+ # also extract probabilities (numbers) in brace
+ probs = re.findall(r'([0-9]+\/[0-9]+|[0-9]*\.?[0-9]+)', brace.group(1))
+ # attempt to find player id for 'p' lines
+ if kind == 'p':
+ # find first integer token after type
+ nums = [t for t in tokens[1:] if t.isdigit()]
+ if len(nums) >= 1:
+ player = int(nums[0])
+ # if there is a second numeric token treat as info-set id
+ iset_id = None
+ if len(nums) >= 2:
+ iset_id = int(nums[1])
+ else:
+ iset_id = None
+ elif kind == 't':
+ # terminal: extract payoffs (allow integers and decimals)
+ if brace:
+ # Match floats like 12.80, .80, -1.5 or integers like 3
+ pay_tokens = re.findall(r'(-?\d*\.\d+|-?\d+)', brace.group(1))
+ payoffs = []
+ for tok in pay_tokens:
+ # If token contains a decimal point treat as float and
+ # format with two decimal places (keeps trailing zeros),
+ # otherwise treat as integer.
+ if '.' in tok:
+ try:
+ v = float(tok)
+ payoffs.append("{:.2f}".format(v))
+ except Exception:
+ # fallback: keep original token
+ payoffs.append(tok)
+ else:
+ try:
+ payoffs.append(str(int(tok)))
+ except Exception:
+ payoffs.append(tok)
+ descriptors.append({
+ 'kind': kind,
+ 'player': player,
+ 'moves': moves,
+ 'probs': probs,
+ 'payoffs': payoffs,
+ 'iset_id': locals().get('iset_id', None),
+ 'raw': line,
+ })
+
+ # Filter descriptors to only the game records (c, p, t)
+ descriptors = [d for d in descriptors if d['kind'] in ('c', 'p', 't')]
+
+ # Layout/emission: delegate to DefaultLayout class for clarity/testability
+ layout = DefaultLayout(descriptors, player_names)
+ out_lines = layout.to_lines()
+
+ try:
+ efg_path = Path(efg_file)
+ out_path = efg_path.with_suffix('.ef')
+ with open(out_path, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(out_lines) + '\n')
+ return str(out_path)
+ except Exception:
+ return '\n'.join(out_lines)
diff --git a/tests/test_default_layout.py b/tests/test_default_layout.py
new file mode 100644
index 0000000..ef0dfaf
--- /dev/null
+++ b/tests/test_default_layout.py
@@ -0,0 +1,45 @@
+from draw_tree.core import DefaultLayout
+
+
+def make_descriptor(kind, player=None, moves=None, probs=None, payoffs=None, iset_id=None, raw=""):
+ return {
+ 'kind': kind,
+ 'player': player,
+ 'moves': moves or [],
+ 'probs': probs or [],
+ 'payoffs': payoffs or [],
+ 'iset_id': iset_id,
+ 'raw': raw,
+ }
+
+
+def test_defaultlayout_simple_player_tree():
+ # Root player with two moves leading to two terminals
+ desc = [
+ make_descriptor('p', player=1, moves=['A', 'B']),
+ make_descriptor('t', payoffs=[1, 0]),
+ make_descriptor('t', payoffs=[0, 1]),
+ ]
+ layout = DefaultLayout(desc, ['P1'])
+ lines = layout.to_lines()
+ # Must contain player name and two terminal payoffs lines
+ assert any(line.startswith('player 1 name') for line in lines)
+ # find lines with payoffs
+ payoff_lines = [line for line in lines if 'payoffs' in line]
+ assert len(payoff_lines) == 2
+ assert '1 0' in payoff_lines[0] or '1 0' in payoff_lines[1]
+
+
+def test_defaultlayout_chance_fraction_probabilities():
+ # Chance root with two moves using fractional probs 1/2 and 1/2
+ desc = [
+ make_descriptor('c', moves=['X', 'Y'], probs=['1/2', '1/2']),
+ make_descriptor('t', payoffs=[1, -1]),
+ make_descriptor('t', payoffs=[-1, 1]),
+ ]
+ layout = DefaultLayout(desc, ['Chance'])
+ lines = layout.to_lines()
+ # Should include the moved label with \frac printed literally
+ move_lines = [line for line in lines if 'move' in line]
+ # either the LaTeX \frac is present or a plain (1/2) form
+ assert any('\\frac{1}{2}' in line or '(1/2)' in line for line in move_lines)
diff --git a/tests/test_drawtree.py b/tests/test_drawtree.py
index d809451..d2ecf3a 100644
--- a/tests/test_drawtree.py
+++ b/tests/test_drawtree.py
@@ -577,5 +577,38 @@ def test_commandline_invalid_dpi_string(self):
assert dpi == 300 # Should default to 300 for invalid values
+def test_efg_to_ef_conversion_examples():
+ """Integration test: convert the repository's example .efg files and
+ require exact equality with their corresponding canonical .ef outputs.
+
+ This combined test iterates over known example pairs so it's easy to
+ extend with additional examples in the future.
+ """
+ examples = [
+ ('games/efg/one_card_poker.efg', 'games/one_card_poker.ef'),
+ ('games/efg/2smp.efg', 'games/2smp.ef'),
+ ('games/efg/2s2x2x2.efg', 'games/2s2x2x2.ef'),
+ ('games/efg/cent2.efg', 'games/cent2.ef'),
+ ]
+
+ for efg_path, expected_ef_path in examples:
+ out = draw_tree.efg_to_ef(efg_path)
+ # Converter must return a path and write the file
+ assert isinstance(out, str), "efg_to_ef must return a file path string"
+ assert os.path.exists(out), f"efg_to_ef did not create output file: {out}"
+
+ with open(out, 'r', encoding='utf-8') as f:
+ generated = f.read().strip().splitlines()
+ with open(expected_ef_path, 'r', encoding='utf-8') as f:
+ expected = f.read().strip().splitlines()
+
+ gen_norm = [line.strip() for line in generated if line.strip()]
+ expected_lines = [ln.strip() for ln in expected if ln.strip()]
+ assert gen_norm == expected_lines, (
+ f"Generated .ef does not match expected for {efg_path}.\nGenerated:\n" + "\n".join(gen_norm)
+ + "\n\nExpected:\n" + "\n".join(expected_lines)
+ )
+
+
if __name__ == "__main__":
pytest.main([__file__])
\ No newline at end of file
diff --git a/tutorial/basic_usage.ipynb b/tutorial/basic_usage.ipynb
index 8b5baec..f4912fc 100644
--- a/tutorial/basic_usage.ipynb
+++ b/tutorial/basic_usage.ipynb
@@ -2,17 +2,26 @@
"cells": [
{
"cell_type": "code",
- "execution_count": 1,
+ "execution_count": 5,
"id": "733a7ced",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "The jupyter_tikz extension is already loaded. To reload it, use:\n",
+ " %reload_ext jupyter_tikz\n"
+ ]
+ }
+ ],
"source": [
"%load_ext jupyter_tikz"
]
},
{
"cell_type": "code",
- "execution_count": 2,
+ "execution_count": 6,
"id": "162e2935",
"metadata": {},
"outputs": [],
@@ -24,28 +33,38 @@
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": 7,
"id": "26ae62eb",
"metadata": {},
"outputs": [],
"source": [
"example_games = [\n",
- " \"one_card_poker\",\n",
- " \"crossing\",\n",
- " \"Figure1\",\n",
- " \"MyTree1\",\n",
- " \"oldex\",\n",
- " \"x1\"\n",
+ " \"efg/one_card_poker.efg\",\n",
+ " \"efg/trust_game.efg\",\n",
+ " \"efg/2smp.efg\",\n",
+ " \"efg/2s2x2x2.efg\",\n",
+ " \"efg/cent2.efg\",\n",
+ " \"one_card_poker.ef\",\n",
+ " \"2smp.ef\",\n",
+ " \"2s2x2x2.ef\",\n",
+ " \"cent2.ef\",\n",
+ " \"crossing.ef\",\n",
+ " \"Figure1.ef\",\n",
+ " \"MyTree1.ef\",\n",
+ " \"oldex.ef\",\n",
+ " \"x1.ef\",\n",
+ " \"efg/cross.efg\",\n",
+ " \"efg/holdout.efg\",\n",
"]\n",
"tikz_codes = {\n",
- " game: draw_tree(f\"../games/{game}.ef\", show_grid=False, scale_factor=1)\n",
+ " game.split(\".\")[0]: draw_tree(f\"../games/{game}\", show_grid=False, scale_factor=1)\n",
" for game in example_games\n",
"}"
]
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": 8,
"id": "c9e84d02",
"metadata": {},
"outputs": [
@@ -86,62 +105,83 @@
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
"\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
"\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
"\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
@@ -170,11 +210,25 @@
"\n",
"\n",
- "\n",
- "\n",
"\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
@@ -185,21 +239,34 @@
"\n",
"\n",
"\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
- "\n",
"\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
@@ -208,127 +275,127 @@
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
"\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
"\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
- "\n",
- "\n",
- "\n",
+ "\n",
+ "\n",
+ "\n",
"\n",
"\n",
"\n",
@@ -341,7 +408,7 @@
""
]
},
- "execution_count": 4,
+ "execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
@@ -352,995 +419,7486 @@
},
{
"cell_type": "code",
- "execution_count": 5,
- "id": "584e6cdc",
+ "execution_count": 9,
+ "id": "176cb959-b61b-43ad-b44f-3e95eafcdbf8",
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
- "