feat: add themed tutorials and overhaul Monopoly AI trading
- Add new tutorials for Labyrinth and Mexican Train with unique character voices - Redesign Battleship and Mahjong tutorials with narrative themes and assets - Implement AI-initiated trade proposals and counter-offer system in Monopoly - Add AI-to-AI trading with rate-limited proposals and mutual benefit validation - Enable tutorial flags for Labyrinth and Mexican Train in game registry - Add new character assets and tutorial videos
This commit is contained in:
parent
b2e33f6bdf
commit
fc7ae2c53d
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,15 +1,17 @@
|
||||||
## Battleship — Salvo Edition
|
## Blackwind's Broadside — Salvo Edition
|
||||||
|
|
||||||
Two fleets, two oceans, one survivor. Hide your ships, hunt down the enemy's, and send every last hull to the bottom before they do the same to you.
|
*Arr, lend me yer ear, ye sea-hungry cur. I be Blackwind, scourge of the Caribbean, and I've plundered more hulls than ye've had hot suppers. Heed me words or feed the sharks.*
|
||||||
|
|
||||||
## Your two grids
|
Two fleets sail these cursed waters, and only one returns with the booty. Conceal yer ships, stalk the enemy's, and send every last bilge-rotted hull to the deep before they do the same to you.
|
||||||
|
|
||||||
- **Your Fleet** (right) — where your ships live. Enemy shots show up here as splashes and burning hits.
|
## Yer two charts, landlubber
|
||||||
- **Enemy Waters** (left) — your targeting grid. A sonar sweep scans for contacts; you fire blind until a shot lands.
|
|
||||||
|
|
||||||
## Deploy your fleet
|
- **Yer Fleet** (right) — where yer beloved ships lay at anchor. When the enemy fires, splashes and flames appear here. Guard 'em with yer life, for they ARE yer life.
|
||||||
|
- **Enemy Waters** (left) — the hunting ground. A sonar sweep scouts for contacts; ye fire blind into the fog o' war until iron meets timber.
|
||||||
|
|
||||||
You command five ships:
|
## Deploy yer fleet, Captain
|
||||||
|
|
||||||
|
Ye command five vessels, from mighty to meager:
|
||||||
|
|
||||||
| Ship | Size |
|
| Ship | Size |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|
@ -19,24 +21,26 @@ You command five ships:
|
||||||
| Submarine | 3 |
|
| Submarine | 3 |
|
||||||
| Destroyer | 2 |
|
| Destroyer | 2 |
|
||||||
|
|
||||||
- **Drag** each ship from the dock onto Your Fleet grid.
|
- **Drag** each ship from the dock onto Yer Fleet grid, same as layin' out yer fleet on a prize map.
|
||||||
- Press **R** or **right-click** to rotate a ship between horizontal and vertical.
|
- Press **R** or **right-click** to spin a ship from broadside to bow — choose wisely, for the sea rewards cunning placement.
|
||||||
- A placement glows **green** when legal, **red** when it overlaps or runs off the board.
|
- A placement glows **green** when it be sound, **red** when it overlaps or runs off the chart — even Blackwind don't sail into rocks.
|
||||||
- Use **Randomize** to auto-deploy, **Clear** to start over, then hit **Ready** when all five are placed.
|
- Hit **Randomize** to let fate decide yer formation, **Clear** to start fresh, then **Ready** when all five are anchored and waitin'.
|
||||||
|
|
||||||
## Salvo mode — fire a volley each turn
|
## Salvo mode — fire a full broadside each turn
|
||||||
|
|
||||||
This isn't one-shot-at-a-time Battleship. Each turn you fire **one shot for every ship you still have afloat**:
|
*This ain't yer grandmother's one-shot duel. This be war, and Blackwind fires in volleys!*
|
||||||
|
|
||||||
1. On your turn, **click cells** on Enemy Waters to *arm* targets — up to your current salvo size.
|
Each turn ye loose **one shot for every ship still afloat in yer fleet**:
|
||||||
2. Armed cells lock under a golden reticle. Click again to disarm.
|
|
||||||
3. Press **FIRE** to launch the whole volley at once.
|
|
||||||
|
|
||||||
- **Splash** = miss. **Burning marker** = hit. Land every hit on a ship and it's **sunk** — its wreck is revealed.
|
1. On yer turn, **click cells** on Enemy Waters to *arm* yer cannons — up to yer current salvo count.
|
||||||
- Lose a ship and your next salvo shrinks. Stay ahead and you out-gun your opponent; fall behind and every turn gets harder.
|
2. Armed cells glow under a golden reticle, ready to roar. Click again to stand 'em down.
|
||||||
|
3. Press **FIRE** to let loose the whole broadside at once — thunder and fury across the deep!
|
||||||
|
|
||||||
## Win the war
|
- **Splash** = a miss, nothin' but seawater. **Burning marker** = a hit, timber and screams. Strike every cell of a ship and it's **sunk** — its wreck is revealed for all to mourn.
|
||||||
|
- Lose a ship and yer next broadside shrinks — fewer guns, fewer chances. Stay ahead and ye outgun yer foe; fall behind and every turn grows darker, like a storm cloakin' the moon.
|
||||||
|
|
||||||
Sink all five enemy ships before yours go under. The fleet-status panels track every hull's damage on both sides — keep an eye on who's bleeding out first.
|
## Claim the plunder
|
||||||
|
|
||||||
**Tip:** When a shot hits, the surrounding water suddenly looks very interesting. Concentrate your next salvo around a fresh hit to finish the kill.
|
Sink all five enemy ships before yers go under, and the booty be YOURS. The fleet-status panels track every hull's punishment on both sides — keep yer weather eye on who's takin' on water.
|
||||||
|
|
||||||
|
**Blackwind's wisdom:** When a shot finds wood, the water round about suddenly looks mighty tempting. Pour yer next broadside into the waters flanking a fresh hit — finish the kill before they can run, and show no mercy. *The sea shows none to you.*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Welcome to Labyrinth
|
||||||
|
|
||||||
|
*By Mario — Minotaur, labyrinth enthusiast, part-time treasure consultant*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Snorts and stamps hoof appreciatively*
|
||||||
|
|
||||||
|
Hello. I am Mario. I am a Minotaur. I live in a labyrinth — a different one, obviously, much nicer, very good sight lines, wonderful acoustics. I have been navigating shifting maze corridors since before your grandparents were born and I am absolutely the right person to explain this game to you.
|
||||||
|
|
||||||
|
Also I am very excited right now. Labyrinths! Let's go!
|
||||||
|
|
||||||
|
## The Goal
|
||||||
|
|
||||||
|
You are a brave explorer wandering a shifting maze. You have been dealt a stack of **treasure cards** — each card shows a different artifact hidden somewhere in the labyrinth. Your job is to reach each treasure's tile in order, collect it, and then return to your **home corner**. First explorer back with all their treasures wins.
|
||||||
|
|
||||||
|
I, Mario, would simply headbutt through all the walls. But that's not how this works. You must navigate the corridors. Very important distinction.
|
||||||
|
|
||||||
|
## The Board
|
||||||
|
|
||||||
|
The maze is a **7×7 grid** of tiles, each printed with corridors in one of three shapes:
|
||||||
|
|
||||||
|
| Shape | What it looks like |
|
||||||
|
|---|---|
|
||||||
|
| **I (Straight)** | A corridor running straight through, open on two opposite sides |
|
||||||
|
| **L (Corner)** | A corridor bending 90°, open on two adjacent sides |
|
||||||
|
| **T (Junction)** | A corridor with three openings — a crossroads, essentially |
|
||||||
|
|
||||||
|
Here's the crucial thing: **some tiles are fixed, bolted into the labyrinth floor**. The others are loose. And the whole brilliant point of this game is that every turn, you push a new tile into the maze, shoving an entire row or column one step, and the tile on the far end pops out to become the new spare. *The maze changes every turn.*
|
||||||
|
|
||||||
|
I cannot tell you how much I love this. My home labyrinth never changes. I know every stone. This version keeps me on my hooves.
|
||||||
|
|
||||||
|
## The Spare Tile
|
||||||
|
|
||||||
|
There is always one tile sitting off the board — the **spare**. It's yours to push in at the start of your turn. Before you push it, you can **rotate it** (tap the spare tile) to orient its corridors however you like. Point that corner north? Done. Flip that straight corridor sideways? Absolutely. Rotate strategically. This is everything.
|
||||||
|
|
||||||
|
## How a Turn Works
|
||||||
|
|
||||||
|
**Step 1 — Rotate the spare** (optional)
|
||||||
|
Tap the spare tile to spin it. Think about where the corridors need to go.
|
||||||
|
|
||||||
|
**Step 2 — Insert the spare**
|
||||||
|
Click one of the **arrows** around the edge of the board. Each arrow marks a valid insertion slot — every non-fixed row and column has an arrow on each end. The spare slides in, the whole row or column shifts one step, and the tile on the far end pops out as the new spare.
|
||||||
|
|
||||||
|
**One rule:** you cannot push in the exact spot that was pushed last turn — you can't just reverse what the previous player did. Don't even think about it.
|
||||||
|
|
||||||
|
**Step 3 — Move your pawn**
|
||||||
|
Now you move. You can travel as far as you like through **connected corridors** — as long as every tile between you and your destination is open on the shared sides. Reachable cells light up so you can see your options. Click a cell to move there.
|
||||||
|
|
||||||
|
If you can't reach your target this turn, no shame. Move to a better position. The maze will shift again next turn and new paths open.
|
||||||
|
|
||||||
|
## Collecting Treasures
|
||||||
|
|
||||||
|
Your treasure cards show what you need to collect and in what order. When you step onto a tile that holds your **current target**, you collect it — the tile's treasure disappears and your card flips to the next one.
|
||||||
|
|
||||||
|
Your **home corner** is marked on the board. Once you've collected every last treasure on your stack, race back home and step onto it. First one home with a complete collection wins.
|
||||||
|
|
||||||
|
Sounds simple. Is not simple. The maze will not cooperate.
|
||||||
|
|
||||||
|
## Strategy From a Minotaur Who Has Thought About This Extensively
|
||||||
|
|
||||||
|
**1. Rotate before you commit.**
|
||||||
|
A spare tile pointed the wrong way is a wasted push. Always spin it first. An I-tile turned sideways to connect two previously dead-end corridors can open an entire highway across the board. I have seen humans forget to rotate and it causes me physical pain.
|
||||||
|
|
||||||
|
**2. Your insertion affects everyone.**
|
||||||
|
When you shove a row, you move your opponents too. Sometimes a good push sends a rival *away* from their treasure. Sometimes it closes a path they were about to take. You are not just moving tiles — you are conducting the labyrinth itself.
|
||||||
|
|
||||||
|
**3. Think about what slides off.**
|
||||||
|
The tile that pops out becomes the next spare. You sometimes want to maneuver a *specific* tile back into play somewhere useful. This is advanced strategy and I respect it deeply.
|
||||||
|
|
||||||
|
**4. You can't reach everywhere every turn.**
|
||||||
|
Don't panic when your target is buried in an unreachable corner. Push a row to bring yourself closer. Push a column to move the treasure tile toward a connected section. The maze changes every turn — your target will come into reach.
|
||||||
|
|
||||||
|
**5. Block late-game leaders.**
|
||||||
|
If someone is one treasure away from winning, look at where that treasure sits. Can you push the row it's on? Can you wall off the corridor leading to it? A well-timed insertion can delay a near-winner for several turns. Delightful chaos. I thrive on this.
|
||||||
|
|
||||||
|
## The AI Opponents
|
||||||
|
|
||||||
|
Skill 1–2 opponents push tiles mostly at random and stumble around. They may accidentally help you. Do not trust the help, but enjoy it.
|
||||||
|
|
||||||
|
Skill 3–4 opponents navigate purposefully toward their targets and sometimes push to block you. Respectable.
|
||||||
|
|
||||||
|
Skill 5 opponents plan their insertions, track your targets, and will actively work to cut off your path right before you reach something important. These ones I respect. They think like Minotaurs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The shifting labyrinth is one of the greatest games ever devised by anyone who is not a Minotaur. For a Minotaur, it is obviously second to the actual experience of *living* in a labyrinth, but this is an excellent simulation.
|
||||||
|
|
||||||
|
May your corridors always connect. May your spare tile always fit. May your home corner be reachable.
|
||||||
|
|
||||||
|
*Stamps both hooves with enthusiasm*
|
||||||
|
|
||||||
|
Good luck. 🐂
|
||||||
|
|
@ -1,50 +1,54 @@
|
||||||
# Welcome to the Mahjong Table!
|
# Welcome to Mahjong
|
||||||
|
|
||||||
*By Auntie Mei — teahouse owner, undefeated since 1987, owner of the loudest tile-shuffle in Kowloon*
|
*By Jeff — sloth, current location: a branch, nothing going on, no plans, plenty of time*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Ahhh, you came! Sit, sit. Pour yourself some tea — the pu-erh, not the cheap stuff, you are my guest. You hear that sound? *Clack clack clack.* That is the sound of one hundred and forty-four tiles being shuffled, and it is the most beautiful sound in the world. My grandmother taught me this game, her grandmother taught her, and now Auntie Mei teaches you. Pay attention. There WILL be a quiz, and the quiz is me taking all your points.
|
Hey. It's Jeff. I'm a sloth. I've been hanging here thinking about teaching you mahjong, and honestly... I've got nowhere to be. My whole afternoon is open. My whole week, actually. So let's do this. Slowly. We'll get there.
|
||||||
|
|
||||||
|
Take a breath. Good. Take another one. Perfect. We're in no rush here.
|
||||||
|
|
||||||
## What's the Goal?
|
## What's the Goal?
|
||||||
|
|
||||||
You and three opponents each build a hand of tiles. The first player to complete a winning hand — **four sets and a pair** — shouts *"Sik wu!"* (or clicks the big Mahjong button, very dignified) and collects points from the others.
|
You and three other players are each building a hand of tiles. The first one to finish — **four sets and a pair** — wins the round. You click the big Mahjong button or shout "Sik wu!" which means... something. I'd look it up but I'm currently hanging upside down and my phone is very far away.
|
||||||
|
|
||||||
A **set** is one of:
|
A **set** is one of:
|
||||||
- **Chow** — three tiles in a row of the same suit, like 4-5-6 of Circles
|
- **Chow** — three tiles in a row, same suit, like 4-5-6 of Circles
|
||||||
- **Pung** — three identical tiles
|
- **Pung** — three identical tiles
|
||||||
- **Kong** — four identical tiles (a special pung with a bonus draw)
|
- **Kong** — four identical tiles (like a pung but with a bonus draw, very exciting, or so I've heard)
|
||||||
|
|
||||||
A **pair** is two identical tiles. Four sets plus the pair makes 14 tiles. That's it. That is the whole shape of the game. Everything else is seasoning.
|
A **pair** is two matching tiles. Four sets plus the pair = 14 tiles. That's the whole shape of the game. Everything else is... seasoning. I like that word. Seasoning. It implies something good is already happening.
|
||||||
|
|
||||||
One session is **one full round**: every player gets to be the dealer (East) at least once. When the fourth dealer finally loses the deal, we count the points, and whoever has the most buys the dim sum. I mean, wins.
|
One full session is **one round**: every player gets to be the dealer at least once. When the fourth dealer finally loses the deal, you count points, and whoever has the most wins. That's it. That's the finish line. We'll get there together.
|
||||||
|
|
||||||
## The Tiles
|
## The Tiles
|
||||||
|
|
||||||
Three suits run 1 to 9: **Bamboo**, **Circles**, and **Characters**. Four of each tile. Then the honor tiles: the four **Winds** (East, South, West, North) and the three **Dragons** (Red, Green, White — the white one is the blank tile; my nephew thought it was broken; he is no longer invited).
|
Three suits run 1 to 9: **Bamboo**, **Circles**, and **Characters**. Four copies of each tile in the set. Then you've got the honor tiles: four **Winds** (East, South, West, North) and three **Dragons** (Red, Green, and White — the white one is blank, don't panic, it's intentional).
|
||||||
|
|
||||||
There are also eight **bonus tiles** — four Flowers and four Seasons. They are not part of your hand. When you draw one, it is set aside face up and you draw a replacement tile. Free decoration, free faan. We like the flowers very much.
|
Also: eight **bonus tiles**, four Flowers and four Seasons. These aren't part of your hand. When you draw one, you set it aside and draw a replacement. Free points. I love anything that gives you something for almost nothing. As a sloth, this resonates deeply with me.
|
||||||
|
|
||||||
## How a Turn Works
|
## How a Turn Works
|
||||||
|
|
||||||
On your turn you **draw one tile** from the wall, then **discard one tile** face up in front of you. That's the whole rhythm: draw, discard, draw, discard, *clack clack*. Your hand stays at 13 tiles between turns.
|
On your turn you **draw one tile** from the wall. Then you **discard one tile**. Draw. Discard. Draw. Discard. The rhythm of it is genuinely soothing. Your hand stays at 13 tiles between turns.
|
||||||
|
|
||||||
But here is where mahjong becomes MAHJONG — other players' discards are not garbage. They are opportunity:
|
Now here's the thing — and I want you to really absorb this, take your time — **other players' discards can be claimed**. Someone else's garbage might be your treasure. Mahjong is basically a very slow treasure hunt, which I think suits me perfectly.
|
||||||
|
|
||||||
- **Pung!** — anyone holding two of the discarded tile may claim it for a pung. Play jumps to them.
|
- **Pung!** — if you have two of the discarded tile, you can claim it for a pung. Play jumps to you.
|
||||||
- **Kong!** — anyone holding three of it may claim it for a kong, and takes a replacement tile from the back of the wall.
|
- **Kong!** — if you have three of it, claim it for a kong and draw a replacement tile from the back of the wall.
|
||||||
- **Chow** — only the player to the discarder's **left** (that's the next player) may claim it to complete a run.
|
- **Chow** — only the player to the discarder's **left** (next player in turn) can claim a discard to complete a run.
|
||||||
- **Mahjong!** — anyone may claim any discard that completes their winning hand. This beats every other claim.
|
- **Mahjong!** — anyone can claim any discard that completes their winning hand. This beats all other claims. This is the big moment. I have yelled "Mahjong" at my branch more than once. It felt great.
|
||||||
|
|
||||||
Claimed sets are placed face up beside your hand. They still count toward your four sets, but everyone can see what you're collecting — and a hand with no claimed sets earns a bonus for staying **concealed**. Choices, choices.
|
Claimed sets go face-up beside your hand. They still count toward your four sets, but now everyone can see your plan. A hand with no claimed sets earns a **concealed** bonus. So there's a tradeoff. I think about it every time... for a long time... while hanging here.
|
||||||
|
|
||||||
If the wall runs out before anyone wins, the hand is a draw — nobody pays, and the same dealer redeals. The dealer also keeps the deal whenever the dealer wins. A hot dealer can run the table for a long time. (It is usually me.)
|
If the wall runs dry before anyone wins, it's a draw. No points change hands, the dealer redeals, and everybody tries again. The dealer also keeps the deal when they win, which means a good dealer can just... keep going. I relate to this. I find something I like and I just stay there.
|
||||||
|
|
||||||
## You Need Faan to Win
|
## You Need Faan to Win
|
||||||
|
|
||||||
You cannot win with just *any* complete hand — a hand worth zero is a "chicken hand," and Auntie Mei's table does not pay chickens. Your hand must be worth at least **1 faan**. Press the **Hands** button in the corner any time to slide out the full list of scoring hands. Keep it open while you learn — that is what it is for.
|
You can't win with just *any* complete hand. A hand worth zero points is called a "chicken hand," and chicken hands don't pay out. Your hand needs to be worth at least **1 faan** to win.
|
||||||
|
|
||||||
The big ones:
|
Hit the **Hands** button in the corner any time to see the full scoring list. Keep it open while you're learning. That's what it's there for.
|
||||||
|
|
||||||
|
Here are some of the highlights:
|
||||||
|
|
||||||
| Hand | Faan |
|
| Hand | Faan |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -57,35 +61,39 @@ The big ones:
|
||||||
| Great Dragons | 8 |
|
| Great Dragons | 8 |
|
||||||
| Great Winds / All Honors / Thirteen Orphans | 13 (limit) |
|
| Great Winds / All Honors / Thirteen Orphans | 13 (limit) |
|
||||||
|
|
||||||
And the seasoning faan: each Dragon pung is 1, a pung of your own seat wind or of East (the round wind) is 1, winning by self-draw is 1, a fully concealed hand is 1, each Flower or Season matching your seat is 1, and finishing with *no* bonus tiles at all is 1. They stack — that is how a humble hand becomes an expensive one.
|
Then there's the bonus faan that stacks on top: Dragon pungs are 1 each, punging your own seat wind or East (the round wind) is 1, winning by self-draw is 1, a fully concealed hand is 1, each Flower or Season matching your seat is 1, and having *no* bonus tiles at all is also 1. They all add up. A simple hand can quietly become a very expensive hand if you let it sit and ripen. Like me. Quietly. On my branch.
|
||||||
|
|
||||||
## Getting Paid
|
## Getting Paid
|
||||||
|
|
||||||
Faan converts to base points on a doubling ladder (1 faan = 2, 2 = 4, 3 = 8... capped at the 13-faan limit).
|
Faan converts to base points on a doubling scale: 1 faan = 2 points, 2 = 4, 3 = 8... capped at the 13-faan limit. It grows fast. I like that. I'm not doing anything, but the points are compounding.
|
||||||
|
|
||||||
- **Win by discard** — the player who threw the tile pays you **double** the base, alone. They will apologize. Do not accept the apology, accept the points.
|
- **Win by discard** — the player who threw the winning tile pays you **double** the base. Alone. By themselves. It stings. Don't be that player.
|
||||||
- **Win by self-draw** — *everyone* pays you the base. This is why self-draw is the sweetest win.
|
- **Win by self-draw** — *everyone* pays you the base. Extremely satisfying. Maximum faan, maximum income, minimum effort. My favorite kind of win.
|
||||||
|
|
||||||
## Auntie Mei's Teahouse Wisdom
|
## Jeff's Extremely Relaxed Strategy Notes
|
||||||
|
|
||||||
**1. Decide your hand early.** Look at your 13 tiles. Are they leaning toward one suit? Toward pungs? A plan worth 3 faan beats a fast plan worth nothing — remember, zero faan cannot even win.
|
**1. Pick a direction early.** Look at your first 13 tiles. Where are they pointing? One suit? Lots of pairs? A 3-faan plan beats a 0-faan plan, and 0-faan plans don't even win. Pick something and slowly work toward it.
|
||||||
|
|
||||||
**2. Do not claim everything.** Every pung you claim shows the table your plan and kills your concealed bonus. Claim when it truly advances you. My late husband claimed every pung he saw. *Every one.* He never beat me once.
|
**2. Don't claim everything.** Every claimed set tells the table what you're doing. You lose the concealed bonus and you lose the element of surprise. Claim when it *really* moves you forward. Otherwise... let it go. Let the discard sit there. Be cool about it. Be like me.
|
||||||
|
|
||||||
**3. Watch the discards.** If a player has three exposed sets, they are one tile from glory. Throwing a fresh, never-seen tile at them is how you end up paying double. When in doubt, discard a tile someone already threw — it passed through the table safely once.
|
**3. Watch what gets thrown.** If someone has three face-up sets, they need one tile to win. Throwing them a tile nobody's seen yet is how you end up paying double. When unsure, discard something another player already threw — it made it around the table safely once.
|
||||||
|
|
||||||
**4. Honor tiles first, usually.** A lone West wind when you are not West and it is not the round wind? It earns nothing and chows with nothing. Out it goes, early, while it is still safe to throw.
|
**4. Lone honor tiles go early.** A single West wind that isn't your seat and isn't the round wind? It does nothing. It combines with nothing. It earns nothing. Throw it early, while it's still safe to throw. Let it go. Move on. (This is also good life advice, but I digress.)
|
||||||
|
|
||||||
**5. Respect the flowers.** Your own flower or season is a free faan. All four flowers is free faan *plus* the table's envy, which is worth even more.
|
**5. Respect the flowers.** If you draw your own Flower or Season, that's a free faan just sitting there. Collect all four of one type and you get even more. Flowers are basically little gifts. I love little gifts.
|
||||||
|
|
||||||
**6. The dealer is dangerous.** The dealer keeps the deal by winning. If the dealer is hot, sometimes the wisest hand is a fast, cheap one that simply ends their turn. Throw water on the fire.
|
**6. Watch the dealer.** The dealer keeps the deal by winning. A dealer on a streak is a problem. Sometimes the smartest move is to finish a fast, cheap hand just to end the round and rotate the deal. Put out the fire. Move forward. Even slowly forward is forward.
|
||||||
|
|
||||||
## The AI Opponents
|
## The AI Opponents
|
||||||
|
|
||||||
The computer players come in skill levels 1 to 5. The low ones claim every tile like my husband, rest his soul. The high ones count what they've seen, fold their plans around your discards, and stop throwing you winning tiles right when you need them. Treat a skill-5 opponent like a Kowloon grandmother: politely, and with great fear.
|
There are five skill levels. The low ones claim tiles carelessly and telegraph everything. The high ones pay attention, adjust their plans, and know exactly which tile you need — and they will not throw it. A skill-5 opponent has a lot of patience. Almost as much as me.
|
||||||
|
|
||||||
|
Almost.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
That's the game. Draw, discard, claim wisely, count your faan, and never — NEVER — throw the tile that wins it for someone else. More tea? Good. The tiles are shuffled. *Clack clack.*
|
Okay. That's mahjong. Draw a tile, discard a tile, claim wisely, stack your faan, and never throw the one tile that hands someone else a win. It's a slow, deep, beautiful game, and I feel like I was made for it.
|
||||||
|
|
||||||
Sik wu! 🀄🍵
|
I'll be right here on this branch if you have questions. I have nothing else going on. Like, genuinely nothing. All good. Take your time.
|
||||||
|
|
||||||
|
Good luck. 🦥
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Howdy, Welcome to Mexican Train!
|
||||||
|
|
||||||
|
*By Jerry — corn farmer, tractor enthusiast, proud owner of the finest Holstein cow in three counties*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Well, pull up a chair and set a spell! Name's Jerry. Got my chores done early today — Bessie was cooperative as ever, gave a full bucket by sunrise — so I figured I'd sit down and teach you the best domino game this side of the Mississippi. Grab yourself a glass of sweet tea and let's get to it!
|
||||||
|
|
||||||
|
I learned Mexican Train from my neighbor Earl at the county fair about twelve years back. We were waiting out a thunderstorm in the equipment barn and he had a set in his jacket pocket. Been playing ever since. Even taught Bessie to watch, though she mostly just chews her cud and judges my tile choices.
|
||||||
|
|
||||||
|
## What's the Big Picture?
|
||||||
|
|
||||||
|
Mexican Train is a domino game where everybody's building trains of tiles out from a central hub. **The player with the LOWEST score at the end wins** — and that right there is the key. You're not trying to rack up points. You're trying to get rid of every last tile in your hand before the round ends, because whatever pips are left in your hand get added to your score. When any player hits 100 points, the match is over and whoever's got the least points wins.
|
||||||
|
|
||||||
|
Think of it like getting your field work done before the rain comes. You want to get those tiles played out quick, or you're stuck holding the bag!
|
||||||
|
|
||||||
|
## The Hub — That's Your Engine Double
|
||||||
|
|
||||||
|
Each round starts with a special tile called the **engine double** placed right in the center. Round one uses the double-6, then double-5, all the way down to double-0. That center tile is the hub everything branches out from.
|
||||||
|
|
||||||
|
Whoever plays first in a round kicks things off. The round winner from last round goes first in the next one. Fair and neighborly!
|
||||||
|
|
||||||
|
## Trains — Your Track Out of the Station
|
||||||
|
|
||||||
|
Radiating out from that hub are the **trains** — one personal train for each player, plus one shared **Mexican Train** that anybody can play on.
|
||||||
|
|
||||||
|
- **Your train**: All yours. You can always play here.
|
||||||
|
- **The Mexican Train**: Belongs to everybody. Open season, any time.
|
||||||
|
- **Other players' trains**: Normally off-limits — BUT if a player couldn't play on their turn, their train gets **marked** (you'll see a marker on it). A marked train is open for anyone to play on. It's like a neighbor who left their gate unlatched — anybody can wander in!
|
||||||
|
|
||||||
|
When a train gets marked, it stays marked until that player successfully plays on their own train. Once they do, the marker comes off and it's private again.
|
||||||
|
|
||||||
|
## How a Turn Works
|
||||||
|
|
||||||
|
### Step 1: Can You Play?
|
||||||
|
|
||||||
|
Look at your hand. Each tile has two pip values. You need a tile whose end matches the **open end** of a train you're allowed to play on.
|
||||||
|
|
||||||
|
- The very first tile on any train must match the hub value (e.g., a 6 on either end to start round 1).
|
||||||
|
- After that, each new tile must match whatever pip count is showing at the end of that train.
|
||||||
|
|
||||||
|
If you've got a match, pick your tile and pick your train!
|
||||||
|
|
||||||
|
### Step 2: No Match? Draw from the Boneyard
|
||||||
|
|
||||||
|
Can't play? That happens. Head to the **boneyard** — the pile of leftover tiles — and draw one. Maybe you'll get lucky and draw something you can use. Bessie always says the boneyard is like the bargain bin at the farm supply store: sometimes you find gold, sometimes you find nothing useful.
|
||||||
|
|
||||||
|
If that drawn tile plays, go right ahead and play it. If not, well...
|
||||||
|
|
||||||
|
### Step 3: Still Can't Play? Pass Your Turn
|
||||||
|
|
||||||
|
If you draw and still can't play, you've got to **pass**. And here's the painful part: when you pass, **your personal train gets marked**. That means everyone else can play on your train until you get back on it yourself.
|
||||||
|
|
||||||
|
It's like borrowing Earl's tractor — you don't want folks poking around your train, so get back on it as soon as you can!
|
||||||
|
|
||||||
|
## Doubles — The House Rule That Keeps Things Spicy
|
||||||
|
|
||||||
|
Oh, doubles. Here's where things get real interesting. When someone plays a **double tile** (same number on both ends, like 4-4 or 2-2), that double **must be covered immediately**.
|
||||||
|
|
||||||
|
The player who laid the double keeps their turn and gets to try covering it first. If they can, great — play continues normally. If they *can't* cover it, then they draw from the boneyard. If they still can't cover it, the next player *must* cover it before doing anything else.
|
||||||
|
|
||||||
|
Until that double is covered, everybody's locked into covering it. No playing elsewhere. No shenanigans. It's like a fence post that's fallen over — you fix it before you do anything else, or everything goes sideways.
|
||||||
|
|
||||||
|
## How a Round Ends
|
||||||
|
|
||||||
|
The round ends when:
|
||||||
|
- **Someone plays their last tile** — that player wins the round with 0 points, and everyone else counts up the pips left in their hand.
|
||||||
|
- **The round gets blocked** — if the boneyard's empty and nobody can play, the round ends. Everyone counts their pips and the player with the fewest pips wins that round.
|
||||||
|
|
||||||
|
Those pip totals get added to your running score. Low score stays low, and that's what you want!
|
||||||
|
|
||||||
|
## Scoring Quick Reference
|
||||||
|
|
||||||
|
| Situation | Your Score |
|
||||||
|
|---|---|
|
||||||
|
| Emptied your hand | 0 points this round |
|
||||||
|
| Round ends with tiles left | Sum of all pips on remaining tiles |
|
||||||
|
| Match over (someone hits 100) | Lowest total score wins! |
|
||||||
|
|
||||||
|
## Jerry's Farming Wisdom (Tips)
|
||||||
|
|
||||||
|
**1. Play your doubles early.** Doubles are double-dangerous to hold — they count for more pips if you get stuck with them. Play them when you can, not when you're forced to. Get those troublesome tiles out of your hand like you'd fix a wobbly fence post: sooner is better.
|
||||||
|
|
||||||
|
**2. Keep your personal train clean.** Don't let your train stay marked any longer than needed. An open train is like leaving your barn door ajar — folks'll wander through it whether you like it or not.
|
||||||
|
|
||||||
|
**3. Watch the Mexican Train.** It's the social train, the one everybody shares. If the end value matches a lot of tiles in your hand, the Mexican Train can be your best friend for getting rid of tiles fast.
|
||||||
|
|
||||||
|
**4. Count what your opponents are playing.** When someone draws from the boneyard, they're hurting. When someone passes, they're in trouble. Keep an eye on who's holding a lot of tiles — they're likely to score big if you can end the round quickly.
|
||||||
|
|
||||||
|
**5. Plan ahead for the end of a train.** Every tile you play changes what end value that train shows. Before you play, think about what you've got left in your hand that can follow on that train. You don't want to paint yourself into a corner with no tiles that match what you just put down.
|
||||||
|
|
||||||
|
**6. Don't fear the Mexican Train.** New players sometimes forget about the Mexican Train and try to play everything on their personal train. The Mexican Train is there for a reason — use it! When your personal train is tied up and the Mexican Train matches, play there.
|
||||||
|
|
||||||
|
## The Final Stretch
|
||||||
|
|
||||||
|
When any player's cumulative score hits 100, the match is over. Whoever has the **lowest total score** wins. So even if you've had a rough round or two, you can still come back if you play smart and keep those pip totals down.
|
||||||
|
|
||||||
|
It's like a long growing season — one bad week doesn't ruin the harvest if you tend to things right the rest of the time!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Well, that's everything you need to know! Mexican Train is a wonderful game — simple enough to learn over sweet tea, but deep enough to keep you thinking all night. Even Bessie gets excited when she hears the tiles rattling around.
|
||||||
|
|
||||||
|
Now get out there, play your tiles, keep that score low, and remember: slow and steady wins the farm. Good luck, neighbor!
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS,
|
SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS,
|
||||||
} from './MonopolyData.js';
|
} from './MonopolyData.js';
|
||||||
import {
|
import {
|
||||||
ownsGroup, canBuildHouse, canBuildHotel, netWorth,
|
ownsGroup, canBuildHouse, canBuildHotel, netWorth, isTradeable, validateTrade,
|
||||||
} from './MonopolyLogic.js';
|
} from './MonopolyLogic.js';
|
||||||
|
|
||||||
const PROFILES = {
|
const PROFILES = {
|
||||||
|
|
@ -97,7 +97,10 @@ function boardAfter(state, offer) {
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evaluateTrade(state, aiSeat, offer, skill) {
|
// Raw scoring of an offer from `aiSeat`'s point of view. Returns the numeric score
|
||||||
|
// plus the flags the caller needs to phrase a verdict or search for a better offer.
|
||||||
|
// `noiseless` skips the random jitter so generation loops are stable.
|
||||||
|
export function scoreTrade(state, aiSeat, offer, skill, noiseless = false) {
|
||||||
const prof = PROFILES[skill] ?? PROFILES[3];
|
const prof = PROFILES[skill] ?? PROFILES[3];
|
||||||
const giveProps = offer.giveProps ?? []; // AI receives these
|
const giveProps = offer.giveProps ?? []; // AI receives these
|
||||||
const getProps = offer.getProps ?? []; // AI gives these up
|
const getProps = offer.getProps ?? []; // AI gives these up
|
||||||
|
|
@ -129,35 +132,130 @@ export function evaluateTrade(state, aiSeat, offer, skill) {
|
||||||
if (fBefore >= 1.0 && fAfter < 1.0) veto = true; // breaking up a completed monopoly
|
if (fBefore >= 1.0 && fAfter < 1.0) veto = true; // breaking up a completed monopoly
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never break up a completed set unless desperate enough to need it to stay in the game.
|
|
||||||
if (veto && state.players[aiSeat].cash >= 0) {
|
|
||||||
return { accept: false, reason: 'I won’t break up a monopoly I’ve completed.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// A very generous cash offer can tip a borderline deal.
|
// A very generous cash offer can tip a borderline deal.
|
||||||
if (!veto && cashDelta - loseVal >= TRADE.cashTemptMult * Math.max(1, loseVal)) {
|
if (!veto && cashDelta - loseVal >= TRADE.cashTemptMult * Math.max(1, loseVal)) {
|
||||||
score += TRADE.cashTempt;
|
score += TRADE.cashTempt;
|
||||||
}
|
}
|
||||||
|
|
||||||
score += noise(prof.noise * 2);
|
if (!noiseless) score += noise(prof.noise * 2);
|
||||||
|
|
||||||
|
return { score, veto, completedSet, gainedControl, cashDelta, assetDelta, loseVal };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateTrade(state, aiSeat, offer, skill) {
|
||||||
|
const sc = scoreTrade(state, aiSeat, offer, skill);
|
||||||
|
const giveProps = offer.giveProps ?? [];
|
||||||
|
const getProps = offer.getProps ?? [];
|
||||||
|
|
||||||
|
// Never break up a completed set unless desperate enough to need it to stay in the game.
|
||||||
|
if (sc.veto && state.players[aiSeat].cash >= 0) {
|
||||||
|
return { accept: false, reason: 'I won’t break up a monopoly I’ve completed.' };
|
||||||
|
}
|
||||||
|
|
||||||
const threshold = TRADE.threshold[skill] ?? 40;
|
const threshold = TRADE.threshold[skill] ?? 40;
|
||||||
const accept = score >= threshold;
|
const accept = sc.score >= threshold;
|
||||||
|
|
||||||
let reason;
|
let reason;
|
||||||
if (accept) {
|
if (accept) {
|
||||||
if (completedSet) reason = 'That completes my set — gladly.';
|
if (sc.completedSet) reason = 'That completes my set — gladly.';
|
||||||
else if (gainedControl) reason = 'That gives me control of the set — deal.';
|
else if (sc.gainedControl) reason = 'That gives me control of the set — deal.';
|
||||||
else if (giveProps.length === 0 && getProps.length === 0) reason = 'The cash makes it worth it — deal.';
|
else if (giveProps.length === 0 && getProps.length === 0) reason = 'The cash makes it worth it — deal.';
|
||||||
else if (cashDelta > 0 && cashDelta >= -assetDelta) reason = 'The money tips it in your favor — deal.';
|
else if (sc.cashDelta > 0 && sc.cashDelta >= -sc.assetDelta) reason = 'The money tips it in your favor — deal.';
|
||||||
else reason = 'That works for me — deal.';
|
else reason = 'That works for me — deal.';
|
||||||
} else {
|
} else {
|
||||||
if (loseVal > 0 && cashDelta <= 0) reason = 'Not nearly enough for what you want.';
|
if (sc.loseVal > 0 && sc.cashDelta <= 0) reason = 'Not nearly enough for what you want.';
|
||||||
else reason = 'I’ll pass — that doesn’t move me.';
|
else reason = 'I’ll pass — that doesn’t move me.';
|
||||||
}
|
}
|
||||||
return { accept, reason };
|
return { accept, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Trade generation ─────────────────────────────────────────────────────────────
|
||||||
|
// Build a trade `aiSeat` would propose to `targetSeat`. Offer is from the AI's
|
||||||
|
// perspective: { fromSeat:aiSeat, toSeat:targetSeat, giveProps, getProps, giveCash, getCash }.
|
||||||
|
// Returns a validated, mutually-beneficial (fair / slightly AI-favored) offer, or null.
|
||||||
|
// opts: { targetHuman:bool, targetSkill:number }
|
||||||
|
const CASH_FACTOR = [null, 1.0, 0.95, 0.9, 0.82, 0.75]; // by skill — low skill overpays to entice
|
||||||
|
|
||||||
|
export function buildAiTradeOffer(state, aiSeat, targetSeat, skill, opts = {}) {
|
||||||
|
const ai = state.players[aiSeat];
|
||||||
|
const target = state.players[targetSeat];
|
||||||
|
if (!ai || !target) return null;
|
||||||
|
if (!ai.active || ai.bankrupt || !target.active || target.bankrupt) return null;
|
||||||
|
|
||||||
|
const board = state.board;
|
||||||
|
const valueOf = idx => SPACES[idx].price ?? 0;
|
||||||
|
|
||||||
|
// 1. Rank the target's tradeable properties by how much acquiring one helps the AI.
|
||||||
|
const wanted = [];
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
if (board[idx]?.owner !== targetSeat) continue;
|
||||||
|
if (!isTradeable(state, idx)) continue;
|
||||||
|
const key = groupKey(idx);
|
||||||
|
const n = groupIndices(key).length || 1;
|
||||||
|
const fBefore = fractionFor(board, aiSeat, key);
|
||||||
|
const fAfter = fBefore + 1 / n;
|
||||||
|
let want = 0;
|
||||||
|
if (fBefore < 1.0 && fAfter >= 1.0) want = 3; // completes a set
|
||||||
|
else if (fBefore <= 0.5 && fAfter > 0.5) want = 2; // takes control
|
||||||
|
else if (fBefore > 0) want = 1; // builds toward a set we already touch
|
||||||
|
if (want === 0) continue;
|
||||||
|
wanted.push({ idx, want, value: valueOf(idx) });
|
||||||
|
}
|
||||||
|
if (!wanted.length) return null;
|
||||||
|
wanted.sort((a, b) => b.want - a.want || b.value - a.value);
|
||||||
|
const getProps = [wanted[0].idx];
|
||||||
|
const wantPrice = valueOf(wanted[0].idx);
|
||||||
|
|
||||||
|
// 2. Properties the AI can give: tradeable, not from a set the AI controls (>50%).
|
||||||
|
const helpful = [];
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
if (board[idx]?.owner !== aiSeat) continue;
|
||||||
|
if (!isTradeable(state, idx)) continue;
|
||||||
|
const key = groupKey(idx);
|
||||||
|
if (fractionFor(board, aiSeat, key) > 0.5) continue; // keep our strong sets intact
|
||||||
|
if (fractionFor(board, targetSeat, key) >= 0.5) helpful.push({ idx, value: valueOf(idx) });
|
||||||
|
}
|
||||||
|
helpful.sort((a, b) => a.value - b.value);
|
||||||
|
|
||||||
|
// 3. Search cash-only first, then sweetened with one helpful property; sweeten cash
|
||||||
|
// until the target is satisfied or the deal stops favoring the AI.
|
||||||
|
const cashFactor = CASH_FACTOR[skill] ?? 0.9;
|
||||||
|
const targetSkill = opts.targetSkill ?? 3;
|
||||||
|
const targetThresh = TRADE.threshold[targetSkill] ?? 40;
|
||||||
|
const acceptFloor = opts.targetHuman ? (TRADE.threshold[3] ?? 40) - 60 : targetThresh;
|
||||||
|
|
||||||
|
const bundles = [[]];
|
||||||
|
if (helpful.length) bundles.push([helpful[0].idx]);
|
||||||
|
|
||||||
|
for (const giveProps of bundles) {
|
||||||
|
const bundleVal = giveProps.reduce((a, i) => a + valueOf(i), 0);
|
||||||
|
let giveCash = Math.max(0, Math.round(wantPrice * cashFactor - bundleVal));
|
||||||
|
for (let step = 0; step < 60; step++) {
|
||||||
|
giveCash = Math.min(giveCash, ai.cash);
|
||||||
|
const offer = {
|
||||||
|
fromSeat: aiSeat, toSeat: targetSeat,
|
||||||
|
giveProps: [...giveProps], getProps: [...getProps], giveCash, getCash: 0,
|
||||||
|
};
|
||||||
|
if (validateTrade(state, offer).ok) {
|
||||||
|
// AI's own benefit, from its receiving perspective.
|
||||||
|
const aiView = {
|
||||||
|
fromSeat: targetSeat, toSeat: aiSeat,
|
||||||
|
giveProps: getProps, getProps: giveProps, giveCash: 0, getCash: giveCash,
|
||||||
|
};
|
||||||
|
const aiScore = scoreTrade(state, aiSeat, aiView, skill, true);
|
||||||
|
const tgtScore = scoreTrade(state, targetSeat, offer, targetSkill, true);
|
||||||
|
if (!aiScore.veto && aiScore.score > 0 && !tgtScore.veto && tgtScore.score >= acceptFloor) {
|
||||||
|
return offer; // mutually agreeable
|
||||||
|
}
|
||||||
|
if (aiScore.score <= 0) break; // giving more cash only worsens the AI — abandon bundle
|
||||||
|
}
|
||||||
|
if (giveCash >= ai.cash) break;
|
||||||
|
giveCash += 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Build decisions ────────────────────────────────────────────────────────────
|
// ── Build decisions ────────────────────────────────────────────────────────────
|
||||||
// Returns { action: 'house'|'hotel', spaceIdx } or null
|
// Returns { action: 'house'|'hotel', spaceIdx } or null
|
||||||
export function chooseBuild(state, seat, skill) {
|
export function chooseBuild(state, seat, skill) {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
canBuildHouse, canBuildHotel, ownsGroup, netWorth,
|
canBuildHouse, canBuildHotel, ownsGroup, netWorth,
|
||||||
isTradeable, validateTrade, applyTrade,
|
isTradeable, validateTrade, applyTrade,
|
||||||
} from './MonopolyLogic.js';
|
} from './MonopolyLogic.js';
|
||||||
import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay, evaluateTrade } from './MonopolyAI.js';
|
import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay, evaluateTrade, buildAiTradeOffer } from './MonopolyAI.js';
|
||||||
|
|
||||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||||
const BL = 30; // board left
|
const BL = 30; // board left
|
||||||
|
|
@ -93,6 +93,14 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
this.tradeDragGhost = null;
|
this.tradeDragGhost = null;
|
||||||
this._dragHintTween = null; // pulses draggable cards while offer is empty
|
this._dragHintTween = null; // pulses draggable cards while offer is empty
|
||||||
this._dragHintCards = [];
|
this._dragHintCards = [];
|
||||||
|
// AI-initiated trades: per-seat cooldown (in that seat's own turns) for proposing
|
||||||
|
// to the human, so each AI bothers the player at most once every other of its turns.
|
||||||
|
this.humanTradeCooldown = {};
|
||||||
|
// Counter-offer state (when the human counters an AI's incoming proposal)
|
||||||
|
this.tradeCounterMode = false;
|
||||||
|
this.tradeCounterFrom = null;
|
||||||
|
this._tradeResolve = null; // resolves the pending AI-proposal promise
|
||||||
|
this.tradeSummaryOpen = false; // incoming-proposal / AI-AI flash popup is showing
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
|
@ -493,7 +501,7 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel();
|
if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel();
|
||||||
if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons();
|
if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons();
|
||||||
// DOM video portraits always render above canvas — hide them during any overlay
|
// DOM video portraits always render above canvas — hide them during any overlay
|
||||||
if (this.gs.pendingCard || this.modalActive || this.tradeMenuOpen) this.hidePortraits();
|
if (this.gs.pendingCard || this.modalActive || this.tradeMenuOpen || this.tradeSummaryOpen) this.hidePortraits();
|
||||||
else this.showPortraits();
|
else this.showPortraits();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1356,16 +1364,237 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
: 0x1565C0;
|
: 0x1565C0;
|
||||||
}
|
}
|
||||||
|
|
||||||
showTradeModal() {
|
// ── AI-initiated trades ──────────────────────────────────────────────────────
|
||||||
if (this.tradeMenuOpen || this.busy) return;
|
// Called during an AI seat's endturn. Proposes to the human (rate-limited to once
|
||||||
if (this.gs.current !== this.humanSeat || this.gs.phase !== 'endturn') return;
|
// every other of this AI's own turns) then completes up to 5 trades with other AIs.
|
||||||
|
async aiAttemptTrades(seat) {
|
||||||
|
const gs = this.gs;
|
||||||
|
if (!gs || gs.phase !== 'endturn' || gs.current !== seat) return;
|
||||||
|
const me = gs.players[seat];
|
||||||
|
if (!me || !me.active || me.bankrupt) return;
|
||||||
|
|
||||||
|
// Tick this AI's human-proposal cooldown down once per its own turn.
|
||||||
|
if (this.humanTradeCooldown[seat] > 0) this.humanTradeCooldown[seat]--;
|
||||||
|
|
||||||
|
const skill = this.skillBySeat[seat] ?? 3;
|
||||||
|
|
||||||
|
// 1. Propose to the human, if off cooldown.
|
||||||
|
const human = this.gs.players[this.humanSeat];
|
||||||
|
if (human && human.active && !human.bankrupt && (this.humanTradeCooldown[seat] ?? 0) <= 0) {
|
||||||
|
const offer = buildAiTradeOffer(this.gs, seat, this.humanSeat, skill,
|
||||||
|
{ targetHuman: true, targetSkill: this.skillBySeat[this.humanSeat] ?? 3 });
|
||||||
|
if (offer && validateTrade(this.gs, offer).ok) {
|
||||||
|
this.humanTradeCooldown[seat] = 2; // blocks this AI's next own turn
|
||||||
|
await this.showIncomingTradeProposal(seat, offer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Trade with other AI opponents — up to 5 completed deals this turn.
|
||||||
|
let done = 0, guard = 0;
|
||||||
|
while (done < 5 && guard++ < 12) {
|
||||||
|
const others = this.gs.players
|
||||||
|
.filter(pl => pl.seat !== seat && pl.seat !== this.humanSeat && pl.active && !pl.bankrupt)
|
||||||
|
.map(pl => pl.seat);
|
||||||
|
Phaser.Utils.Array.Shuffle(others);
|
||||||
|
let made = false;
|
||||||
|
for (const tSeat of others) {
|
||||||
|
const tSkill = this.skillBySeat[tSeat] ?? 3;
|
||||||
|
const offer = buildAiTradeOffer(this.gs, seat, tSeat, skill, { targetSkill: tSkill });
|
||||||
|
if (!offer || !validateTrade(this.gs, offer).ok) continue;
|
||||||
|
if (!evaluateTrade(this.gs, tSeat, offer, tSkill).accept) continue;
|
||||||
|
this.gs = applyTrade(this.gs, offer);
|
||||||
|
this.render();
|
||||||
|
await this.flashAiTrade(offer);
|
||||||
|
done++; made = true;
|
||||||
|
if (done >= 5) break;
|
||||||
|
}
|
||||||
|
if (!made) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared summary layout for both the incoming-proposal popup and the AI-AI flash.
|
||||||
|
// Returns an array of created display objects for the caller to destroy.
|
||||||
|
buildTradeSummaryContents(offer, geo, topLabel, bottomLabel) {
|
||||||
|
const objs = [];
|
||||||
|
const cardScale = 0.46;
|
||||||
|
const cw = MODAL_W * cardScale, gap = 24, rowH = MODAL_H * cardScale;
|
||||||
|
const midX = geo.x + geo.w / 2;
|
||||||
|
|
||||||
|
const drawRow = (label, props, cash, labelY) => {
|
||||||
|
objs.push(this.add.text(geo.x + 30, labelY, label, {
|
||||||
|
fontFamily:'Righteous', fontSize:'18px', color:COLORS.goldHex,
|
||||||
|
}).setOrigin(0, 0).setDepth(DEPTH.popup + 1));
|
||||||
|
|
||||||
|
const n = props.length + (cash > 0 ? 1 : 0);
|
||||||
|
const totalW = n > 0 ? n * cw + (n - 1) * gap : 0;
|
||||||
|
let x = midX - totalW / 2 + cw / 2;
|
||||||
|
const cy = labelY + 30 + rowH / 2;
|
||||||
|
for (const idx of props) {
|
||||||
|
const card = this.buildPropertyCardContainer(idx);
|
||||||
|
card.setPosition(x, cy).setScale(cardScale).setDepth(DEPTH.popup + 1);
|
||||||
|
objs.push(card);
|
||||||
|
x += cw + gap;
|
||||||
|
}
|
||||||
|
if (cash > 0) {
|
||||||
|
const badge = this.add.container(x, cy).setDepth(DEPTH.popup + 1);
|
||||||
|
const g = this.add.graphics();
|
||||||
|
g.fillStyle(0x14110a, 1); g.fillRoundedRect(-cw/2, -42, cw, 84, 10);
|
||||||
|
g.lineStyle(2, 0x7fb87f, 1); g.strokeRoundedRect(-cw/2, -42, cw, 84, 10);
|
||||||
|
badge.add(g);
|
||||||
|
badge.add(this.add.text(0, 0, `$${cash.toLocaleString()}`, {
|
||||||
|
fontFamily:'Righteous', fontSize:'26px', color:'#7fdd9f',
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
objs.push(badge);
|
||||||
|
}
|
||||||
|
if (props.length === 0 && cash <= 0) {
|
||||||
|
objs.push(this.add.text(midX, cy, '— nothing —', {
|
||||||
|
fontFamily:'"Julius Sans One"', fontSize:'16px', color:COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const row1Y = geo.y + 4;
|
||||||
|
drawRow(topLabel, offer.giveProps ?? [], offer.giveCash ?? 0, row1Y);
|
||||||
|
|
||||||
|
const divY = row1Y + 30 + rowH + 22;
|
||||||
|
const dg = this.add.graphics().setDepth(DEPTH.popup + 1);
|
||||||
|
dg.lineStyle(1, COLORS.accent, 0.5);
|
||||||
|
dg.beginPath(); dg.moveTo(geo.x + 30, divY); dg.lineTo(geo.x + geo.w - 30, divY); dg.strokePath();
|
||||||
|
objs.push(dg);
|
||||||
|
|
||||||
|
drawRow(bottomLabel, offer.getProps ?? [], offer.getCash ?? 0, divY + 14);
|
||||||
|
return objs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming proposal from an AI to the human. Resolves when the human Accepts,
|
||||||
|
// Declines, or finishes a Counter-offer.
|
||||||
|
showIncomingTradeProposal(fromSeat, offer) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this._tradeResolve = resolve;
|
||||||
|
this.tradeSummaryOpen = true;
|
||||||
|
this.hidePortraits();
|
||||||
|
const fromName = this.gs.players[fromSeat].name;
|
||||||
|
const objs = [];
|
||||||
|
|
||||||
|
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
|
||||||
|
.setDepth(DEPTH.popup - 1).setInteractive();
|
||||||
|
objs.push(overlay);
|
||||||
|
|
||||||
|
const PW = 1180, PH = 760;
|
||||||
|
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
|
||||||
|
const panel = this.add.graphics().setDepth(DEPTH.popup);
|
||||||
|
panel.fillStyle(0x1e1a12, 1); panel.fillRoundedRect(PX, PY, PW, PH, 14);
|
||||||
|
panel.lineStyle(2, COLORS.gold, 1); panel.strokeRoundedRect(PX, PY, PW, PH, 14);
|
||||||
|
objs.push(panel);
|
||||||
|
|
||||||
|
objs.push(this.add.text(GAME_WIDTH/2, PY + 22, `${fromName} proposes a trade`, {
|
||||||
|
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
|
||||||
|
|
||||||
|
objs.push(...this.buildTradeSummaryContents(offer,
|
||||||
|
{ x: PX, y: PY + 58, w: PW }, `${fromName} gives you`, `You give ${fromName}`));
|
||||||
|
|
||||||
|
const finish = (result) => {
|
||||||
|
objs.forEach(o => { try { o.destroy(); } catch {} });
|
||||||
|
this.tradeSummaryOpen = false;
|
||||||
|
if (result === 'counter') {
|
||||||
|
this.openCounterModal(fromSeat, offer); // resolves _tradeResolve on close
|
||||||
|
} else {
|
||||||
|
this.showPortraits();
|
||||||
|
const r = this._tradeResolve; this._tradeResolve = null;
|
||||||
|
if (r) r();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const by = PY + PH - 54;
|
||||||
|
const accept = new Button(this, GAME_WIDTH/2 - 230, by, 'Accept', () => {
|
||||||
|
if (validateTrade(this.gs, offer).ok) {
|
||||||
|
this.gs = applyTrade(this.gs, offer);
|
||||||
|
playSound(this, SFX.MONOPOLY_PURCHASE);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
finish('accept');
|
||||||
|
}, { width: 200, height: 50, fontSize: 22 });
|
||||||
|
accept.setDepth(DEPTH.popup + 2); objs.push(accept);
|
||||||
|
|
||||||
|
const counter = new Button(this, GAME_WIDTH/2, by, 'Counter-offer', () => finish('counter'),
|
||||||
|
{ width: 230, height: 50, fontSize: 20, variant:'ghost' });
|
||||||
|
counter.setDepth(DEPTH.popup + 2); objs.push(counter);
|
||||||
|
|
||||||
|
const decline = new Button(this, GAME_WIDTH/2 + 230, by, 'Decline', () => finish('decline'),
|
||||||
|
{ width: 200, height: 50, fontSize: 22, variant:'ghost' });
|
||||||
|
decline.setDepth(DEPTH.popup + 2); objs.push(decline);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief read-only summary of a completed AI-vs-AI trade.
|
||||||
|
flashAiTrade(offer) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.tradeSummaryOpen = true;
|
||||||
|
this.hidePortraits();
|
||||||
|
const fromName = this.gs.players[offer.fromSeat].name;
|
||||||
|
const toName = this.gs.players[offer.toSeat].name;
|
||||||
|
const objs = [];
|
||||||
|
|
||||||
|
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
||||||
|
.setDepth(DEPTH.popup - 1).setInteractive();
|
||||||
|
objs.push(overlay);
|
||||||
|
|
||||||
|
const PW = 1180, PH = 760;
|
||||||
|
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
|
||||||
|
const panel = this.add.graphics().setDepth(DEPTH.popup);
|
||||||
|
panel.fillStyle(0x1e1a12, 1); panel.fillRoundedRect(PX, PY, PW, PH, 14);
|
||||||
|
panel.lineStyle(2, COLORS.gold, 1); panel.strokeRoundedRect(PX, PY, PW, PH, 14);
|
||||||
|
objs.push(panel);
|
||||||
|
|
||||||
|
objs.push(this.add.text(GAME_WIDTH/2, PY + 22, `${fromName} ⇄ ${toName} — Trade`, {
|
||||||
|
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
|
||||||
|
|
||||||
|
objs.push(...this.buildTradeSummaryContents(offer,
|
||||||
|
{ x: PX, y: PY + 58, w: PW }, `${fromName} gives`, `${toName} gives`));
|
||||||
|
|
||||||
|
objs.push(this.add.text(GAME_WIDTH/2, PY + PH - 36, 'ACCEPTED', {
|
||||||
|
fontFamily:'Righteous', fontSize:'28px', color:'#7fdd9f',
|
||||||
|
}).setOrigin(0.5).setDepth(DEPTH.popup + 1));
|
||||||
|
|
||||||
|
this.time.delayedCall(1500, () => {
|
||||||
|
objs.forEach(o => { try { o.destroy(); } catch {} });
|
||||||
|
this.tradeSummaryOpen = false;
|
||||||
|
this.showPortraits();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the existing drag-drop trade builder in "counter" mode, seeded with the AI's
|
||||||
|
// offer reversed to the human's perspective and locked to that opponent.
|
||||||
|
openCounterModal(fromSeat, offer) {
|
||||||
|
const seed = {
|
||||||
|
giveProps: [...(offer.getProps ?? [])], // human gives what the AI requested
|
||||||
|
getProps: [...(offer.giveProps ?? [])], // human gets what the AI offered
|
||||||
|
giveCash: offer.getCash ?? 0,
|
||||||
|
getCash: offer.giveCash ?? 0,
|
||||||
|
};
|
||||||
|
this.showTradeModal({ counter: true, counterFrom: fromSeat, seedOffer: seed });
|
||||||
|
}
|
||||||
|
|
||||||
|
showTradeModal(opts = {}) {
|
||||||
|
if (this.tradeMenuOpen) return;
|
||||||
|
if (!opts.counter && this.busy) return;
|
||||||
|
if (!opts.counter && (this.gs.current !== this.humanSeat || this.gs.phase !== 'endturn')) return;
|
||||||
|
this.tradeCounterMode = !!opts.counter;
|
||||||
|
this.tradeCounterFrom = opts.counterFrom ?? null;
|
||||||
this.tradeMenuOpen = true;
|
this.tradeMenuOpen = true;
|
||||||
this.tradeMenuObjs = [];
|
this.tradeMenuObjs = [];
|
||||||
this.tradeLaneObjs = [];
|
this.tradeLaneObjs = [];
|
||||||
this.tradeRightObjs = [];
|
this.tradeRightObjs = [];
|
||||||
this.tradeMineCards = {};
|
this.tradeMineCards = {};
|
||||||
this.tradeOppCards = {};
|
this.tradeOppCards = {};
|
||||||
this.tradeOffer = { giveProps: [], getProps: [], giveCash: 0, getCash: 0 };
|
this.tradeOffer = opts.seedOffer
|
||||||
|
? { giveProps:[...opts.seedOffer.giveProps], getProps:[...opts.seedOffer.getProps],
|
||||||
|
giveCash: opts.seedOffer.giveCash, getCash: opts.seedOffer.getCash }
|
||||||
|
: { giveProps: [], getProps: [], giveCash: 0, getCash: 0 };
|
||||||
this.tradeDragGhost = null;
|
this.tradeDragGhost = null;
|
||||||
this._tradeDidDrag = false;
|
this._tradeDidDrag = false;
|
||||||
|
|
||||||
|
|
@ -1373,10 +1602,14 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
this.hidePortraits();
|
this.hidePortraits();
|
||||||
|
|
||||||
const gs = this.gs;
|
const gs = this.gs;
|
||||||
|
if (this.tradeCounterMode && this.tradeCounterFrom !== null) {
|
||||||
|
this.tradeCounterparty = this.tradeCounterFrom; // locked to the proposing AI
|
||||||
|
} else {
|
||||||
// Default counterparty: first active opponent owning a property, else first opponent
|
// Default counterparty: first active opponent owning a property, else first opponent
|
||||||
const opps = gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
|
const opps = gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
|
||||||
this.tradeCounterparty = (opps.find(pl =>
|
this.tradeCounterparty = (opps.find(pl =>
|
||||||
PURCHASABLE.some(i => gs.board[i]?.owner === pl.seat)) ?? opps[0])?.seat ?? null;
|
PURCHASABLE.some(i => gs.board[i]?.owner === pl.seat)) ?? opps[0])?.seat ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Geometry
|
// Geometry
|
||||||
const PW = 1600, PH = 860;
|
const PW = 1600, PH = 860;
|
||||||
|
|
@ -1400,7 +1633,10 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
panel.strokeRoundedRect(PX, PY, PW, PH, 14);
|
panel.strokeRoundedRect(PX, PY, PW, PH, 14);
|
||||||
this.tradeMenuObjs.push(panel);
|
this.tradeMenuObjs.push(panel);
|
||||||
|
|
||||||
this.tradeMenuObjs.push(this.add.text(GAME_WIDTH/2, PY + 24, 'Propose a Trade', {
|
const titleTxt = this.tradeCounterMode && this.tradeCounterparty !== null
|
||||||
|
? `Counter-offer to ${gs.players[this.tradeCounterparty].name}`
|
||||||
|
: 'Propose a Trade';
|
||||||
|
this.tradeMenuObjs.push(this.add.text(GAME_WIDTH/2, PY + 24, titleTxt, {
|
||||||
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
|
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
|
||||||
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
|
||||||
|
|
||||||
|
|
@ -1521,7 +1757,8 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
this.buildCashStepper('get', PY + 396);
|
this.buildCashStepper('get', PY + 396);
|
||||||
|
|
||||||
// Propose / Cancel
|
// Propose / Cancel
|
||||||
const proposeBtn = new Button(this, midX, PY + 476, 'Propose Trade', () => this.onProposeTrade(),
|
const proposeLabel = this.tradeCounterMode ? 'Send Counter-offer' : 'Propose Trade';
|
||||||
|
const proposeBtn = new Button(this, midX, PY + 476, proposeLabel, () => this.onProposeTrade(),
|
||||||
{ width: 260, height: 52, fontSize: 22 });
|
{ width: 260, height: 52, fontSize: 22 });
|
||||||
proposeBtn.setDepth(DEPTH.popup+2);
|
proposeBtn.setDepth(DEPTH.popup+2);
|
||||||
this.tradeMenuObjs.push(proposeBtn);
|
this.tradeMenuObjs.push(proposeBtn);
|
||||||
|
|
@ -1590,7 +1827,9 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
this.tradeOppCards = {};
|
this.tradeOppCards = {};
|
||||||
const { RX, RW, PY } = this._tradeGeo;
|
const { RX, RW, PY } = this._tradeGeo;
|
||||||
const gs = this.gs;
|
const gs = this.gs;
|
||||||
const opps = gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
|
const opps = this.tradeCounterMode
|
||||||
|
? gs.players.filter(pl => pl.seat === this.tradeCounterparty)
|
||||||
|
: gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
|
||||||
|
|
||||||
// Opponent tabs
|
// Opponent tabs
|
||||||
const tabW = Math.min(150, Math.floor((RW - (opps.length - 1) * 8) / Math.max(1, opps.length)));
|
const tabW = Math.min(150, Math.floor((RW - (opps.length - 1) * 8) / Math.max(1, opps.length)));
|
||||||
|
|
@ -1630,6 +1869,7 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
selectTradeCounterparty(seat) {
|
selectTradeCounterparty(seat) {
|
||||||
|
if (this.tradeCounterMode) return; // locked to the proposing AI
|
||||||
if (seat === this.tradeCounterparty) return;
|
if (seat === this.tradeCounterparty) return;
|
||||||
this.tradeCounterparty = seat;
|
this.tradeCounterparty = seat;
|
||||||
// getProps/getCash referenced the previous opponent — reset them
|
// getProps/getCash referenced the previous opponent — reset them
|
||||||
|
|
@ -1898,6 +2138,10 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
this.closeTradeModal();
|
this.closeTradeModal();
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
} else if (this.tradeCounterMode) {
|
||||||
|
// The AI gets a single chance to accept a counter — a rejection ends it.
|
||||||
|
this.setTradeFeedback('They declined your counter. ' + verdict.reason, 'bad');
|
||||||
|
this.time.delayedCall(1500, () => { this.closeTradeModal(); this.render(); });
|
||||||
} else {
|
} else {
|
||||||
this.setTradeFeedback('Rejected: ' + verdict.reason, 'bad');
|
this.setTradeFeedback('Rejected: ' + verdict.reason, 'bad');
|
||||||
}
|
}
|
||||||
|
|
@ -1928,6 +2172,11 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
this.tradeGiveCashText = null;
|
this.tradeGiveCashText = null;
|
||||||
this.tradeGetCashText = null;
|
this.tradeGetCashText = null;
|
||||||
this.tradeFeedbackText = null;
|
this.tradeFeedbackText = null;
|
||||||
|
this.tradeCounterMode = false;
|
||||||
|
this.tradeCounterFrom = null;
|
||||||
|
// If this modal was a counter to an AI proposal, resolve that pending promise.
|
||||||
|
const r = this._tradeResolve; this._tradeResolve = null;
|
||||||
|
if (r) r();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Game Over ──────────────────────────────────────────────────────────────
|
// ── Game Over ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -2113,6 +2362,7 @@ export default class MonopolyGame extends Phaser.Scene {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'endturn': {
|
case 'endturn': {
|
||||||
|
await this.aiAttemptTrades(seat);
|
||||||
await this.delay(450);
|
await this.delay(450);
|
||||||
this.gs = endTurn(this.gs);
|
this.gs = endTurn(this.gs);
|
||||||
this.render();
|
this.render();
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: tru
|
||||||
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 9 });
|
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 9 });
|
||||||
registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6, iconFrame: 10 });
|
registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6, iconFrame: 10 });
|
||||||
registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6, iconFrame: 11 });
|
registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6, iconFrame: 11 });
|
||||||
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 12 });
|
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 12 });
|
||||||
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 13 });
|
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 13 });
|
||||||
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3, hasTutorial: true, iconFrame: 14 });
|
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3, hasTutorial: true, iconFrame: 14 });
|
||||||
registerGame({ slug: 'tickettoride', name: 'Ticket to Ride', category: 'tabletop', cardGame: true, minPlayers: 2, maxPlayers: 5, minOpponents: 1, maxOpponents: 4, hasTutorial: true, iconFrame: 15 });
|
registerGame({ slug: 'tickettoride', name: 'Ticket to Ride', category: 'tabletop', cardGame: true, minPlayers: 2, maxPlayers: 5, minOpponents: 1, maxOpponents: 4, hasTutorial: true, iconFrame: 15 });
|
||||||
|
|
@ -68,7 +68,7 @@ registerGame({ slug: 'tectonic', name: 'Tectonic', category: 'word', minPlayers:
|
||||||
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 39 });
|
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 39 });
|
||||||
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
|
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
|
||||||
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, hasTutorial: true, maxOpponents: 3, iconFrame: 41 });
|
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, hasTutorial: true, maxOpponents: 3, iconFrame: 41 });
|
||||||
registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 43 });
|
registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 43 });
|
||||||
registerGame({ slug: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 });
|
registerGame({ slug: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 });
|
||||||
registerGame({ slug: 'farkel', name: 'Farkle', category: 'cards', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 45 });
|
registerGame({ slug: 'farkel', name: 'Farkle', category: 'cards', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 45 });
|
||||||
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });
|
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue