Compare commits
19 Commits
a6e0bd0489
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3341c3922a | |||
| b27032762e | |||
| 54bae2faf5 | |||
| 1c370f80a6 | |||
| 025f0457c7 | |||
| 223fc91b19 | |||
| d5345c6cee | |||
| 3257d40722 | |||
| f9d100f918 | |||
| 0fc20da274 | |||
| 2eecc94cfd | |||
| 913b0c92c1 | |||
| 7725226dfc | |||
| 7118738818 | |||
| f489442cb5 | |||
| b5d696bf30 | |||
| 3e29a2e4da | |||
| da63f05ac3 | |||
| 8a776dfae5 |
@@ -0,0 +1,11 @@
|
||||
# AI Settings
|
||||
LOOK_AHEAD=8
|
||||
BLUNDER_ENABLED=false
|
||||
BLUNDER_CHANCE=20
|
||||
|
||||
# Demo Settings
|
||||
DEMO_RESET_PAUSE=5
|
||||
IDLE_TIMEOUT=60
|
||||
|
||||
# Game Log
|
||||
MAX_GAME_LOG=100
|
||||
@@ -3,3 +3,10 @@
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
.vscode/settings.json
|
||||
CLAUDE.md
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.games.txt
|
||||
uv.lock
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.14
|
||||
3.13
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Vier op een Rij AI: Hoe denkt de computer?
|
||||
|
||||
## 1. Het Virtuele Bord
|
||||
|
||||
De computer ziet geen gekleurde schijfjes op een bord. Voor de computer is het bord een tabel met cijfers:
|
||||
|
||||
- **0** = Lege plek
|
||||
- **1** = Gele schijf
|
||||
- **2** = Rode schijf
|
||||
|
||||
Het bord heeft 7 kolommen en 6 rijen. Na elke zet controleert de computer alle richtingen (horizontaal, verticaal en schuin) om te kijken of iemand vier op een rij heeft.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wat is een "Ply"?
|
||||
|
||||
Een **ply** is één zet van een speler. Als de AI op ply 6 staat, kijkt hij 6 zetten vooruit. Omdat spelers om de beurt spelen, betekent ply 6 dat de AI 3 van zijn eigen zetten en 3 zetten van de tegenstander bedenkt.
|
||||
|
||||
Meer plies = slimmere zetten, maar het duurt langer om te berekenen. Op de ESP32-C3 is ply 4 bijna meteen klaar, ply 6 duurt ongeveer een seconde, en ply 8-10 kan enkele seconden duren. Terwijl de AI nadenkt, zie je een knipperend lichtje.
|
||||
|
||||
---
|
||||
|
||||
## 3. De Minimax Strategie
|
||||
|
||||
### Het basisidee
|
||||
|
||||
Stel je voor dat je Vier op een Rij speelt tegen een vriend. Voordat je je schijfje laat vallen, denk je: "Als ik mijn schijfje hier leg, wat doet mijn vriend dan? En wat doe ik daarna?" Dat is precies wat de computer doet, alleen kijkt hij naar **alle** mogelijke zetten, niet alleen een paar.
|
||||
|
||||
### Twee spelers, twee doelen
|
||||
|
||||
De AI noemt de twee spelers **Max** (de computer zelf) en **Min** (jij):
|
||||
|
||||
- **Max** wil de hoogste score (de computer wint).
|
||||
- **Min** wil de laagste score (jij wint).
|
||||
|
||||
De AI gaat ervan uit dat jij altijd je beste zet doet. Hij hoopt niet dat jij een fout maakt.
|
||||
|
||||
### Een eenvoudig voorbeeld
|
||||
|
||||
Stel je voor dat er nog maar 3 kolommen over zijn en de AI kijkt 2 zetten vooruit. Hij maakt een soort boom:
|
||||
|
||||
```text
|
||||
Beurt van de AI (Max - kiest de hoogste score)
|
||||
/ | \\
|
||||
kolom 2 kolom 3 kolom 4
|
||||
/ \\ / \\ / \\
|
||||
Jouw beurt (Min - kiest de laagste score)
|
||||
... ... ... ... ... ...
|
||||
+5 -3 +2 +8 -1 +4
|
||||
```
|
||||
|
||||
1. Na kolom 2: jij zou de laagste score kiezen (-3).
|
||||
2. Na kolom 3: jij zou de laagste score kiezen (+2).
|
||||
3. Na kolom 4: jij zou de laagste score kiezen (-1).
|
||||
|
||||
De AI kiest dan de kolom met de hoogste score die overblijft, in dit geval kolom 3 (+2).
|
||||
|
||||
### Scoren: Hoe de AI een Bord Waardeert
|
||||
|
||||
Na het doorspelen van een "wat als?"-scenario, moet de AI beslissen: is dit een goed of slecht resultaat? Hij gebruikt een gelaagd scoringsysteem:
|
||||
|
||||
- **+1000 of meer: "Ik win!"** De AI heeft een manier gevonden om vier op een rij te krijgen. Hoe sneller hij kan winnen, hoe hoger de score. Winnen in 2 zetten scoort hoger dan winnen in 6 zetten. Daarom gaat de AI altijd voor de snelste overwinning.
|
||||
|
||||
- **-1000 of minder: "Ik verlies!"** De tegenstander krijgt vier op een rij. Hoe sneller hij verliest, hoe slechter de score. Dit zorgt ervoor dat de AI het hardst vecht tegen zetten die dreigen tot een direct verlies.
|
||||
|
||||
- **Heuristiek-score: "Ik weet het nog niet, maar ik kan zien hoe goed het eruitziet."** Als de AI zo ver vooruit heeft gekeken als hij kon (door zijn plies heen) en niemand heeft gewonnen, beoordeelt hij de positie met een heuristiek — een snelle schatting van wie er sterker voor staat.
|
||||
|
||||
### De Heuristiek: Het Bord Lezen
|
||||
|
||||
In plaats van elke onbesliste positie "neutraal" te noemen, bekijkt de AI elke mogelijke groep van vier opeenvolgende cellen op het bord (horizontaal, verticaal en beide diagonalen — 69 groepen in totaal). Voor elke groep telt hij de schijfjes:
|
||||
|
||||
- **3 AI-schijfjes + 1 leeg (speelbaar):** De lege cel kan nu meteen gevuld worden (hij zit op de onderste rij of er zit een schijfje onder). Dit is een directe dreiging. Score: **+100**.
|
||||
- **3 AI-schijfjes + 1 leeg (nog niet speelbaar):** De lege cel zweeft in de lucht — de dreiging bestaat maar kan nog niet benut worden. Score: **+40**.
|
||||
- **2 AI-schijfjes + 2 leeg:** Een veelbelovende opbouw die zich tot een dreiging kan ontwikkelen. Score: **+5**.
|
||||
- **3 tegenstander-schijfjes + 1 leeg (speelbaar):** Een direct gevaar. Score: **-100**.
|
||||
- **3 tegenstander-schijfjes + 1 leeg (nog niet speelbaar):** Een toekomstig gevaar. Score: **-40**.
|
||||
- **2 tegenstander-schijfjes + 2 leeg:** De tegenstander bouwt iets op. Score: **-5**.
|
||||
- **Gemengde groepen** (beide spelers hebben schijfjes in dezelfde groep): Geblokkeerd — niemand kan hier winnen. Score: **0**.
|
||||
|
||||
Daarbovenop gebruikt de AI twee extra scorebonussen:
|
||||
|
||||
- **Controle over de middelste kolom:** +3 per AI-schijfje in de middelste kolom, -3 per tegenstander-schijfje. De middelste kolom is betrokken bij meer winnende lijnen dan elke andere kolom, dus het beheersen ervan is waardevol.
|
||||
- **Vorkdetectie:** Als een speler **twee of meer** drie-op-een-rij dreigingen tegelijk heeft, is dat een vork — de tegenstander kan er maar één per beurt blokkeren, dus de andere wint het spel. De AI geeft een grote bonus (**+200** of **-200**) wanneer hij een vork detecteert, waardoor hij agressief vork-opstellingen najaagt en wanhopig probeert te voorkomen dat de tegenstander er een maakt.
|
||||
|
||||
Al deze scores tellen bij elkaar op. De maximale heuristiek-score ligt ruim onder 1000, dus het verstoort nooit de echte winst/verlies-detectie — een gegarandeerde winst wint altijd van de beste heuristiek-positie.
|
||||
|
||||
Deze heuristiek betekent dat de AI nu het verschil kan zien tussen een sterke positie (veel dreigingen in opbouw, vooral speelbare) en een zwakke (de tegenstander heeft alle dreigingen), zelfs als hij geen gedwongen winst of verlies kan zien binnen zijn zoekdiepte.
|
||||
|
||||
### Waarom de middelste kolom belangrijk is
|
||||
|
||||
De AI controleert altijd eerst de middelste kolom (kolom 3), en werkt dan naar buiten toe (2, 4, 1, 5, 0, 6). De middelste kolom is betrokken bij meer mogelijke winnende lijnen dan de randen, dus door deze eerst te controleren, vindt de AI sneller goede zetten en kan hij slechte zetten eerder overslaan (dankzij alpha-beta snoeien). De heuristiek geeft ook een kleine bonus voor controle over het midden, wat dit natuurlijke voordeel versterkt.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alpha-Beta Snoeien: De Slimme Snelweg
|
||||
|
||||
### Het probleem
|
||||
|
||||
Acht plies vooruit kijken in Vier op een Rij betekent miljoenen bordposities verkennen.
|
||||
Zelfs een snelle microcontroller kan ze niet allemaal in een redelijke tijd controleren.
|
||||
|
||||
### De oplossing
|
||||
|
||||
**Alpha-Beta pruning** is een manier om takken van de boom over te slaan die de uiteindelijke beslissing niet kunnen veranderen.
|
||||
_"to prune" betekent snoeien of snijden_
|
||||
|
||||
Stel je voor dat je een verjaardagscadeau koopt.
|
||||
Je gaat naar Winkel A en vindt een leuk speelgoed voor 10 euro. Dan ga je naar Winkel B.
|
||||
Het eerste artikel dat je ziet, kost 15 euro, en je merkt dat alles in Winkel B nog duurder is.
|
||||
Je hoeft niet elk artikel in Winkel B te controleren — je weet al dat Winkel A beter is.
|
||||
Je verlaat Winkel B en bespaart tijd.
|
||||
|
||||
De AI doet hetzelfde:
|
||||
|
||||
- **Alpha** is de beste score die de AI (Max) tot nu toe heeft gevonden. Denk hierbij aan: "Ik weet al dat ik ten minste dit goed kan doen."
|
||||
- **Beta** is de beste score die de tegenstander (Min) tot nu toe heeft gevonden. Denk hierbij aan: "De tegenstander weet al dat hij mij tot hoogstens dit kan beperken."
|
||||
|
||||
Wanneer de AI een tak verkent en ontdekt dat de score nooit beter kan worden dan wat hij al heeft (beta <= alpha),
|
||||
**snijdt** hij (snijdt hij af) die hele tak. Hij slaat alle overgebleven zetten in die tak over, omdat ze het resultaat niet kunnen veranderen.
|
||||
|
||||
### Hoeveel helpt het?
|
||||
|
||||
In de praktijk laat snoeien de AI 50-90% van de posities overslaan die hij anders zou moeten controleren.
|
||||
Daarom is de volgorde van de kolommen belangrijk — de AI controleert eerst de middelste kolom (kolom 3) en werkt dan naar buiten toe.
|
||||
Goede zetten zitten vaak in het midden, dus door deze eerst te controleren, leidt dat tot betere snoeiing en een snellere zoektocht.
|
||||
|
||||
---
|
||||
|
||||
## 5. De Drie Fases van de AI
|
||||
|
||||
De AI doet zijn werk in drie stappen:
|
||||
|
||||
1. **Kan ik nu winnen?** De AI controleert **alle** kolommen op een winnende zet. Als hij ergens vier op een rij kan maken, doet hij dat meteen. Geen verdere berekeningen nodig. Belangrijk: de AI controleert eerst elke kolom op eigen winst voordat hij naar dreigingen kijkt — zo blokkeert hij nooit per ongeluk een dreiging van de tegenstander als hij zelf het spel kan winnen.
|
||||
|
||||
2. **Kan de tegenstander volgende beurt winnen?** Pas nadat is bevestigd dat er geen directe winst is, controleert de AI alle kolommen op dreigingen van de tegenstander. Als jij ergens vier op een rij kunt maken, blokkeert hij die kolom. Dit overslaan zou een grote fout zijn.
|
||||
|
||||
3. **Diepe zoektocht.** Als er geen directe winst of bedreiging is, voert de AI de volledige minimax-strategie uit met alpha-beta snoeien en de heuristiek-evaluatie.
|
||||
|
||||
Deze drie stappen maken de AI zowel snel (directe reacties op duidelijke zetten) als slim (diep nadenken als het nodig is).
|
||||
|
||||
---
|
||||
|
||||
## 6. Demo-modus: Verschillende Vaardigheden
|
||||
|
||||
In de demo-modus spelen twee AI's tegen elkaar.
|
||||
Om het spannend te maken (in plaats van altijd gelijkspel), krijgt elke speler willekeurig een andere diepte toegekend.
|
||||
De ene speler kijkt bijvoorbeeld 5 zetten vooruit, de andere maar 3.
|
||||
De sterkere speler kan zo winnende zetten vinden die de zwakkere mist. Wie sterker is, wordt elke keer willekeurig bepaald.
|
||||
|
||||
---
|
||||
|
||||
## 7. Blunder-modus
|
||||
|
||||
Normaal speelt de AI altijd de beste zet die hij kan vinden. Maar dat kan frustrerend zijn voor jongere of minder ervaren spelers die nooit winnen. De **blunder-modus** geeft de AI een instelbare kans (bijvoorbeeld 20%) om een willekeurige zet te doen in plaats van diep na te denken. Als er een blunder gebeurt, slaat de AI zijn slimme analyse over en laat hij een schijfje in een willekeurige open kolom vallen. De rest van de tijd speelt hij gewoon op volle kracht — maar af en toe maakt hij een domme fout die een oplettende speler kan afstraffen.
|
||||
|
||||
Blunders gaan nooit boven een directe winst of blokkade. Als de AI nu kan winnen, of als de tegenstander op het punt staat te winnen, maakt de AI altijd de juiste zet. Blunders vervangen alleen de diepe zoektocht op beurten waar er geen directe dreiging is.
|
||||
|
||||
---
|
||||
|
||||
## 8. Snelle Bediening
|
||||
|
||||
De ESP32-C3 heeft maar één kern. Als de AI nadenkt, kan hij de bediening een paar seconden blokkeren.
|
||||
Twee trucs zorgen ervoor dat het spel soepel blijft:
|
||||
|
||||
1. **Knopcontrole tijdens het zoeken:** Tijdens het nadenken controleert de AI af en toe of je op de knop hebt gedrukt. Zo ja, stopt hij meteen met nadenken.
|
||||
|
||||
2. **Stopvlag:** Een wereldwijde vlag (`abortAi`) zorgt ervoor dat het nadenken meteen stopt als je op de knop drukt. Binnen microseconden stopt de hele berekening.
|
||||
|
||||
---
|
||||
|
||||
## Meer Weten?
|
||||
|
||||
- [Vier op een Rij - Wiskundige Oplossing (Wikipedia)](https://nl.wikipedia.org/wiki/Vier_op_een_rij)
|
||||
- [Minimax Algorithme (Wikipedia)](https://nl.wikipedia.org/wiki/Minimax)
|
||||
- [Alpha-Beta Pruning (Wikipedia)](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning)
|
||||
@@ -0,0 +1,151 @@
|
||||
# Connect 4 AI: How the Computer Thinks
|
||||
|
||||
## 1. The Virtual Board
|
||||
|
||||
The computer doesn't see colored discs on a grid. It sees a table of numbers:
|
||||
|
||||
- **0** = Empty space
|
||||
- **1** = Yellow disc
|
||||
- **2** = Red disc
|
||||
|
||||
The board has 7 columns and 6 rows. After every move, a scan function checks all directions (horizontal, vertical, and both diagonals) to see if anyone has four in a row.
|
||||
|
||||
## 2. What is a "Ply"?
|
||||
|
||||
A **ply** is one move by one player. If the AI is set to ply 6, it looks 6 individual moves into the future. Since players alternate turns, ply 6 means the AI considers 3 of its own moves and 3 of the opponent's moves.
|
||||
|
||||
More plies = stronger play, but takes longer to calculate. On the ESP32-C3, ply 4 is nearly instant, ply 6 takes about a second, and ply 8-10 can take several seconds. The AI shows a pulsing light while it is thinking.
|
||||
|
||||
## 3. The Minimax Strategy
|
||||
|
||||
### The basic idea
|
||||
|
||||
Imagine you are playing Connect 4 against a friend. Before you drop your disc, you think: "If I put my disc here, what will my friend do? And then what would I do after that?"
|
||||
|
||||
That is exactly what the computer does, except it checks **every** possible move, not just a few.
|
||||
|
||||
### Two players, two goals
|
||||
|
||||
The AI calls the two players **Max** (itself) and **Min** (you):
|
||||
|
||||
- **Max** wants the highest possible score (the AI winning).
|
||||
- **Min** wants the lowest possible score (you winning).
|
||||
|
||||
The AI assumes you will always make your best move. It doesn't hope you'll make a mistake.
|
||||
|
||||
### A simple example
|
||||
|
||||
Imagine there are only 3 columns left and the AI can look 2 moves ahead. It builds a tree like this:
|
||||
|
||||
```
|
||||
AI's turn (Max - pick the highest)
|
||||
/ | \
|
||||
col 2 col 3 col 4
|
||||
/ \ / \ / \
|
||||
Your turn (Min - pick the lowest)
|
||||
... ... ... ... ... ...
|
||||
+5 -3 +2 +8 -1 +4
|
||||
```
|
||||
|
||||
1. After column 2: you would pick the move scoring -3 (lowest = best for you).
|
||||
2. After column 3: you would pick the move scoring +2.
|
||||
3. After column 4: you would pick the move scoring -1.
|
||||
|
||||
The AI compares -3, +2, and -1, and picks column 3 because +2 is the best it can guarantee.
|
||||
|
||||
### Scoring: How the AI Rates a Board
|
||||
|
||||
After playing out a "what if?" scenario, the AI needs to decide: is this a good result or a bad one? It uses a layered scoring system:
|
||||
|
||||
- **+1000 or more: "I win!"** The AI found a way to get four in a row. The bonus points above 1000 depend on how quickly it can win. Winning in 2 moves scores higher than winning in 6 moves. This is why the AI always goes for the fastest victory — it never wastes time when it can finish the game.
|
||||
|
||||
- **-1000 or less: "I lose!"** The opponent gets four in a row. Losing sooner gets an even worse score. This makes the AI fight hardest against moves that threaten an immediate loss.
|
||||
|
||||
- **Heuristic score: "I don't know yet, but I can tell how good this looks."** When the AI has looked as far ahead as it can (it ran out of plies) and nobody has won, it evaluates the position using a heuristic — a quick estimate of who is in a stronger position.
|
||||
|
||||
### The Heuristic: Reading the Board
|
||||
|
||||
Instead of calling every unsolved position "neutral," the AI examines every possible group of four consecutive cells on the board (horizontal, vertical, and both diagonals — 69 groups in total). For each group, it counts pieces:
|
||||
|
||||
- **3 AI pieces + 1 empty (playable):** The empty cell can be filled right now (it's on the bottom row or has a piece below it). This is an immediate threat. Score: **+100**.
|
||||
- **3 AI pieces + 1 empty (not yet playable):** The empty cell is floating in the air — the threat exists but can't be used yet. Score: **+40**.
|
||||
- **2 AI pieces + 2 empty:** A promising setup that could develop into a threat. Score: **+5**.
|
||||
- **3 opponent pieces + 1 empty (playable):** An immediate danger. Score: **-100**.
|
||||
- **3 opponent pieces + 1 empty (not yet playable):** A future danger. Score: **-40**.
|
||||
- **2 opponent pieces + 2 empty:** The opponent is building something. Score: **-5**.
|
||||
- **Mixed groups** (both players have pieces in the same group): Blocked — nobody can win here. Score: **0**.
|
||||
|
||||
On top of that, the AI uses two more scoring bonuses:
|
||||
|
||||
- **Center column control:** +3 per AI piece in the center column, -3 per opponent piece. The center column is involved in more winning lines than any other column, so controlling it is valuable.
|
||||
- **Fork detection:** If a player has **two or more** three-in-a-row threats at the same time, that's a fork — the opponent can only block one per turn, so the other wins the game. The AI adds a large bonus (**+200** or **-200**) when it detects a fork, making it aggressively pursue fork setups and desperately avoid letting the opponent create one.
|
||||
|
||||
All these scores add up. The maximum possible heuristic score is well below 1000, so it never interferes with actual win/loss detection — a guaranteed win always beats the best heuristic position.
|
||||
|
||||
This heuristic means the AI can now tell the difference between a strong position (many threats being built, especially playable ones) and a weak one (the opponent has all the threats), even when it can't see a forced win or loss within its search depth.
|
||||
|
||||
### Why the center column matters
|
||||
|
||||
The AI always checks the center column first (column 3), then works outward (2, 4, 1, 5, 0, 6). The center column is involved in more possible winning lines than the edges, so checking it first helps the AI find good moves faster and skip bad ones sooner (thanks to alpha-beta pruning). The heuristic also gives a small bonus for center control, reinforcing this natural advantage.
|
||||
|
||||
## 4. Alpha-Beta Pruning: The Smart Shortcut
|
||||
|
||||
### The problem
|
||||
|
||||
Looking ahead 8 plies in Connect 4 means exploring millions of board positions.
|
||||
Even a fast microcontroller can't check them all in a reasonable time.
|
||||
|
||||
### The solution
|
||||
|
||||
**Alpha-Beta pruning** is a way to skip branches of the tree that can't possibly change the final decision.
|
||||
|
||||
Think of it like shopping for a birthday present. You visit Shop A and find a nice toy for 10 euros.
|
||||
Then you go to Shop B. The first item you see costs 15 euros, and you notice everything else in Shop B is even more expensive.
|
||||
You don't need to check every item in Shop B - you already know Shop A is better. You leave Shop B and save time.
|
||||
|
||||
The AI does the same thing:
|
||||
|
||||
- **Alpha** is the best score the AI (Max) has found so far. Think of it as "I already know I can do at least this well."
|
||||
- **Beta** is the best score the opponent (Min) has found so far. Think of it as "The opponent already knows they can limit me to at most this."
|
||||
|
||||
When the AI is exploring a branch and discovers that the score can never beat what it already has (beta <= alpha), it **prunes** (cuts off) that entire branch. It skips all remaining moves in that branch because they can't change the outcome.
|
||||
|
||||
### How much does it help?
|
||||
|
||||
In practice, pruning lets the AI skip 50-90% of the positions it would otherwise need to check. This is why the column order matters - the AI checks the center column first (column 3), then works outward. Good moves tend to be near the center, so checking them first leads to better pruning and faster search.
|
||||
|
||||
## 5. The Three-Phase Move Strategy
|
||||
|
||||
Before running the expensive minimax search, the AI takes two quick shortcuts:
|
||||
|
||||
1. **Can I win right now?** The AI checks **all** columns for a winning move. If any column completes four in a row, it takes that move immediately. No need to think further. Importantly, the AI scans every column for its own win before checking for threats — this ensures it never accidentally blocks an opponent's threat when it could win the game outright.
|
||||
|
||||
2. **Can my opponent win next turn?** Only after confirming there is no instant win, the AI checks all columns for opponent threats. If the opponent could win by playing in any column, the AI blocks it. Missing this would be a fatal mistake.
|
||||
|
||||
3. **Deep search.** Only if there are no immediate wins or threats does the AI run the full minimax search with alpha-beta pruning and the heuristic evaluation.
|
||||
|
||||
This three-phase approach makes the AI both fast (instant reactions to obvious moves) and smart (deep strategic thinking when needed).
|
||||
|
||||
## 6. Demo Mode: Asymmetric Skill
|
||||
|
||||
In demo mode, two AI players play against each other. To make the games interesting (rather than always ending in a draw), each player is randomly assigned a different search depth. One player might look 5 moves ahead while the other only looks 3 moves ahead. The stronger player can find winning setups that the weaker one misses, leading to exciting games with real winners. Who gets the advantage is randomized each game.
|
||||
|
||||
## 7. Blunder Mode
|
||||
|
||||
Normally, the AI always plays the best move it can find. But that can be frustrating for younger or casual players who never get to win. **Blunder mode** gives the AI a configurable chance (for example 20%) to make a random move instead of running the deep minimax search. When a blunder happens, the AI simply drops a disc in a random open column. It still plays normally the rest of the time, so the game feels real - but every now and then the AI makes a silly mistake that a sharp player can punish.
|
||||
|
||||
Blunders never override an instant win or block. If the AI can win right now, or if the opponent is about to win, the AI always makes the correct move. Blunders only replace the deep search on turns where there is no immediate threat.
|
||||
|
||||
## 8. Responsive Controls
|
||||
|
||||
The ESP32-C3 is a single-core processor. When the AI is thinking, it could block all input for several seconds. Two techniques keep the game responsive:
|
||||
|
||||
1. **Mid-search button checks:** During the minimax search, the AI periodically checks whether the player has pressed the button. If so, it immediately abandons the search.
|
||||
|
||||
2. **Abort flag:** A global flag (`abortAi`) propagates through all levels of the recursive search. Once set, every level of the search returns immediately, unwinding the entire calculation in microseconds.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Connect Four - Mathematical Solution (Wikipedia)](https://en.wikipedia.org/wiki/Connect_Four#Mathematical_solution)
|
||||
- [Minimax Algorithm (Wikipedia)](https://en.wikipedia.org/wiki/Minimax)
|
||||
- [Alpha-Beta Pruning (Wikipedia)](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning)
|
||||
Binary file not shown.
@@ -1,38 +1,149 @@
|
||||
# Connect Four
|
||||
# Connect 4 - ESP32
|
||||
|
||||
Connect Four is a two player game played on a 7 by 6 grid. Each player has a color: one player is red, and the other player is yellow. The first player starts Connect Four by dropping one of their yellow discs into the (center) column of an empty game board. The two players then alternate turns dropping one of their discs at a time into an unfilled column, until the second player, with red discs, achieves a diagonal four in a row, and wins the game. If the board fills up before either player achieves four in a row, then the game is a draw.
|
||||
A Connect 4 game with AI for the ESP32-C3 (Lolin C3 Mini), using an 8x8 NeoPixel LED matrix and rotary encoder.
|
||||
|
||||
## Technical specifications
|
||||
## Hardware
|
||||
|
||||
The game board is an 8 x 8 NeoPixel grid. To mark the limits of the board, the top row is off, in the row below the pixels are blue, and in the rightmost column's pixels are blue from the row below the empty row until the bottom row.
|
||||
The game consists of a rotary encoder, using the Encoder.h library connected to pins:
|
||||
```c++
|
||||
#define ENCODER_A 2
|
||||
#define ENCODER_B 3
|
||||
#define ENCODER_SW 4
|
||||
### Pin Mapping (Lolin C3 Mini)
|
||||
|
||||
| Component | ESP32-C3 Pin | Function |
|
||||
| :------------------- | :----------- | :------------------- |
|
||||
| **NeoPixel Matrix** | `GPIO 4` | Data Input (DIN) |
|
||||
| **Rotary Encoder A** | `GPIO 0` | Directional CLK |
|
||||
| **Rotary Encoder B** | `GPIO 1` | Directional DT |
|
||||
| **Encoder Button** | `GPIO 2` | Selection/Abort (SW) |
|
||||
|
||||
### LED Matrix Layout
|
||||
|
||||
The 8x8 matrix (64 LEDs) is used as follows:
|
||||
|
||||
```
|
||||
Row 0: [ col0 ] [ col1 ] [ col2 ] [ col3 ] [ col4 ] [ col5 ] [ col6 ] [ indicator ]
|
||||
Row 1: [ ---- ] [ ---- ] [ ---- ] [ ---- ] [ ---- ] [ ---- ] [ ---- ] [ border ]
|
||||
Row 2-7:[ game board: 7 columns x 6 rows ] [ border ]
|
||||
```
|
||||
|
||||
The grid is managed using the FastLED.h library and is connected to:
|
||||
```c++
|
||||
#define LED_PIN 6
|
||||
#define LED_WIDTH 8
|
||||
#define LED_HEIGHT 8
|
||||
#define NUM_LEDS (LED_WIDTH * LED_HEIGHT)
|
||||
#define LED_TYPE WS2812B
|
||||
#define COLOR_ORDER GRB
|
||||
- **Row 0 (columns 0-6):** Interaction row. Shows the current column selection cursor and AI thinking animation (pulsing disc).
|
||||
- **Row 0 (column 7):** Game mode indicator LED (dim). Yellow = player is yellow vs AI, Red = player is red vs AI, Blue = two player, Off = demo.
|
||||
- **Row 1 + Column 7:** Blue border frame (toggleable via `SHOW_BORDER` build flag). Glows softly during demo and finished states.
|
||||
- **Rows 2-7, Columns 0-6:** The 7x6 game board. Yellow and Red discs.
|
||||
- **LED index formula:** `index = (y * 8) + x`
|
||||
|
||||
## Game Modes
|
||||
|
||||
Use the rotary encoder to select a mode, press the button to start:
|
||||
|
||||
1. **Player vs AI (Yellow)** - Player plays yellow (first move), AI plays red.
|
||||
2. **Player vs AI (Red)** - AI plays yellow (first move), player plays red.
|
||||
3. **Two Player** - Two humans alternate turns. Yellow goes first.
|
||||
|
||||
### Demo Mode
|
||||
|
||||
When idle (no input for the configured timeout), the board enters demo mode where two AI players play against each other automatically. To make games more interesting, the two demo players are assigned different skill levels (asymmetric ply depths), so games frequently end in a win rather than a draw. Press the button or turn the encoder to exit demo mode and return to the menu.
|
||||
|
||||
### Animations
|
||||
|
||||
- **Disc drop:** Discs fall from the top with accelerating speed.
|
||||
- **Column movement:** Discs slide across the top row to the selected column.
|
||||
- **AI thinking:** The disc in the selected column pulses while the AI calculates.
|
||||
- **Win:** The winning four discs flash while the rest of the board dims. Displayed for 30 seconds.
|
||||
- **Draw:** All discs blink on and off.
|
||||
|
||||
## WiFi Admin Interface
|
||||
|
||||
The ESP32 creates a WiFi access point:
|
||||
|
||||
- **Network:** Configured via `WIFI_SSID` build flag (default: `Connect4`)
|
||||
- **Password:** Configured via `WIFI_PASSWORD` build flag (default: `youlose4`)
|
||||
- **Admin page:** Connect to the network and open `http://192.168.4.1`
|
||||
|
||||
### Settings (via web interface)
|
||||
|
||||
| Setting | Description |
|
||||
| :--------------- | :------------------------------------------------------- |
|
||||
| **Base AI Ply** | Search depth for the AI (1-10). Higher = stronger. |
|
||||
| **Brightness** | LED brightness (0-255). |
|
||||
| **Idle Timeout** | Seconds of inactivity before demo mode starts. |
|
||||
| **Blunders** | AI randomly picks a bad move at the configured chance %. |
|
||||
|
||||
Settings are saved to flash (NVS) and persist across reboots.
|
||||
|
||||
### Game Log
|
||||
|
||||
The web interface shows a log of the last N games (configurable via `MAX_GAME_LOG` build flag). Each entry shows the game type, AI level, winner, and the sequence of columns played. Games where a player beats the computer are highlighted in red. The game log is persisted to flash and survives reboots.
|
||||
|
||||
## Build & Upload
|
||||
|
||||
Requires [PlatformIO](https://platformio.org/) CLI or VS Code with PlatformIO extension.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
pio run
|
||||
|
||||
# Upload to board
|
||||
pio run --target upload
|
||||
|
||||
# Monitor serial output
|
||||
pio device monitor
|
||||
```
|
||||
|
||||
Players play by turning the rotary encoder to choose a column by rotating the encoder. When they press the button, the disc is dropped and falls to the lowest free position in the grid. If a column is full and a player tries to drop a disc, the disc at the top (column selection row) blinks, indicating that no disc can be dropped. If the player rotates the encoder to a non-full column, a disc can be played.
|
||||
### Dependencies
|
||||
|
||||
A win is when one of the players achieves four connecting discs of the same color: horizontal, vertical, or diagonal. If a player wins, all the discs on the board a dimmed to 15% intensity, and the winning four discs are blinking at high intensity.
|
||||
- [FastLED](https://github.com/FastLED/FastLED) >= 3.6.0 - LED control
|
||||
- [Encoder](https://github.com/PaulStoffregen/Encoder) >= 1.4.4 - Rotary encoder input
|
||||
- Preferences (built-in) - Persistent settings storage
|
||||
- WiFi / WebServer (built-in) - Admin interface
|
||||
|
||||
If the board is full and none of the players win, a draw, then all the discs on the board are dimmed, and a blinking animation indicates a draw.
|
||||
### Build Flags
|
||||
|
||||
The game has four states:
|
||||
1. Menu: Here the players choose the type of game they want to play, 1 or 2 player. This is indicated on the board by a single yellow vertical bar in the center column (single player versus computer) or two vertical bars: two player game. Rotating the encoder switches between game modes, and pressing the button selects the game mode.
|
||||
2. Game play: Players are playing the game until one of the players connects four or they achieve a draw.
|
||||
3. Game over: One of the players connects four, or they achieve a draw. If the rotary encoder button is pressed, this state switches to the Menu state.
|
||||
4. Demo mode: The computer plays against itself. This mode is automatically triggered if there is no input for 60 seconds. If the rotary encoder is turned or pushed, the demo mode exits and the game returns to the menu state.
|
||||
All configurable parameters are defined as `-D` flags in `platformio.ini`:
|
||||
|
||||
The program initializes the SerialPrint output (baud 115200) and outputs useful (debugging) information regarding the game state and selections.
|
||||
| Flag | Default | Description |
|
||||
| :--------------------- | :--------- | :------------------------------------------------- |
|
||||
| `LED_PIN` | `4` | GPIO pin for NeoPixel data line |
|
||||
| `ENC_A` | `0` | GPIO pin for encoder CLK |
|
||||
| `ENC_B` | `1` | GPIO pin for encoder DT |
|
||||
| `ENC_SW` | `2` | GPIO pin for encoder button |
|
||||
| `SENSITIVITY` | `4` | Encoder steps per detent (higher = less sensitive) |
|
||||
| `SHOW_BORDER` | `1` | Show blue border frame (0 = off, 1 = on) |
|
||||
| `DEFAULT_LOOK_AHEAD` | `8` | Default AI search depth (plies) |
|
||||
| `DEFAULT_BRIGHTNESS` | `25` | Default LED brightness (0-255) |
|
||||
| `DEFAULT_IDLE_TIMEOUT` | `45` | Seconds before demo mode activates |
|
||||
| `DEMO_RESET_PAUSE` | `30000` | Milliseconds before finished game enters demo |
|
||||
| `MAX_GAME_LOG` | `5` | Number of games stored in the game log |
|
||||
| `WIFI_SSID` | `Connect4` | SSID for the WiFi access point |
|
||||
| `WIFI_PASSWORD` | `youlose4` | Password for the WiFi access point |
|
||||
|
||||
## AI Strategy
|
||||
|
||||
The AI uses **minimax with alpha-beta pruning** and a **heuristic evaluation function**. Moves are selected in three phases:
|
||||
|
||||
1. **Instant win/block** — scan all columns for an immediate win first, then for an opponent threat to block.
|
||||
2. **Blunder** (optional) — random move at a configurable chance, skipping the deep search.
|
||||
3. **Deep minimax search** — full tree search with alpha-beta pruning up to the configured ply depth.
|
||||
|
||||
The heuristic evaluates leaf nodes by scoring all 69 possible four-cell windows on the board:
|
||||
|
||||
- **Playable threats** (3-in-a-row where the gap can be filled now): ±100
|
||||
- **Non-playable threats** (gap is floating in the air): ±40
|
||||
- **Two-in-a-row setups**: ±5
|
||||
- **Center column control**: ±3 per piece
|
||||
- **Fork bonus** (2+ simultaneous three-in-a-row threats): ±200
|
||||
|
||||
See `Background information.md` / `Achtergrondinformatie.md` for a detailed explanation accessible to all ages.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/main.cpp ESP32 application (game logic, AI, LED, web server)
|
||||
connect_four.js JavaScript browser edition (canvas rendering)
|
||||
connect_four.html HTML wrapper for the JavaScript version
|
||||
connect_four.py Python terminal edition (Rich TUI)
|
||||
platformio.ini Build configuration, pin mappings, and tunable parameters
|
||||
README.md This file - technical and practical information
|
||||
Background information.md How the AI works (English, suitable for all ages)
|
||||
Achtergrondinformatie.md How the AI works (Dutch, suitable for all ages)
|
||||
CLAUDE.md AI assistant project context
|
||||
```
|
||||
|
||||
All three implementations (C++, JavaScript, Python) share the same AI algorithm and heuristic.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connect Four</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
canvas {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
<script src="connect_four.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+871
@@ -0,0 +1,871 @@
|
||||
/* ============================================================
|
||||
* Connect Four — Browser Edition
|
||||
* A single-file game: AI (minimax + alpha-beta + heuristic), demo mode,
|
||||
* game log (localStorage), blunder mode, idle timeout.
|
||||
*
|
||||
* Include this script in an HTML page that has:
|
||||
* <canvas id="gameCanvas"></canvas>
|
||||
*
|
||||
* Works in Firefox, Chrome, Edge, Safari, and Brave.
|
||||
* ============================================================ */
|
||||
|
||||
// --- Configurable Parameters --------------------------------
|
||||
const COLS = 7; // board columns
|
||||
const ROWS = 6; // board rows
|
||||
const LOOK_AHEAD = 8; // AI search depth (plies)
|
||||
const BLUNDER_ENABLED = false; // allow random AI mistakes
|
||||
const BLUNDER_CHANCE = 20; // percent chance of blunder (0-100)
|
||||
const DEMO_RESET_PAUSE = 5; // seconds before auto-demo after game end
|
||||
const IDLE_TIMEOUT = 60; // seconds of inactivity before demo starts
|
||||
const MAX_GAME_LOG = 100; // max stored game entries (localStorage)
|
||||
|
||||
// --- Visual Parameters --------------------------------------
|
||||
const CELL_SIZE = 70; // pixel size of each board cell
|
||||
const DISC_RADIUS = 28; // radius of a disc
|
||||
const BOARD_PAD_TOP = 100; // space above the board (cursor + col numbers)
|
||||
const BOARD_PAD_X = 40; // horizontal padding
|
||||
const BOARD_PAD_BOTTOM = 40; // space below the board
|
||||
const ANIM_DROP_SPEED = 1200; // pixels per second for drop animation
|
||||
const FONT_FAMILY = "system-ui, -apple-system, sans-serif";
|
||||
|
||||
// --- Colors -------------------------------------------------
|
||||
const COLOR_BG = "#1a1a2e";
|
||||
const COLOR_BOARD = "#16213e";
|
||||
const COLOR_GRID_LINE = "#0f3460";
|
||||
const COLOR_EMPTY = "#0a1628";
|
||||
const COLOR_P1 = "#ffd700"; // Yellow (player 1)
|
||||
const COLOR_P2 = "#e63946"; // Red (player 2)
|
||||
const COLOR_P1_DIM = "#8b7500";
|
||||
const COLOR_P2_DIM = "#7a1f26";
|
||||
const COLOR_HIGHLIGHT = "#ffffff";
|
||||
const COLOR_TEXT = "#e0e0e0";
|
||||
const COLOR_TEXT_DIM = "#666680";
|
||||
const COLOR_MENU_BG = "#1a1a2e";
|
||||
const COLOR_MENU_SEL = "#0f3460";
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const COL_ORDER = [3, 2, 4, 1, 5, 0, 6];
|
||||
|
||||
const State = Object.freeze({
|
||||
MENU: 0,
|
||||
PLAYING: 1,
|
||||
AI_TURN: 2,
|
||||
FINISHED_WIN: 3,
|
||||
FINISHED_DRAW: 4,
|
||||
DEMO: 5,
|
||||
});
|
||||
|
||||
// --- Canvas setup -------------------------------------------
|
||||
const canvas = document.getElementById("gameCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const BOARD_W = COLS * CELL_SIZE;
|
||||
const BOARD_H = ROWS * CELL_SIZE;
|
||||
const CANVAS_W = BOARD_W + BOARD_PAD_X * 2;
|
||||
const CANVAS_H = BOARD_PAD_TOP + BOARD_H + BOARD_PAD_BOTTOM;
|
||||
|
||||
canvas.width = CANVAS_W;
|
||||
canvas.height = CANVAS_H;
|
||||
canvas.style.display = "block";
|
||||
canvas.style.margin = "0 auto";
|
||||
canvas.tabIndex = 0;
|
||||
canvas.focus();
|
||||
|
||||
// --- Game state ---------------------------------------------
|
||||
let board = makeBoard();
|
||||
let gameState = State.MENU;
|
||||
let menuMode = 0;
|
||||
let currentPlayer = 1;
|
||||
let activeCol = 3;
|
||||
let winnerPlayer = 0;
|
||||
let winPositions = [];
|
||||
let currentMoves = "";
|
||||
let gameMenuMode = 0;
|
||||
let gameLevel = LOOK_AHEAD;
|
||||
let games = loadGameLog();
|
||||
let demoPly = [4, 4];
|
||||
let lastActivity = performance.now() / 1000;
|
||||
let demoResetTimer = 0;
|
||||
let flashToggle = true;
|
||||
let lastFlash = 0;
|
||||
let hoverCol = -1;
|
||||
|
||||
// Drop animation state
|
||||
let dropping = false;
|
||||
let dropCol = -1;
|
||||
let dropPlayer = 0;
|
||||
let dropTargetRow = -1;
|
||||
let dropY = 0;
|
||||
let dropTargetY = 0;
|
||||
|
||||
// --- Board helpers ------------------------------------------
|
||||
function makeBoard() {
|
||||
const b = [];
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
b[c] = new Array(ROWS).fill(0);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
board = makeBoard();
|
||||
winnerPlayer = 0;
|
||||
winPositions = [];
|
||||
currentMoves = "";
|
||||
}
|
||||
|
||||
function getFirstEmptyRow(b, col) {
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
if (b[col][r] === 0) return r;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isBoardFull(b) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
if (b[c][ROWS - 1] === 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function scanBoard(b) {
|
||||
function check(c, r, dc, dr) {
|
||||
const p = b[c][r];
|
||||
if (p === 0) return [0, []];
|
||||
const pos = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const cc = c + i * dc;
|
||||
const rr = r + i * dr;
|
||||
if (cc < 0 || cc >= COLS || rr < 0 || rr >= ROWS) return [0, []];
|
||||
if (b[cc][rr] !== p) return [0, []];
|
||||
pos.push([cc, rr]);
|
||||
}
|
||||
return [p, pos];
|
||||
}
|
||||
|
||||
for (let r = 0; r < ROWS; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++) {
|
||||
const [w, pos] = check(c, r, 1, 0);
|
||||
if (w) return [w, pos];
|
||||
}
|
||||
for (let r = 0; r <= ROWS - 4; r++)
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const [w, pos] = check(c, r, 0, 1);
|
||||
if (w) return [w, pos];
|
||||
}
|
||||
for (let r = 0; r <= ROWS - 4; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++) {
|
||||
const [w, pos] = check(c, r, 1, 1);
|
||||
if (w) return [w, pos];
|
||||
}
|
||||
for (let r = 3; r < ROWS; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++) {
|
||||
const [w, pos] = check(c, r, 1, -1);
|
||||
if (w) return [w, pos];
|
||||
}
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
function evaluateBoard(b, aiP, huP) {
|
||||
let score = 0;
|
||||
let aiThreats = 0, huThreats = 0;
|
||||
|
||||
// Center column bonus
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
if (b[3][r] === aiP) score += 3;
|
||||
else if (b[3][r] === huP) score -= 3;
|
||||
}
|
||||
|
||||
// Score a window of 4 cells by piece counts
|
||||
function scoreWindow(c, r, dc, dr) {
|
||||
let ai = 0, hu = 0, emptyC = -1, emptyR = -1;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const cc = c + i * dc;
|
||||
const rr = r + i * dr;
|
||||
const v = b[cc][rr];
|
||||
if (v === aiP) ai++;
|
||||
else if (v === huP) hu++;
|
||||
else { emptyC = cc; emptyR = rr; }
|
||||
}
|
||||
if (ai > 0 && hu > 0) return 0;
|
||||
if (ai === 3) {
|
||||
aiThreats++;
|
||||
const playable = emptyR === 0 || b[emptyC][emptyR - 1] !== 0;
|
||||
return playable ? 100 : 40;
|
||||
}
|
||||
if (ai === 2) return 5;
|
||||
if (hu === 3) {
|
||||
huThreats++;
|
||||
const playable = emptyR === 0 || b[emptyC][emptyR - 1] !== 0;
|
||||
return playable ? -100 : -40;
|
||||
}
|
||||
if (hu === 2) return -5;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
for (let r = 0; r < ROWS; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++)
|
||||
score += scoreWindow(c, r, 1, 0);
|
||||
// Vertical
|
||||
for (let r = 0; r <= ROWS - 4; r++)
|
||||
for (let c = 0; c < COLS; c++)
|
||||
score += scoreWindow(c, r, 0, 1);
|
||||
// Diagonal up-right
|
||||
for (let r = 0; r <= ROWS - 4; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++)
|
||||
score += scoreWindow(c, r, 1, 1);
|
||||
// Diagonal down-right
|
||||
for (let r = 3; r < ROWS; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++)
|
||||
score += scoreWindow(c, r, 1, -1);
|
||||
|
||||
// Fork bonus: multiple threats are disproportionately dangerous
|
||||
if (aiThreats >= 2) score += 200;
|
||||
if (huThreats >= 2) score -= 200;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// --- AI -----------------------------------------------------
|
||||
function minimax(b, depth, alpha, beta, isMax, aiP, huP) {
|
||||
const [winner] = scanBoard(b);
|
||||
if (winner === aiP) return 1000 + depth;
|
||||
if (winner === huP) return -1000 - depth;
|
||||
if (depth === 0 || isBoardFull(b)) return evaluateBoard(b, aiP, huP);
|
||||
|
||||
let best = isMax ? -10000 : 10000;
|
||||
for (const c of COL_ORDER) {
|
||||
const r = getFirstEmptyRow(b, c);
|
||||
if (r === -1) continue;
|
||||
b[c][r] = isMax ? aiP : huP;
|
||||
const score = minimax(b, depth - 1, alpha, beta, !isMax, aiP, huP);
|
||||
b[c][r] = 0;
|
||||
if (isMax) {
|
||||
if (score > best) best = score;
|
||||
if (best > alpha) alpha = best;
|
||||
} else {
|
||||
if (score < best) best = score;
|
||||
if (best < beta) beta = best;
|
||||
}
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function performAiMove(b, aiP, lookAhead, isDemo = false, dPly = 4) {
|
||||
const huP = aiP === 1 ? 2 : 1;
|
||||
const ply = isDemo ? dPly : lookAhead;
|
||||
|
||||
// Phase 1a: check ALL columns for instant AI win
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const r = getFirstEmptyRow(b, c);
|
||||
if (r === -1) continue;
|
||||
b[c][r] = aiP;
|
||||
if (scanBoard(b)[0] === aiP) { b[c][r] = 0; return c; }
|
||||
b[c][r] = 0;
|
||||
}
|
||||
|
||||
// Phase 1b: check ALL columns for opponent block
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const r = getFirstEmptyRow(b, c);
|
||||
if (r === -1) continue;
|
||||
b[c][r] = huP;
|
||||
if (scanBoard(b)[0] === huP) { b[c][r] = 0; return c; }
|
||||
b[c][r] = 0;
|
||||
}
|
||||
|
||||
// Phase 2: blunder
|
||||
if (!isDemo && BLUNDER_ENABLED && Math.random() * 100 < BLUNDER_CHANCE) {
|
||||
const valid = [];
|
||||
for (let c = 0; c < COLS; c++) if (getFirstEmptyRow(b, c) !== -1) valid.push(c);
|
||||
return valid[Math.floor(Math.random() * valid.length)];
|
||||
}
|
||||
|
||||
// Phase 3: minimax
|
||||
let bestScore = -30000;
|
||||
let bestCol = 3;
|
||||
for (const c of COL_ORDER) {
|
||||
const r = getFirstEmptyRow(b, c);
|
||||
if (r === -1) continue;
|
||||
b[c][r] = aiP;
|
||||
const score = minimax(b, ply, -30000, 30000, false, aiP, huP);
|
||||
b[c][r] = 0;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestCol = c;
|
||||
}
|
||||
}
|
||||
return bestCol;
|
||||
}
|
||||
|
||||
function randomizeDemoPlies() {
|
||||
const strong = 4 + Math.floor(Math.random() * 2);
|
||||
const weak = 2 + Math.floor(Math.random() * 2);
|
||||
return Math.random() < 0.5 ? [strong, weak] : [weak, strong];
|
||||
}
|
||||
|
||||
// --- Game log (localStorage) --------------------------------
|
||||
function loadGameLog() {
|
||||
try {
|
||||
const raw = localStorage.getItem("connectFourLog");
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw).slice(-MAX_GAME_LOG);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function saveGameLog(g) {
|
||||
try {
|
||||
localStorage.setItem("connectFourLog", JSON.stringify(g.slice(-MAX_GAME_LOG)));
|
||||
} catch { /* storage full or unavailable */ }
|
||||
}
|
||||
|
||||
function logGame(g, gMenuMode, level, winner, moves) {
|
||||
const type = gMenuMode === 0 ? "Y" : gMenuMode === 1 ? "R" : "2";
|
||||
const winChar = winner === 1 ? "Y" : winner === 2 ? "R" : "D";
|
||||
g.push({ type, level: String(level), winner: winChar, moves });
|
||||
g = g.slice(-MAX_GAME_LOG);
|
||||
saveGameLog(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
// --- Check game end -----------------------------------------
|
||||
function checkGameEnd() {
|
||||
const [w, pos] = scanBoard(board);
|
||||
winnerPlayer = w;
|
||||
winPositions = pos;
|
||||
const won = w !== 0;
|
||||
const draw = !won && isBoardFull(board);
|
||||
if (!won && !draw) return false;
|
||||
|
||||
if (gameState !== State.DEMO) {
|
||||
games = logGame(games, gameMenuMode, gameLevel, won ? w : 0, currentMoves);
|
||||
console.log(`Game: ${currentMoves} → ${won ? playerName(w) + " wins" : "Draw"}`);
|
||||
}
|
||||
gameState = won ? State.FINISHED_WIN : State.FINISHED_DRAW;
|
||||
demoResetTimer = performance.now() / 1000;
|
||||
lastActivity = performance.now() / 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Drawing ------------------------------------------------
|
||||
function playerColor(p) { return p === 1 ? COLOR_P1 : COLOR_P2; }
|
||||
function playerColorDim(p) { return p === 1 ? COLOR_P1_DIM : COLOR_P2_DIM; }
|
||||
function playerName(p) { return p === 1 ? "Yellow" : "Red"; }
|
||||
|
||||
function cellX(c) { return BOARD_PAD_X + c * CELL_SIZE + CELL_SIZE / 2; }
|
||||
function cellY(r) { return BOARD_PAD_TOP + (ROWS - 1 - r) * CELL_SIZE + CELL_SIZE / 2; }
|
||||
|
||||
function isWinPos(c, r) {
|
||||
for (const [wc, wr] of winPositions) {
|
||||
if (wc === c && wr === r) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function drawDisc(x, y, radius, color) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawBoard() {
|
||||
// Board background
|
||||
ctx.fillStyle = COLOR_BOARD;
|
||||
const bx = BOARD_PAD_X;
|
||||
const by = BOARD_PAD_TOP;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(bx - 5, by - 5, BOARD_W + 10, BOARD_H + 10, 12);
|
||||
ctx.fill();
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = COLOR_GRID_LINE;
|
||||
ctx.lineWidth = 1;
|
||||
for (let c = 1; c < COLS; c++) {
|
||||
const x = BOARD_PAD_X + c * CELL_SIZE;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, BOARD_PAD_TOP);
|
||||
ctx.lineTo(x, BOARD_PAD_TOP + BOARD_H);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let r = 1; r < ROWS; r++) {
|
||||
const y = BOARD_PAD_TOP + r * CELL_SIZE;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(BOARD_PAD_X, y);
|
||||
ctx.lineTo(BOARD_PAD_X + BOARD_W, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Cells
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const x = cellX(c);
|
||||
const y = cellY(r);
|
||||
const val = board[c][r];
|
||||
|
||||
// Skip drawing in cell if we're animating a drop into it
|
||||
if (dropping && c === dropCol && r === dropTargetRow) continue;
|
||||
|
||||
if (val === 0) {
|
||||
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
|
||||
} else {
|
||||
const isWin = isWinPos(c, r);
|
||||
if (gameState === State.FINISHED_WIN) {
|
||||
if (isWin && flashToggle) {
|
||||
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
|
||||
} else if (!isWin) {
|
||||
drawDisc(x, y, DISC_RADIUS, playerColorDim(val));
|
||||
} else {
|
||||
drawDisc(x, y, DISC_RADIUS, playerColor(val));
|
||||
}
|
||||
} else if (gameState === State.FINISHED_DRAW && flashToggle) {
|
||||
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
|
||||
} else {
|
||||
drawDisc(x, y, DISC_RADIUS, playerColor(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop animation disc
|
||||
if (dropping) {
|
||||
drawDisc(cellX(dropCol), dropY, DISC_RADIUS, playerColor(dropPlayer));
|
||||
}
|
||||
}
|
||||
|
||||
function drawCursor() {
|
||||
if (gameState === State.PLAYING && !dropping) {
|
||||
const x = cellX(activeCol);
|
||||
const y = BOARD_PAD_TOP - 45;
|
||||
drawDisc(x, y, DISC_RADIUS * 0.8, playerColor(currentPlayer));
|
||||
}
|
||||
if (gameState === State.PLAYING && hoverCol >= 0 && hoverCol !== activeCol && !dropping) {
|
||||
const x = cellX(hoverCol);
|
||||
const y = BOARD_PAD_TOP - 45;
|
||||
drawDisc(x, y, DISC_RADIUS * 0.5, playerColorDim(currentPlayer));
|
||||
}
|
||||
}
|
||||
|
||||
function drawColNumbers() {
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
ctx.fillStyle = (c === activeCol && gameState === State.PLAYING) ? COLOR_TEXT : COLOR_TEXT_DIM;
|
||||
ctx.fillText(String(c + 1), cellX(c), BOARD_PAD_TOP - 12);
|
||||
}
|
||||
}
|
||||
|
||||
function drawStatus() {
|
||||
const y = BOARD_PAD_TOP + BOARD_H + 25;
|
||||
ctx.font = `bold 18px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
if (gameState === State.PLAYING) {
|
||||
ctx.fillStyle = playerColor(currentPlayer);
|
||||
const label = gameMenuMode === 2 ? `${playerName(currentPlayer)}'s turn`
|
||||
: currentPlayer === (gameMenuMode === 0 ? 1 : 2) ? "Your turn" : "AI thinking...";
|
||||
ctx.fillText(label, CANVAS_W / 2, y);
|
||||
} else if (gameState === State.AI_TURN) {
|
||||
const aiP = gameMenuMode === 0 ? 2 : 1;
|
||||
ctx.fillStyle = playerColor(aiP);
|
||||
ctx.fillText("AI thinking...", CANVAS_W / 2, y);
|
||||
} else if (gameState === State.FINISHED_WIN) {
|
||||
ctx.fillStyle = playerColor(winnerPlayer);
|
||||
ctx.fillText(`${playerName(winnerPlayer)} wins!`, CANVAS_W / 2, y);
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.fillStyle = COLOR_TEXT_DIM;
|
||||
ctx.fillText("Click or press any key for menu", CANVAS_W / 2, y + 24);
|
||||
} else if (gameState === State.FINISHED_DRAW) {
|
||||
ctx.fillStyle = COLOR_TEXT;
|
||||
ctx.fillText("Draw!", CANVAS_W / 2, y);
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.fillStyle = COLOR_TEXT_DIM;
|
||||
ctx.fillText("Click or press any key for menu", CANVAS_W / 2, y + 24);
|
||||
} else if (gameState === State.DEMO) {
|
||||
ctx.fillStyle = COLOR_TEXT_DIM;
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.fillText("Demo mode - click or press any key for menu", CANVAS_W / 2, y);
|
||||
}
|
||||
}
|
||||
|
||||
function drawMenu() {
|
||||
ctx.fillStyle = COLOR_MENU_BG;
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
ctx.font = `bold 36px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = COLOR_P1;
|
||||
ctx.fillText("Connect", CANVAS_W / 2 - 60, 80);
|
||||
ctx.fillStyle = COLOR_P2;
|
||||
ctx.fillText("Four", CANVAS_W / 2 + 70, 80);
|
||||
|
||||
const items = [
|
||||
{ label: "1P Yellow (you start)", color: COLOR_P1 },
|
||||
{ label: "1P Red (AI starts)", color: COLOR_P2 },
|
||||
{ label: "Multiplayer", color: "#5dade2" },
|
||||
];
|
||||
|
||||
const startY = 160;
|
||||
const itemH = 60;
|
||||
const itemW = 340;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const y = startY + i * itemH;
|
||||
const x = (CANVAS_W - itemW) / 2;
|
||||
const selected = i === menuMode;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = selected ? COLOR_MENU_SEL : "transparent";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, itemW, 48, 8);
|
||||
ctx.fill();
|
||||
|
||||
// Border for selected
|
||||
if (selected) {
|
||||
ctx.strokeStyle = items[i].color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, itemW, 48, 8);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Arrow
|
||||
ctx.font = `bold 20px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = selected ? items[i].color : COLOR_TEXT_DIM;
|
||||
ctx.fillText(selected ? "\u25b6 " : " ", x + 16, y + 24);
|
||||
|
||||
// Label
|
||||
ctx.font = `${selected ? "bold " : ""}18px ${FONT_FAMILY}`;
|
||||
ctx.fillText(items[i].label, x + 50, y + 24);
|
||||
}
|
||||
|
||||
// Instructions
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = COLOR_TEXT_DIM;
|
||||
ctx.fillText("Up/Down or hover to select, click or Enter to start", CANVAS_W / 2, startY + items.length * itemH + 20);
|
||||
ctx.fillText("During game: Arrow keys or click columns, 1-7 for direct drop", CANVAS_W / 2, startY + items.length * itemH + 44);
|
||||
}
|
||||
|
||||
function render() {
|
||||
ctx.fillStyle = COLOR_BG;
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
if (gameState === State.MENU) {
|
||||
drawMenu();
|
||||
} else {
|
||||
drawBoard();
|
||||
drawCursor();
|
||||
drawColNumbers();
|
||||
drawStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drop animation -----------------------------------------
|
||||
function animateDrop(col, row, player) {
|
||||
return new Promise(resolve => {
|
||||
dropping = true;
|
||||
dropCol = col;
|
||||
dropPlayer = player;
|
||||
dropTargetRow = row;
|
||||
dropY = BOARD_PAD_TOP - 45;
|
||||
dropTargetY = cellY(row);
|
||||
|
||||
function step(timestamp) {
|
||||
dropY += ANIM_DROP_SPEED * (1 / 60);
|
||||
if (dropY >= dropTargetY) {
|
||||
dropY = dropTargetY;
|
||||
dropping = false;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Input: column from mouse / touch -----------------------
|
||||
function colFromEvent(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CANVAS_W / rect.width;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const col = Math.floor((x - BOARD_PAD_X) / CELL_SIZE);
|
||||
return (col >= 0 && col < COLS) ? col : -1;
|
||||
}
|
||||
|
||||
function menuItemFromEvent(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleY = CANVAS_H / rect.height;
|
||||
const scaleX = CANVAS_W / rect.width;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const startY = 160;
|
||||
const itemH = 60;
|
||||
const itemW = 340;
|
||||
const mx = (CANVAS_W - itemW) / 2;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const iy = startY + i * itemH;
|
||||
if (x >= mx && x <= mx + itemW && y >= iy && y <= iy + 48) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// --- Place a disc (with animation) --------------------------
|
||||
let busy = false; // prevents input during animation / AI
|
||||
|
||||
async function placeDisk(col, player) {
|
||||
const r = getFirstEmptyRow(board, col);
|
||||
if (r === -1) return false;
|
||||
currentMoves += String(col);
|
||||
await animateDrop(col, r, player);
|
||||
board[col][r] = player;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- AI turn (async to not block UI) ------------------------
|
||||
async function doAiTurn() {
|
||||
busy = true;
|
||||
const aiP = gameMenuMode === 0 ? 2 : 1;
|
||||
gameState = State.AI_TURN;
|
||||
|
||||
// Yield a frame so "AI thinking" shows
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const bestCol = performAiMove(board, aiP, LOOK_AHEAD);
|
||||
await placeDisk(bestCol, aiP);
|
||||
activeCol = bestCol;
|
||||
|
||||
if (!checkGameEnd()) {
|
||||
gameState = State.PLAYING;
|
||||
currentPlayer = aiP === 1 ? 2 : 1;
|
||||
}
|
||||
lastActivity = performance.now() / 1000;
|
||||
busy = false;
|
||||
}
|
||||
|
||||
// --- Demo turn ----------------------------------------------
|
||||
let demoTimer = null;
|
||||
|
||||
function stopDemo() {
|
||||
if (demoTimer !== null) {
|
||||
clearTimeout(demoTimer);
|
||||
demoTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function demoStep() {
|
||||
if (gameState !== State.DEMO) return;
|
||||
busy = true;
|
||||
const ply = demoPly[currentPlayer - 1];
|
||||
const bestCol = performAiMove(board, currentPlayer, LOOK_AHEAD, true, ply);
|
||||
await placeDisk(bestCol, currentPlayer);
|
||||
|
||||
if (!checkGameEnd()) {
|
||||
currentPlayer = currentPlayer === 1 ? 2 : 1;
|
||||
demoTimer = setTimeout(demoStep, 400);
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
function startDemo() {
|
||||
resetGame();
|
||||
demoPly = randomizeDemoPlies();
|
||||
gameState = State.DEMO;
|
||||
currentPlayer = 1;
|
||||
lastActivity = performance.now() / 1000;
|
||||
demoTimer = setTimeout(demoStep, 400);
|
||||
}
|
||||
|
||||
// --- Start game from menu -----------------------------------
|
||||
function startGame(mode) {
|
||||
resetGame();
|
||||
gameMenuMode = mode;
|
||||
gameLevel = LOOK_AHEAD;
|
||||
currentPlayer = 1;
|
||||
activeCol = 3;
|
||||
hoverCol = -1;
|
||||
|
||||
if (mode === 1) {
|
||||
gameState = State.PLAYING; // briefly, then AI
|
||||
doAiTurn();
|
||||
} else {
|
||||
gameState = State.PLAYING;
|
||||
}
|
||||
lastActivity = performance.now() / 1000;
|
||||
}
|
||||
|
||||
function returnToMenu() {
|
||||
stopDemo();
|
||||
resetGame();
|
||||
gameState = State.MENU;
|
||||
menuMode = 0;
|
||||
lastActivity = performance.now() / 1000;
|
||||
}
|
||||
|
||||
// --- Mouse events -------------------------------------------
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
if (gameState === State.MENU) {
|
||||
const mi = menuItemFromEvent(e);
|
||||
if (mi >= 0) menuMode = mi;
|
||||
} else if (gameState === State.PLAYING && !busy) {
|
||||
hoverCol = colFromEvent(e);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("click", async (e) => {
|
||||
if (busy) return;
|
||||
lastActivity = performance.now() / 1000;
|
||||
|
||||
if (gameState === State.MENU) {
|
||||
const mi = menuItemFromEvent(e);
|
||||
if (mi >= 0) {
|
||||
menuMode = mi;
|
||||
startGame(mi);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW || gameState === State.DEMO) {
|
||||
returnToMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.PLAYING) {
|
||||
const col = colFromEvent(e);
|
||||
if (col < 0) return;
|
||||
const r = getFirstEmptyRow(board, col);
|
||||
if (r === -1) return;
|
||||
|
||||
busy = true;
|
||||
activeCol = col;
|
||||
await placeDisk(col, currentPlayer);
|
||||
|
||||
if (!checkGameEnd()) {
|
||||
if (gameMenuMode < 2) {
|
||||
await doAiTurn();
|
||||
} else {
|
||||
currentPlayer = currentPlayer === 1 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Touch support (mobile) ---------------------------------
|
||||
canvas.addEventListener("touchend", (e) => {
|
||||
if (e.changedTouches.length > 0) {
|
||||
const touch = e.changedTouches[0];
|
||||
const click = new MouseEvent("click", {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
canvas.dispatchEvent(click);
|
||||
}
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
|
||||
// --- Keyboard events ----------------------------------------
|
||||
document.addEventListener("keydown", async (e) => {
|
||||
if (busy) return;
|
||||
lastActivity = performance.now() / 1000;
|
||||
|
||||
if (e.key === "q" || e.key === "Q") {
|
||||
if (gameState !== State.MENU) {
|
||||
returnToMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.MENU) {
|
||||
if (e.key === "ArrowUp") {
|
||||
menuMode = (menuMode - 1 + 3) % 3;
|
||||
} else if (e.key === "ArrowDown") {
|
||||
menuMode = (menuMode + 1) % 3;
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
startGame(menuMode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW || gameState === State.DEMO) {
|
||||
returnToMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.PLAYING) {
|
||||
if (e.key === "ArrowLeft") {
|
||||
activeCol = Math.max(0, activeCol - 1);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
activeCol = Math.min(COLS - 1, activeCol + 1);
|
||||
} else if (e.key >= "1" && e.key <= "7") {
|
||||
const col = parseInt(e.key) - 1;
|
||||
const r = getFirstEmptyRow(board, col);
|
||||
if (r === -1) return;
|
||||
|
||||
busy = true;
|
||||
activeCol = col;
|
||||
await placeDisk(col, currentPlayer);
|
||||
if (!checkGameEnd()) {
|
||||
if (gameMenuMode < 2) {
|
||||
await doAiTurn();
|
||||
} else {
|
||||
currentPlayer = currentPlayer === 1 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
const r = getFirstEmptyRow(board, activeCol);
|
||||
if (r === -1) return;
|
||||
|
||||
busy = true;
|
||||
await placeDisk(activeCol, currentPlayer);
|
||||
if (!checkGameEnd()) {
|
||||
if (gameMenuMode < 2) {
|
||||
await doAiTurn();
|
||||
} else {
|
||||
currentPlayer = currentPlayer === 1 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Main loop ----------------------------------------------
|
||||
let lastTime = 0;
|
||||
|
||||
function gameLoop(timestamp) {
|
||||
const now = timestamp / 1000;
|
||||
|
||||
// Flash toggle for win/draw
|
||||
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW) {
|
||||
if (now - lastFlash > 0.4) {
|
||||
lastFlash = now;
|
||||
flashToggle = !flashToggle;
|
||||
}
|
||||
|
||||
// Auto-restart to demo
|
||||
if (now - demoResetTimer > DEMO_RESET_PAUSE) {
|
||||
startDemo();
|
||||
}
|
||||
}
|
||||
|
||||
// Idle timeout -> demo
|
||||
if (gameState !== State.DEMO && gameState !== State.FINISHED_WIN && gameState !== State.FINISHED_DRAW) {
|
||||
if (now - lastActivity > IDLE_TIMEOUT) {
|
||||
startDemo();
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
"""Connect Four terminal game with AI (minimax + alpha-beta + heuristic), using Rich for display."""
|
||||
|
||||
import os
|
||||
import queue
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
|
||||
import readchar
|
||||
from dotenv import load_dotenv
|
||||
from rich.console import Console, Group
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
|
||||
load_dotenv(Path(__file__).parent / ".env")
|
||||
|
||||
# --- Configuration from .env ---
|
||||
LOOK_AHEAD = int(os.getenv("LOOK_AHEAD", "8"))
|
||||
BLUNDER_ENABLED = os.getenv("BLUNDER_ENABLED", "false").lower() == "true"
|
||||
BLUNDER_CHANCE = int(os.getenv("BLUNDER_CHANCE", "20"))
|
||||
DEMO_RESET_PAUSE = int(os.getenv("DEMO_RESET_PAUSE", "5"))
|
||||
IDLE_TIMEOUT = int(os.getenv("IDLE_TIMEOUT", "60"))
|
||||
MAX_GAME_LOG = int(os.getenv("MAX_GAME_LOG", "100"))
|
||||
GAMES_FILE = Path(__file__).parent / ".games.txt"
|
||||
|
||||
COLS = 7
|
||||
ROWS = 6
|
||||
COL_ORDER = [3, 2, 4, 1, 5, 0, 6]
|
||||
|
||||
# Box-drawing characters for the board frame
|
||||
DISC = "\u2b24"
|
||||
EMPTY = "\u25cb"
|
||||
H_LINE = "\u2500"
|
||||
V_LINE = "\u2502"
|
||||
TL = "\u250c"
|
||||
TR = "\u2510"
|
||||
BL = "\u2514"
|
||||
BR = "\u2518"
|
||||
T_DOWN = "\u252c"
|
||||
T_UP = "\u2534"
|
||||
T_RIGHT = "\u251c"
|
||||
T_LEFT = "\u2524"
|
||||
CROSS = "\u253c"
|
||||
|
||||
console = Console()
|
||||
|
||||
# Key constants - readchar uses escape sequences
|
||||
KEY_LEFT = readchar.key.LEFT if hasattr(readchar.key, "LEFT") else "\x1b[D"
|
||||
KEY_RIGHT = readchar.key.RIGHT if hasattr(readchar.key, "RIGHT") else "\x1b[C"
|
||||
KEY_UP = readchar.key.UP if hasattr(readchar.key, "UP") else "\x1b[A"
|
||||
KEY_DOWN = readchar.key.DOWN if hasattr(readchar.key, "DOWN") else "\x1b[B"
|
||||
KEY_ENTER = readchar.key.ENTER if hasattr(readchar.key, "ENTER") else "\r"
|
||||
CONFIRM_KEYS = {KEY_ENTER, " ", "\r", "\n"}
|
||||
|
||||
|
||||
class State(Enum):
|
||||
MENU = auto()
|
||||
PLAYING = auto()
|
||||
AI_TURN = auto()
|
||||
FINISHED_WIN = auto()
|
||||
FINISHED_DRAW = auto()
|
||||
DEMO = auto()
|
||||
|
||||
|
||||
def player_name(player: int) -> str:
|
||||
return "Yellow" if player == 1 else "Red"
|
||||
|
||||
|
||||
def player_style(player: int) -> str:
|
||||
return "bold yellow" if player == 1 else "bold red"
|
||||
|
||||
|
||||
def dim_player_style(player: int) -> str:
|
||||
return "dim yellow" if player == 1 else "dim red"
|
||||
|
||||
|
||||
# --- Board ---
|
||||
|
||||
def make_board() -> list[list[int]]:
|
||||
return [[0] * ROWS for _ in range(COLS)]
|
||||
|
||||
|
||||
def get_first_empty_row(board: list[list[int]], col: int) -> int:
|
||||
for r in range(ROWS):
|
||||
if board[col][r] == 0:
|
||||
return r
|
||||
return -1
|
||||
|
||||
|
||||
def is_board_full(board: list[list[int]]) -> bool:
|
||||
return all(board[c][ROWS - 1] != 0 for c in range(COLS))
|
||||
|
||||
|
||||
def scan_board(board: list[list[int]]) -> tuple[int, list[tuple[int, int]]]:
|
||||
"""Returns (winner, winning_positions). winner=0 if no winner."""
|
||||
def check(c, r, dc, dr):
|
||||
p = board[c][r]
|
||||
if p != 0:
|
||||
positions = [(c + i * dc, r + i * dr) for i in range(4)]
|
||||
if all(board[cc][rr] == p for cc, rr in positions):
|
||||
return p, positions
|
||||
return 0, []
|
||||
|
||||
for r in range(ROWS):
|
||||
for c in range(COLS - 3):
|
||||
w, pos = check(c, r, 1, 0)
|
||||
if w:
|
||||
return w, pos
|
||||
for r in range(ROWS - 3):
|
||||
for c in range(COLS):
|
||||
w, pos = check(c, r, 0, 1)
|
||||
if w:
|
||||
return w, pos
|
||||
for r in range(ROWS - 3):
|
||||
for c in range(COLS - 3):
|
||||
w, pos = check(c, r, 1, 1)
|
||||
if w:
|
||||
return w, pos
|
||||
for r in range(3, ROWS):
|
||||
for c in range(COLS - 3):
|
||||
w, pos = check(c, r, 1, -1)
|
||||
if w:
|
||||
return w, pos
|
||||
return 0, []
|
||||
|
||||
|
||||
# --- Display ---
|
||||
|
||||
def render_board(
|
||||
board: list[list[int]],
|
||||
active_col: int = -1,
|
||||
current_player: int = 0,
|
||||
win_positions: list[tuple[int, int]] | None = None,
|
||||
flash_off: bool = False,
|
||||
is_draw_flash: bool = False,
|
||||
thinking_col: int = -1,
|
||||
thinking_bright: bool = False,
|
||||
) -> Text:
|
||||
cell_w = 4 # width per cell including padding
|
||||
|
||||
lines = Text()
|
||||
|
||||
# Cursor row above the board
|
||||
cursor_line = Text(" ")
|
||||
for c in range(COLS):
|
||||
if thinking_col == c:
|
||||
style = player_style(current_player) if thinking_bright else dim_player_style(current_player)
|
||||
cursor_line.append(f" {DISC} ", style=style)
|
||||
elif c == active_col and current_player > 0:
|
||||
cursor_line.append(f" {DISC} ", style=player_style(current_player))
|
||||
else:
|
||||
cursor_line.append(" ")
|
||||
lines.append_text(cursor_line)
|
||||
lines.append("\n")
|
||||
|
||||
# Column numbers row
|
||||
num_line = Text(" ")
|
||||
for c in range(COLS):
|
||||
style = "bold white" if c == active_col else "dim"
|
||||
num_line.append(f" {c + 1} ", style=style)
|
||||
lines.append_text(num_line)
|
||||
lines.append("\n")
|
||||
|
||||
# Top border
|
||||
top = Text(" ", style="bold blue")
|
||||
top.append(TL, style="bold blue")
|
||||
for c in range(COLS):
|
||||
top.append(H_LINE * (cell_w - 1), style="bold blue")
|
||||
top.append(T_DOWN if c < COLS - 1 else TR, style="bold blue")
|
||||
lines.append_text(top)
|
||||
lines.append("\n")
|
||||
|
||||
# Board rows (top row of board = row 5, displayed first)
|
||||
for r in range(ROWS - 1, -1, -1):
|
||||
row_line = Text(" ", style="bold blue")
|
||||
for c in range(COLS):
|
||||
row_line.append(V_LINE, style="bold blue")
|
||||
val = board[c][r]
|
||||
if val == 0:
|
||||
row_line.append(f" {EMPTY} ", style="dim blue")
|
||||
else:
|
||||
is_win = win_positions and (c, r) in win_positions
|
||||
if flash_off and is_win:
|
||||
row_line.append(" ")
|
||||
elif is_draw_flash and flash_off:
|
||||
row_line.append(" ")
|
||||
elif not is_win and win_positions:
|
||||
row_line.append(f" {DISC} ", style=dim_player_style(val))
|
||||
else:
|
||||
row_line.append(f" {DISC} ", style=player_style(val))
|
||||
row_line.append(V_LINE, style="bold blue")
|
||||
lines.append_text(row_line)
|
||||
lines.append("\n")
|
||||
|
||||
# Row separator or bottom border
|
||||
if r > 0:
|
||||
sep = Text(" ", style="bold blue")
|
||||
sep.append(T_RIGHT, style="bold blue")
|
||||
for c in range(COLS):
|
||||
sep.append(H_LINE * (cell_w - 1), style="bold blue")
|
||||
sep.append(CROSS if c < COLS - 1 else T_LEFT, style="bold blue")
|
||||
lines.append_text(sep)
|
||||
lines.append("\n")
|
||||
|
||||
# Bottom border
|
||||
bot = Text(" ", style="bold blue")
|
||||
bot.append(BL, style="bold blue")
|
||||
for c in range(COLS):
|
||||
bot.append(H_LINE * (cell_w - 1), style="bold blue")
|
||||
bot.append(T_UP if c < COLS - 1 else BR, style="bold blue")
|
||||
lines.append_text(bot)
|
||||
lines.append("\n")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def render_menu(menu_mode: int) -> Text:
|
||||
items = ["1P Yellow (you start)", "1P Red (AI starts)", "Multiplayer"]
|
||||
lines = ["\n [bold blue]Connect Four[/bold blue]\n"]
|
||||
for i, item in enumerate(items):
|
||||
marker = " \u25b6 " if i == menu_mode else " "
|
||||
style = "bold yellow" if i == 0 else "bold red" if i == 1 else "bold blue"
|
||||
if i == menu_mode:
|
||||
lines.append(f"[{style}]{marker}{item}[/{style}]")
|
||||
else:
|
||||
lines.append(f"[dim]{marker}{item}[/dim]")
|
||||
lines.append("\n [dim]Up/Down to select, Space/Enter to start, Q to quit[/dim]\n")
|
||||
return Text.from_markup("\n".join(lines))
|
||||
|
||||
|
||||
# --- Game log ---
|
||||
|
||||
def load_game_log() -> list[dict]:
|
||||
if not GAMES_FILE.exists():
|
||||
return []
|
||||
games = []
|
||||
for line in GAMES_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(":", 3)
|
||||
if len(parts) == 4:
|
||||
games.append({
|
||||
"type": parts[0],
|
||||
"level": parts[1],
|
||||
"winner": parts[2],
|
||||
"moves": parts[3],
|
||||
})
|
||||
return games[-MAX_GAME_LOG:]
|
||||
|
||||
|
||||
def save_game_log(games: list[dict]):
|
||||
with GAMES_FILE.open("w") as f:
|
||||
for g in games:
|
||||
f.write(f"{g['type']}:{g['level']}:{g['winner']}:{g['moves']}\n")
|
||||
|
||||
|
||||
def log_game(games: list[dict], game_menu_mode: int, level: int, winner: int, moves: str) -> list[dict]:
|
||||
game_type = "Y" if game_menu_mode == 0 else "R" if game_menu_mode == 1 else "2"
|
||||
win_char = "Y" if winner == 1 else "R" if winner == 2 else "D"
|
||||
entry = {"type": game_type, "level": str(level), "winner": win_char, "moves": moves}
|
||||
games.append(entry)
|
||||
games = games[-MAX_GAME_LOG:]
|
||||
save_game_log(games)
|
||||
return games
|
||||
|
||||
|
||||
# --- AI ---
|
||||
|
||||
def evaluate_board(board: list[list[int]], ai_p: int, hu_p: int) -> int:
|
||||
score = 0
|
||||
ai_threats = 0
|
||||
hu_threats = 0
|
||||
|
||||
# Center column bonus
|
||||
for r in range(ROWS):
|
||||
if board[3][r] == ai_p:
|
||||
score += 3
|
||||
elif board[3][r] == hu_p:
|
||||
score -= 3
|
||||
|
||||
# Score a window of 4 cells by piece counts
|
||||
def score_window(c: int, r: int, dc: int, dr: int) -> int:
|
||||
nonlocal ai_threats, hu_threats
|
||||
ai, hu, empty_c, empty_r = 0, 0, -1, -1
|
||||
for i in range(4):
|
||||
cc = c + i * dc
|
||||
rr = r + i * dr
|
||||
v = board[cc][rr]
|
||||
if v == ai_p:
|
||||
ai += 1
|
||||
elif v == hu_p:
|
||||
hu += 1
|
||||
else:
|
||||
empty_c, empty_r = cc, rr
|
||||
if ai > 0 and hu > 0:
|
||||
return 0
|
||||
if ai == 3:
|
||||
ai_threats += 1
|
||||
playable = empty_r == 0 or board[empty_c][empty_r - 1] != 0
|
||||
return 100 if playable else 40
|
||||
if ai == 2:
|
||||
return 5
|
||||
if hu == 3:
|
||||
hu_threats += 1
|
||||
playable = empty_r == 0 or board[empty_c][empty_r - 1] != 0
|
||||
return -100 if playable else -40
|
||||
if hu == 2:
|
||||
return -5
|
||||
return 0
|
||||
|
||||
# Horizontal
|
||||
for r in range(ROWS):
|
||||
for c in range(COLS - 3):
|
||||
score += score_window(c, r, 1, 0)
|
||||
# Vertical
|
||||
for r in range(ROWS - 3):
|
||||
for c in range(COLS):
|
||||
score += score_window(c, r, 0, 1)
|
||||
# Diagonal up-right
|
||||
for r in range(ROWS - 3):
|
||||
for c in range(COLS - 3):
|
||||
score += score_window(c, r, 1, 1)
|
||||
# Diagonal down-right
|
||||
for r in range(3, ROWS):
|
||||
for c in range(COLS - 3):
|
||||
score += score_window(c, r, 1, -1)
|
||||
|
||||
# Fork bonus: multiple threats are disproportionately dangerous
|
||||
if ai_threats >= 2:
|
||||
score += 200
|
||||
if hu_threats >= 2:
|
||||
score -= 200
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def minimax(
|
||||
board: list[list[int]], depth: int, alpha: int, beta: int,
|
||||
is_max: bool, ai_p: int, hu_p: int,
|
||||
) -> int:
|
||||
winner, _ = scan_board(board)
|
||||
if winner == ai_p:
|
||||
return 1000 + depth
|
||||
if winner == hu_p:
|
||||
return -1000 - depth
|
||||
if depth == 0 or is_board_full(board):
|
||||
return evaluate_board(board, ai_p, hu_p)
|
||||
|
||||
best = -10000 if is_max else 10000
|
||||
for c in COL_ORDER:
|
||||
r = get_first_empty_row(board, c)
|
||||
if r != -1:
|
||||
board[c][r] = ai_p if is_max else hu_p
|
||||
score = minimax(board, depth - 1, alpha, beta, not is_max, ai_p, hu_p)
|
||||
board[c][r] = 0
|
||||
if is_max:
|
||||
if score > best:
|
||||
best = score
|
||||
if best > alpha:
|
||||
alpha = best
|
||||
else:
|
||||
if score < best:
|
||||
best = score
|
||||
if best < beta:
|
||||
beta = best
|
||||
if beta <= alpha:
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def perform_ai_move(
|
||||
board: list[list[int]], ai_p: int, look_ahead: int, is_demo: bool = False, demo_ply: int = 4,
|
||||
) -> int:
|
||||
hu_p = 2 if ai_p == 1 else 1
|
||||
ply = demo_ply if is_demo else look_ahead
|
||||
|
||||
# Phase 1a: check ALL columns for instant AI win
|
||||
for c in range(COLS):
|
||||
r = get_first_empty_row(board, c)
|
||||
if r != -1:
|
||||
board[c][r] = ai_p
|
||||
if scan_board(board)[0] == ai_p:
|
||||
board[c][r] = 0
|
||||
return c
|
||||
board[c][r] = 0
|
||||
|
||||
# Phase 1b: check ALL columns for opponent block
|
||||
for c in range(COLS):
|
||||
r = get_first_empty_row(board, c)
|
||||
if r != -1:
|
||||
board[c][r] = hu_p
|
||||
if scan_board(board)[0] == hu_p:
|
||||
board[c][r] = 0
|
||||
return c
|
||||
board[c][r] = 0
|
||||
|
||||
# Phase 2: blunder
|
||||
if not is_demo and BLUNDER_ENABLED and random.randint(0, 99) < BLUNDER_CHANCE:
|
||||
valid = [c for c in range(COLS) if get_first_empty_row(board, c) != -1]
|
||||
return random.choice(valid)
|
||||
|
||||
# Phase 3: minimax
|
||||
best_score = -30000
|
||||
best_col = 3
|
||||
for c in COL_ORDER:
|
||||
r = get_first_empty_row(board, c)
|
||||
if r != -1:
|
||||
board[c][r] = ai_p
|
||||
score = minimax(board, ply, -30000, 30000, False, ai_p, hu_p)
|
||||
board[c][r] = 0
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_col = c
|
||||
return best_col
|
||||
|
||||
|
||||
def randomize_demo_plies() -> tuple[int, int]:
|
||||
strong = random.randint(4, 5)
|
||||
weak = random.randint(2, 3)
|
||||
if random.randint(0, 1) == 0:
|
||||
return strong, weak
|
||||
return weak, strong
|
||||
|
||||
|
||||
# --- Input (cross-platform, non-blocking via thread) ---
|
||||
|
||||
_key_queue: queue.Queue[str] = queue.Queue()
|
||||
_input_stop = threading.Event()
|
||||
|
||||
|
||||
def _input_thread():
|
||||
"""Background thread that reads keys and puts them on the queue."""
|
||||
while not _input_stop.is_set():
|
||||
try:
|
||||
key = readchar.readkey()
|
||||
_key_queue.put(key)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
|
||||
def read_key() -> str | None:
|
||||
"""Non-blocking key read from the queue."""
|
||||
try:
|
||||
return _key_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
|
||||
# --- Main game loop ---
|
||||
|
||||
def main():
|
||||
console.clear()
|
||||
|
||||
game_state = State.MENU
|
||||
board = make_board()
|
||||
menu_mode = 0
|
||||
current_player = 1
|
||||
active_col = 3
|
||||
winner_player = 0
|
||||
win_positions: list[tuple[int, int]] = []
|
||||
current_moves = ""
|
||||
game_menu_mode = 0
|
||||
game_level = LOOK_AHEAD
|
||||
games = load_game_log()
|
||||
demo_ply = (4, 4)
|
||||
last_activity = time.time()
|
||||
demo_reset_timer = 0.0
|
||||
flash_toggle = True
|
||||
last_flash = 0.0
|
||||
|
||||
def reset():
|
||||
nonlocal board, winner_player, win_positions, current_moves
|
||||
board = make_board()
|
||||
winner_player = 0
|
||||
win_positions = []
|
||||
current_moves = ""
|
||||
|
||||
def check_game_end() -> bool:
|
||||
nonlocal winner_player, win_positions, game_state, games, demo_reset_timer, last_activity
|
||||
winner_player, win_positions = scan_board(board)
|
||||
won = winner_player != 0
|
||||
draw = not won and is_board_full(board)
|
||||
if not won and not draw:
|
||||
return False
|
||||
if game_state != State.DEMO:
|
||||
games = log_game(games, game_menu_mode, game_level, winner_player if won else 0, current_moves)
|
||||
game_state = State.FINISHED_WIN if won else State.FINISHED_DRAW
|
||||
demo_reset_timer = time.time()
|
||||
last_activity = time.time()
|
||||
return True
|
||||
|
||||
# Start input thread
|
||||
input_thread = threading.Thread(target=_input_thread, daemon=True)
|
||||
input_thread.start()
|
||||
|
||||
try:
|
||||
with Live(render_menu(menu_mode), console=console, refresh_per_second=10, screen=True) as live:
|
||||
while True:
|
||||
key = read_key()
|
||||
|
||||
# Quit
|
||||
if key in ("q", "Q"):
|
||||
break
|
||||
|
||||
# --- MENU ---
|
||||
if game_state == State.MENU:
|
||||
if key in (KEY_UP,):
|
||||
menu_mode = (menu_mode - 1) % 3
|
||||
last_activity = time.time()
|
||||
elif key in (KEY_DOWN,):
|
||||
menu_mode = (menu_mode + 1) % 3
|
||||
last_activity = time.time()
|
||||
elif key in CONFIRM_KEYS:
|
||||
reset()
|
||||
game_menu_mode = menu_mode
|
||||
game_level = LOOK_AHEAD
|
||||
current_player = 1
|
||||
active_col = 3
|
||||
if menu_mode == 1:
|
||||
game_state = State.AI_TURN
|
||||
else:
|
||||
game_state = State.PLAYING
|
||||
last_activity = time.time()
|
||||
|
||||
if game_state == State.MENU:
|
||||
live.update(render_menu(menu_mode))
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
# --- Interrupt: return to menu from finished/demo ---
|
||||
if game_state in (State.FINISHED_WIN, State.FINISHED_DRAW, State.DEMO) and key is not None:
|
||||
reset()
|
||||
game_state = State.MENU
|
||||
menu_mode = 0
|
||||
last_activity = time.time()
|
||||
live.update(render_menu(menu_mode))
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
# --- Idle timeout: enter demo ---
|
||||
if game_state not in (State.DEMO, State.FINISHED_WIN, State.FINISHED_DRAW):
|
||||
if time.time() - last_activity > IDLE_TIMEOUT:
|
||||
reset()
|
||||
demo_ply = randomize_demo_plies()
|
||||
game_state = State.DEMO
|
||||
current_player = 1
|
||||
|
||||
# --- PLAYING ---
|
||||
if game_state == State.PLAYING:
|
||||
if key in (KEY_LEFT,):
|
||||
active_col = max(0, active_col - 1)
|
||||
last_activity = time.time()
|
||||
elif key in (KEY_RIGHT,):
|
||||
active_col = min(COLS - 1, active_col + 1)
|
||||
last_activity = time.time()
|
||||
elif key in ("1", "2", "3", "4", "5", "6", "7"):
|
||||
col = int(key) - 1
|
||||
r = get_first_empty_row(board, col)
|
||||
if r != -1:
|
||||
active_col = col
|
||||
current_moves += str(col)
|
||||
board[col][r] = current_player
|
||||
if not check_game_end():
|
||||
if menu_mode < 2:
|
||||
game_state = State.AI_TURN
|
||||
else:
|
||||
current_player = 2 if current_player == 1 else 1
|
||||
last_activity = time.time()
|
||||
elif key in CONFIRM_KEYS:
|
||||
r = get_first_empty_row(board, active_col)
|
||||
if r != -1:
|
||||
current_moves += str(active_col)
|
||||
board[active_col][r] = current_player
|
||||
if not check_game_end():
|
||||
if menu_mode < 2:
|
||||
game_state = State.AI_TURN
|
||||
else:
|
||||
current_player = 2 if current_player == 1 else 1
|
||||
last_activity = time.time()
|
||||
|
||||
live.update(render_board(board, active_col, current_player))
|
||||
|
||||
# --- AI_TURN ---
|
||||
elif game_state == State.AI_TURN:
|
||||
ai_p = 2 if menu_mode == 0 else 1
|
||||
live.update(render_board(board, -1, ai_p, thinking_col=active_col, thinking_bright=True))
|
||||
|
||||
best_col = perform_ai_move(board, ai_p, LOOK_AHEAD)
|
||||
r = get_first_empty_row(board, best_col)
|
||||
if r != -1:
|
||||
current_moves += str(best_col)
|
||||
board[best_col][r] = ai_p
|
||||
active_col = best_col
|
||||
if not check_game_end():
|
||||
game_state = State.PLAYING
|
||||
current_player = 2 if ai_p == 1 else 1
|
||||
last_activity = time.time()
|
||||
|
||||
live.update(render_board(board, active_col, current_player, win_positions if winner_player else None))
|
||||
|
||||
# --- DEMO ---
|
||||
elif game_state == State.DEMO:
|
||||
ply = demo_ply[current_player - 1]
|
||||
best_col = perform_ai_move(board, current_player, LOOK_AHEAD, is_demo=True, demo_ply=ply)
|
||||
r = get_first_empty_row(board, best_col)
|
||||
if r != -1:
|
||||
board[best_col][r] = current_player
|
||||
if not check_game_end():
|
||||
current_player = 2 if current_player == 1 else 1
|
||||
|
||||
live.update(render_board(board, -1, 0))
|
||||
time.sleep(0.4)
|
||||
|
||||
# --- FINISHED ---
|
||||
elif game_state in (State.FINISHED_WIN, State.FINISHED_DRAW):
|
||||
now = time.time()
|
||||
if now - last_flash > 0.4:
|
||||
last_flash = now
|
||||
flash_toggle = not flash_toggle
|
||||
|
||||
if game_state == State.FINISHED_WIN:
|
||||
style = player_style(winner_player)
|
||||
status = Text.from_markup(
|
||||
f"\n [{style}]{player_name(winner_player)} wins![/{style}] [dim]Press any key for menu[/dim]\n"
|
||||
)
|
||||
tbl = render_board(board, -1, 0, win_positions, flash_off=flash_toggle)
|
||||
else:
|
||||
status = Text.from_markup(
|
||||
"\n [bold]Draw![/bold] [dim]Press any key for menu[/dim]\n"
|
||||
)
|
||||
tbl = render_board(board, -1, 0, is_draw_flash=True, flash_off=flash_toggle)
|
||||
|
||||
live.update(Group(tbl, status))
|
||||
|
||||
# Auto-restart to demo after pause
|
||||
if time.time() - demo_reset_timer > DEMO_RESET_PAUSE:
|
||||
reset()
|
||||
demo_ply = randomize_demo_plies()
|
||||
game_state = State.DEMO
|
||||
current_player = 1
|
||||
last_activity = time.time()
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
_input_stop.set()
|
||||
console.clear()
|
||||
console.print("[bold]Thanks for playing![/bold]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+10
-3
@@ -9,9 +9,16 @@ build_flags =
|
||||
-D LED_PIN=4
|
||||
-D ENC_A=0
|
||||
-D ENC_B=1
|
||||
-D ENC_SW=6
|
||||
-D ENC_SW=2
|
||||
-D SENSITIVITY=4
|
||||
-D BRIGHTNESS=25
|
||||
-D SHOW_BORDER=0
|
||||
-D DEMO_RESET_PAUSE=20000
|
||||
-D DEFAULT_LOOK_AHEAD=8
|
||||
-D DEFAULT_BRIGHTNESS=25
|
||||
-D DEFAULT_IDLE_TIMEOUT=45
|
||||
-D MAX_GAME_LOG=100
|
||||
-D WIFI_SSID=\"Connect4\"
|
||||
-D WIFI_PASSWORD=\"youlose4\"
|
||||
lib_deps =
|
||||
fastled/FastLED @ ^3.6.0
|
||||
fastled/FastLED @ 3.9.12
|
||||
paulstoffregen/Encoder @ ^1.4.4
|
||||
+13
-6
@@ -1,9 +1,16 @@
|
||||
[project]
|
||||
name = "connect-four"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
name = "connect-four-terminal"
|
||||
version = "1.0.0"
|
||||
description = "Connect Four terminal game with AI"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"esptool>=5.2.0",
|
||||
"rich>=13.0",
|
||||
"python-dotenv>=1.0",
|
||||
"readchar>=4.0",
|
||||
"tensorflow>=2.16",
|
||||
"numpy>=2.0",
|
||||
"pygame>=2.5",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
connect-four = "connect_four:main"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Entry point: python -m rl [train|export|info]"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
|
||||
os.environ["TF_ENABLE_ONEDNN_OPTS"] = "0"
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "train"
|
||||
|
||||
if cmd == "train":
|
||||
from .train import train
|
||||
train()
|
||||
|
||||
elif cmd == "export":
|
||||
from .export import export_tflite
|
||||
model_path = sys.argv[2] if len(sys.argv) > 2 else "rl/checkpoints/model_final.keras"
|
||||
export_tflite(model_path)
|
||||
|
||||
elif cmd == "visualize":
|
||||
from .visualize import run_visualized
|
||||
run_visualized()
|
||||
|
||||
elif cmd == "info":
|
||||
from .model import build_model, print_model_info
|
||||
model = build_model()
|
||||
print_model_info(model)
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
print("Usage: python -m rl [train|visualize|export|info]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Training hyperparameters — edit these to tune your model."""
|
||||
|
||||
# ── Model architecture ──────────────────────────────────────────────
|
||||
CONV_FILTERS = 32 # filters per conv layer (keep small for ESP32)
|
||||
NUM_CONV_LAYERS = 3 # number of convolutional blocks
|
||||
DENSE_UNITS = 64 # units in the dense layer before heads
|
||||
|
||||
# ── Training ────────────────────────────────────────────────────────
|
||||
LEARNING_RATE = 1e-3 # Adam learning rate
|
||||
BATCH_SIZE = 256 # training batch size
|
||||
EPOCHS_PER_ITERATION = 4 # epochs per training iteration
|
||||
REPLAY_BUFFER_SIZE = 50000 # max samples kept in replay buffer
|
||||
|
||||
# ── Self-play ───────────────────────────────────────────────────────
|
||||
NUM_ITERATIONS = 50 # total train iterations (self-play → train cycles)
|
||||
GAMES_PER_ITERATION = 100 # self-play games generated per iteration
|
||||
MCTS_SIMULATIONS = 50 # MCTS simulations per move
|
||||
MCTS_C_PUCT = 1.4 # exploration constant
|
||||
MCTS_TEMPERATURE = 1.0 # move selection temperature (1 = proportional, →0 = greedy)
|
||||
TEMP_DROP_MOVE = 10 # switch to greedy after this many moves
|
||||
|
||||
# ── Parallelism ────────────────────────────────────────────────────
|
||||
NUM_WORKERS = 0 # 0 = use all available CPU cores
|
||||
|
||||
# ── Reward shaping ──────────────────────────────────────────────────
|
||||
WIN_REWARD = 1.0
|
||||
DRAW_REWARD = 0.0
|
||||
LOSS_REWARD = -1.0
|
||||
|
||||
# ── Checkpointing ──────────────────────────────────────────────────
|
||||
CHECKPOINT_DIR = "rl/checkpoints"
|
||||
CHECKPOINT_INTERVAL = 5 # save model every N iterations
|
||||
EXPORT_DIR = "rl/export"
|
||||
|
||||
# ── ESP32 export ────────────────────────────────────────────────────
|
||||
QUANTIZE_INT8 = True # int8 quantization for TFLite (recommended for ESP32)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Export trained Keras model to TFLite (optionally int8-quantized) for ESP32."""
|
||||
|
||||
import os
|
||||
import numpy as np
|
||||
|
||||
from .game import ConnectFour, ROWS, COLS
|
||||
from .config import EXPORT_DIR, QUANTIZE_INT8
|
||||
|
||||
|
||||
def representative_dataset():
|
||||
"""Generate sample inputs for int8 calibration."""
|
||||
game = ConnectFour()
|
||||
for _ in range(200):
|
||||
game.reset()
|
||||
# Play random moves to get diverse board states
|
||||
moves = np.random.randint(0, min(ROWS * COLS, 20))
|
||||
for _ in range(moves):
|
||||
legal = game.legal_moves()
|
||||
if not legal or game.done:
|
||||
break
|
||||
game.step(np.random.choice(legal))
|
||||
yield [game.get_state()[np.newaxis].astype(np.float32)]
|
||||
|
||||
|
||||
def export_tflite(model_path, quantize=None):
|
||||
"""Convert a saved Keras model to TFLite.
|
||||
|
||||
Args:
|
||||
model_path: Path to the .keras model file.
|
||||
quantize: Override quantization setting. If None, uses config.QUANTIZE_INT8.
|
||||
"""
|
||||
import tensorflow as tf
|
||||
|
||||
if quantize is None:
|
||||
quantize = QUANTIZE_INT8
|
||||
|
||||
os.makedirs(EXPORT_DIR, exist_ok=True)
|
||||
|
||||
model = tf.keras.models.load_model(model_path)
|
||||
|
||||
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
||||
|
||||
if quantize:
|
||||
converter.optimizations = [tf.lite.Optimize.DEFAULT]
|
||||
converter.representative_dataset = representative_dataset
|
||||
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
|
||||
converter.inference_input_type = tf.int8
|
||||
converter.inference_output_type = tf.int8
|
||||
suffix = "_int8"
|
||||
else:
|
||||
suffix = "_f32"
|
||||
|
||||
tflite_model = converter.convert()
|
||||
|
||||
out_path = os.path.join(EXPORT_DIR, f"connect4{suffix}.tflite")
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(tflite_model)
|
||||
|
||||
size_kb = len(tflite_model) / 1024
|
||||
print(f"Exported: {out_path} ({size_kb:.1f} KB)")
|
||||
|
||||
# Also export as C header for direct embedding in firmware
|
||||
header_path = os.path.join(EXPORT_DIR, f"connect4_model{suffix}.h")
|
||||
_write_c_header(tflite_model, header_path)
|
||||
print(f"C header: {header_path}")
|
||||
|
||||
return out_path
|
||||
|
||||
|
||||
def _write_c_header(model_bytes, path):
|
||||
"""Write TFLite model as a C byte array for ESP32 firmware inclusion."""
|
||||
with open(path, "w") as f:
|
||||
f.write("#pragma once\n\n")
|
||||
f.write(f"// Auto-generated — {len(model_bytes)} bytes\n")
|
||||
f.write(f"const unsigned int connect4_model_len = {len(model_bytes)};\n")
|
||||
f.write("alignas(16) const unsigned char connect4_model[] = {\n")
|
||||
for i in range(0, len(model_bytes), 12):
|
||||
chunk = model_bytes[i:i + 12]
|
||||
f.write(" " + ", ".join(f"0x{b:02x}" for b in chunk) + ",\n")
|
||||
f.write("};\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
model_path = sys.argv[1] if len(sys.argv) > 1 else "rl/checkpoints/model_final.keras"
|
||||
export_tflite(model_path)
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""Connect Four game environment for self-play training."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROWS = 6
|
||||
COLS = 7
|
||||
WIN_LENGTH = 4
|
||||
|
||||
|
||||
class ConnectFour:
|
||||
"""Connect Four game with numpy board representation.
|
||||
|
||||
Board encoding: 0 = empty, 1 = player 1, -1 = player 2.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.board = np.zeros((ROWS, COLS), dtype=np.int8)
|
||||
self.current_player = 1
|
||||
self.done = False
|
||||
self.winner = 0 # 0 = no winner / draw, 1 or -1
|
||||
self.move_count = 0
|
||||
return self.get_state()
|
||||
|
||||
def get_state(self):
|
||||
"""Return board from current player's perspective as (6,7,2) tensor.
|
||||
|
||||
Channel 0: current player's pieces (1s).
|
||||
Channel 1: opponent's pieces (1s).
|
||||
"""
|
||||
state = np.zeros((ROWS, COLS, 2), dtype=np.float32)
|
||||
state[:, :, 0] = (self.board == self.current_player).astype(np.float32)
|
||||
state[:, :, 1] = (self.board == -self.current_player).astype(np.float32)
|
||||
return state
|
||||
|
||||
def legal_moves(self):
|
||||
"""Return list of columns that are not full."""
|
||||
return [c for c in range(COLS) if self.board[0, c] == 0]
|
||||
|
||||
def legal_moves_mask(self):
|
||||
"""Return binary mask of legal columns."""
|
||||
return (self.board[0] == 0).astype(np.float32)
|
||||
|
||||
def step(self, col):
|
||||
"""Play a move in the given column. Returns (state, reward, done)."""
|
||||
if self.done:
|
||||
raise ValueError("Game is already over.")
|
||||
if col < 0 or col >= COLS or self.board[0, col] != 0:
|
||||
raise ValueError(f"Illegal move: column {col}")
|
||||
|
||||
# Drop piece
|
||||
row = self._get_drop_row(col)
|
||||
self.board[row, col] = self.current_player
|
||||
self.move_count += 1
|
||||
|
||||
# Check win
|
||||
if self._check_win(row, col):
|
||||
self.done = True
|
||||
self.winner = self.current_player
|
||||
reward = 1.0
|
||||
elif self.move_count == ROWS * COLS:
|
||||
self.done = True
|
||||
self.winner = 0
|
||||
reward = 0.0
|
||||
else:
|
||||
reward = 0.0
|
||||
|
||||
# Switch player
|
||||
self.current_player *= -1
|
||||
return self.get_state(), reward, self.done
|
||||
|
||||
def _get_drop_row(self, col):
|
||||
for r in range(ROWS - 1, -1, -1):
|
||||
if self.board[r, col] == 0:
|
||||
return r
|
||||
raise ValueError(f"Column {col} is full")
|
||||
|
||||
def _check_win(self, row, col):
|
||||
player = self.board[row, col]
|
||||
directions = [(0, 1), (1, 0), (1, 1), (1, -1)]
|
||||
for dr, dc in directions:
|
||||
count = 1
|
||||
for sign in (1, -1):
|
||||
r, c = row + sign * dr, col + sign * dc
|
||||
while 0 <= r < ROWS and 0 <= c < COLS and self.board[r, c] == player:
|
||||
count += 1
|
||||
r += sign * dr
|
||||
c += sign * dc
|
||||
if count >= WIN_LENGTH:
|
||||
return True
|
||||
return False
|
||||
|
||||
def clone(self):
|
||||
g = ConnectFour()
|
||||
g.board = self.board.copy()
|
||||
g.current_player = self.current_player
|
||||
g.done = self.done
|
||||
g.winner = self.winner
|
||||
g.move_count = self.move_count
|
||||
return g
|
||||
@@ -1,5 +1,5 @@
|
||||
def main():
|
||||
print("Hello from connect-four!")
|
||||
print("Hello from rl!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
"""Monte Carlo Tree Search for self-play data generation."""
|
||||
|
||||
import math
|
||||
import numpy as np
|
||||
from .game import ConnectFour
|
||||
from .config import MCTS_C_PUCT
|
||||
|
||||
|
||||
class MCTSNode:
|
||||
__slots__ = ("parent", "action", "prior", "visit_count", "value_sum", "children", "game")
|
||||
|
||||
def __init__(self, game, parent=None, action=None, prior=0.0):
|
||||
self.game = game
|
||||
self.parent = parent
|
||||
self.action = action
|
||||
self.prior = prior
|
||||
self.visit_count = 0
|
||||
self.value_sum = 0.0
|
||||
self.children = {}
|
||||
|
||||
@property
|
||||
def q_value(self):
|
||||
if self.visit_count == 0:
|
||||
return 0.0
|
||||
return self.value_sum / self.visit_count
|
||||
|
||||
def ucb_score(self):
|
||||
parent_visits = self.parent.visit_count if self.parent else 1
|
||||
exploration = MCTS_C_PUCT * self.prior * math.sqrt(parent_visits) / (1 + self.visit_count)
|
||||
return self.q_value + exploration
|
||||
|
||||
def is_leaf(self):
|
||||
return len(self.children) == 0
|
||||
|
||||
def expand(self, policy_probs):
|
||||
"""Expand node using network policy output."""
|
||||
legal = self.game.legal_moves()
|
||||
for col in legal:
|
||||
if col not in self.children:
|
||||
self.children[col] = MCTSNode(
|
||||
game=None, parent=self, action=col, prior=policy_probs[col]
|
||||
)
|
||||
|
||||
def select_child(self):
|
||||
return max(self.children.values(), key=lambda c: c.ucb_score())
|
||||
|
||||
|
||||
def run_mcts(game, model, num_simulations):
|
||||
"""Run MCTS from current game state, return visit-count policy vector."""
|
||||
root = MCTSNode(game.clone())
|
||||
|
||||
# Evaluate root
|
||||
state = root.game.get_state()
|
||||
policy_logits, value = model.predict(state[np.newaxis], verbose=0)
|
||||
policy = _mask_and_normalize(policy_logits[0], root.game.legal_moves_mask())
|
||||
root.expand(policy)
|
||||
|
||||
for _ in range(num_simulations):
|
||||
node = root
|
||||
sim_game = root.game.clone()
|
||||
|
||||
# SELECT — walk down tree picking best UCB child
|
||||
while not node.is_leaf() and not sim_game.done:
|
||||
node = node.select_child()
|
||||
sim_game.step(node.action)
|
||||
|
||||
# EVALUATE leaf
|
||||
if sim_game.done:
|
||||
# Terminal: value from perspective of player who just moved
|
||||
if sim_game.winner == 0:
|
||||
leaf_value = 0.0
|
||||
else:
|
||||
# The winner is sim_game.winner; current_player already switched
|
||||
leaf_value = -1.0 # current player lost (winner was previous player)
|
||||
else:
|
||||
node.game = sim_game.clone()
|
||||
state = sim_game.get_state()
|
||||
policy_logits, value = model.predict(state[np.newaxis], verbose=0)
|
||||
leaf_value = value[0, 0]
|
||||
policy = _mask_and_normalize(policy_logits[0], sim_game.legal_moves_mask())
|
||||
node.expand(policy)
|
||||
|
||||
# BACKUP — propagate value up, flipping sign each level
|
||||
while node is not None:
|
||||
node.visit_count += 1
|
||||
node.value_sum += leaf_value
|
||||
leaf_value = -leaf_value
|
||||
node = node.parent
|
||||
|
||||
# Build policy from visit counts
|
||||
visits = np.zeros(7, dtype=np.float32)
|
||||
for col, child in root.children.items():
|
||||
visits[col] = child.visit_count
|
||||
return visits
|
||||
|
||||
|
||||
def _mask_and_normalize(logits, mask):
|
||||
"""Apply legal-move mask and softmax."""
|
||||
logits = np.array(logits, dtype=np.float64)
|
||||
logits[mask == 0] = -1e9
|
||||
exp = np.exp(logits - np.max(logits))
|
||||
probs = exp / np.sum(exp)
|
||||
return probs.astype(np.float32)
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""Compact dual-head neural network (policy + value) sized for ESP32."""
|
||||
|
||||
from .config import CONV_FILTERS, NUM_CONV_LAYERS, DENSE_UNITS, LEARNING_RATE
|
||||
|
||||
|
||||
def build_model():
|
||||
"""Build a small AlphaZero-style network.
|
||||
|
||||
Input: (6, 7, 2) — current player pieces / opponent pieces
|
||||
Output: policy (7,) — log-probabilities over columns
|
||||
value (1,) — board evaluation in [-1, 1]
|
||||
"""
|
||||
from tensorflow import keras
|
||||
from tensorflow.keras import layers
|
||||
|
||||
inp = layers.Input(shape=(6, 7, 2), name="board")
|
||||
|
||||
x = inp
|
||||
for i in range(NUM_CONV_LAYERS):
|
||||
x = layers.Conv2D(
|
||||
CONV_FILTERS, 3, padding="same", activation="relu", name=f"conv{i}"
|
||||
)(x)
|
||||
x = layers.BatchNormalization(name=f"bn{i}")(x)
|
||||
|
||||
flat = layers.Flatten(name="flat")(x)
|
||||
shared = layers.Dense(DENSE_UNITS, activation="relu", name="shared_dense")(flat)
|
||||
|
||||
# Policy head
|
||||
policy = layers.Dense(7, name="policy_logits")(shared)
|
||||
|
||||
# Value head
|
||||
value = layers.Dense(1, activation="tanh", name="value")(shared)
|
||||
|
||||
model = keras.Model(inputs=inp, outputs=[policy, value], name="connect4_net")
|
||||
|
||||
model.compile(
|
||||
optimizer=keras.optimizers.Adam(learning_rate=LEARNING_RATE),
|
||||
loss={
|
||||
"policy_logits": keras.losses.CategoricalCrossentropy(from_logits=True),
|
||||
"value": keras.losses.MeanSquaredError(),
|
||||
},
|
||||
loss_weights={"policy_logits": 1.0, "value": 1.0},
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
def print_model_info(model):
|
||||
model.summary()
|
||||
total_params = model.count_params()
|
||||
approx_size_kb = total_params * 4 / 1024 # float32
|
||||
approx_int8_kb = total_params / 1024 # int8
|
||||
print(f"\nTotal parameters: {total_params:,}")
|
||||
print(f"Approx size (float32): {approx_size_kb:.1f} KB")
|
||||
print(f"Approx size (int8): {approx_int8_kb:.1f} KB")
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
"""Self-play training loop with parallel game generation."""
|
||||
|
||||
import os
|
||||
import numpy as np
|
||||
from collections import deque
|
||||
from multiprocessing import Pool, cpu_count
|
||||
|
||||
from .game import ConnectFour
|
||||
from .model import build_model, print_model_info
|
||||
from .mcts import run_mcts
|
||||
from .config import (
|
||||
NUM_ITERATIONS, GAMES_PER_ITERATION, MCTS_SIMULATIONS,
|
||||
MCTS_TEMPERATURE, TEMP_DROP_MOVE,
|
||||
WIN_REWARD, DRAW_REWARD, LOSS_REWARD,
|
||||
BATCH_SIZE, EPOCHS_PER_ITERATION, REPLAY_BUFFER_SIZE,
|
||||
CHECKPOINT_DIR, CHECKPOINT_INTERVAL, NUM_WORKERS,
|
||||
)
|
||||
|
||||
# Per-worker global model (loaded once per process)
|
||||
_worker_model = None
|
||||
|
||||
|
||||
def _init_worker(weights_list):
|
||||
"""Initialize a worker process with its own model copy."""
|
||||
global _worker_model
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
|
||||
os.environ["TF_ENABLE_ONEDNN_OPTS"] = "0"
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
_worker_model = build_model()
|
||||
_worker_model.set_weights(weights_list)
|
||||
|
||||
|
||||
def _play_one_game(_):
|
||||
"""Play a single self-play game in a worker process."""
|
||||
game = ConnectFour()
|
||||
trajectory = []
|
||||
|
||||
while not game.done:
|
||||
state = game.get_state()
|
||||
visit_counts = run_mcts(game, _worker_model, MCTS_SIMULATIONS)
|
||||
|
||||
if game.move_count < TEMP_DROP_MOVE:
|
||||
temp = MCTS_TEMPERATURE
|
||||
else:
|
||||
temp = 0.1
|
||||
|
||||
if temp < 0.2:
|
||||
action = int(np.argmax(visit_counts))
|
||||
policy = np.zeros(7, dtype=np.float32)
|
||||
policy[action] = 1.0
|
||||
else:
|
||||
counts = visit_counts ** (1.0 / temp)
|
||||
policy = counts / counts.sum()
|
||||
action = np.random.choice(7, p=policy)
|
||||
|
||||
trajectory.append((state, policy, game.current_player))
|
||||
game.step(action)
|
||||
|
||||
samples = []
|
||||
for state, policy, player in trajectory:
|
||||
if game.winner == 0:
|
||||
value = DRAW_REWARD
|
||||
elif game.winner == player:
|
||||
value = WIN_REWARD
|
||||
else:
|
||||
value = LOSS_REWARD
|
||||
samples.append((state, policy, value))
|
||||
|
||||
return samples
|
||||
|
||||
|
||||
def train():
|
||||
"""Main training entry point."""
|
||||
model = build_model()
|
||||
print_model_info(model)
|
||||
|
||||
num_workers = NUM_WORKERS if NUM_WORKERS > 0 else cpu_count()
|
||||
print(f"Using {num_workers} worker processes for self-play")
|
||||
|
||||
replay_buffer = deque(maxlen=REPLAY_BUFFER_SIZE)
|
||||
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
|
||||
|
||||
for iteration in range(1, NUM_ITERATIONS + 1):
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Iteration {iteration}/{NUM_ITERATIONS}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# ── Self-play (parallel) ───────────────────────────────
|
||||
weights = model.get_weights()
|
||||
with Pool(processes=num_workers, initializer=_init_worker, initargs=(weights,)) as pool:
|
||||
results = pool.map(_play_one_game, range(GAMES_PER_ITERATION))
|
||||
|
||||
wins = {1: 0, -1: 0, 0: 0}
|
||||
for samples in results:
|
||||
replay_buffer.extend(samples)
|
||||
if samples:
|
||||
last_value = samples[-1][2]
|
||||
if last_value == WIN_REWARD:
|
||||
wins[1] += 1
|
||||
elif last_value == LOSS_REWARD:
|
||||
wins[-1] += 1
|
||||
else:
|
||||
wins[0] += 1
|
||||
|
||||
print(f" Self-play: {GAMES_PER_ITERATION} games "
|
||||
f"(P1 wins: {wins[1]}, P2 wins: {wins[-1]}, draws: {wins[0]})")
|
||||
print(f" Buffer size: {len(replay_buffer)}")
|
||||
|
||||
# ── Train ───────────────────────────────────────────────
|
||||
if len(replay_buffer) >= BATCH_SIZE:
|
||||
sample_size = min(len(replay_buffer), BATCH_SIZE * EPOCHS_PER_ITERATION)
|
||||
indices = np.random.choice(len(replay_buffer), size=sample_size, replace=False)
|
||||
batch = [replay_buffer[i] for i in indices]
|
||||
|
||||
states = np.array([s[0] for s in batch])
|
||||
policies = np.array([s[1] for s in batch])
|
||||
values = np.array([s[2] for s in batch]).reshape(-1, 1)
|
||||
|
||||
history = model.fit(
|
||||
states,
|
||||
{"policy_logits": policies, "value": values},
|
||||
batch_size=BATCH_SIZE,
|
||||
epochs=EPOCHS_PER_ITERATION,
|
||||
verbose=1,
|
||||
)
|
||||
policy_loss = history.history["policy_logits_loss"][-1]
|
||||
value_loss = history.history["value_loss"][-1]
|
||||
print(f" Policy loss: {policy_loss:.4f} Value loss: {value_loss:.4f}")
|
||||
|
||||
# ── Checkpoint ──────────────────────────────────────────
|
||||
if iteration % CHECKPOINT_INTERVAL == 0:
|
||||
path = os.path.join(CHECKPOINT_DIR, f"model_iter{iteration}.keras")
|
||||
model.save(path)
|
||||
print(f" Saved checkpoint: {path}")
|
||||
|
||||
final_path = os.path.join(CHECKPOINT_DIR, "model_final.keras")
|
||||
model.save(final_path)
|
||||
print(f"\nTraining complete. Final model saved to {final_path}")
|
||||
return model
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
train()
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
"""Pygame visualization of Connect Four RL training.
|
||||
|
||||
Left panel: live self-play game board
|
||||
Right panel: loss curves + win-rate chart + training stats
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
import pygame
|
||||
|
||||
from .game import ConnectFour, ROWS, COLS
|
||||
from .model import build_model, print_model_info
|
||||
from .mcts import run_mcts
|
||||
from .config import (
|
||||
NUM_ITERATIONS, GAMES_PER_ITERATION, MCTS_SIMULATIONS,
|
||||
MCTS_TEMPERATURE, TEMP_DROP_MOVE,
|
||||
WIN_REWARD, DRAW_REWARD, LOSS_REWARD,
|
||||
BATCH_SIZE, EPOCHS_PER_ITERATION, REPLAY_BUFFER_SIZE,
|
||||
CHECKPOINT_DIR, CHECKPOINT_INTERVAL, NUM_WORKERS,
|
||||
)
|
||||
from multiprocessing import Pool, cpu_count
|
||||
|
||||
# ── Layout constants ────────────────────────────────────────────────
|
||||
CELL = 80
|
||||
BOARD_W = COLS * CELL
|
||||
BOARD_H = ROWS * CELL
|
||||
PANEL_W = 420
|
||||
MARGIN = 20
|
||||
WIN_W = BOARD_W + PANEL_W + MARGIN * 3
|
||||
WIN_H = BOARD_H + MARGIN * 2
|
||||
FPS = 30
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────────────────
|
||||
BG = (30, 30, 40)
|
||||
BOARD_BG = (0, 60, 180)
|
||||
EMPTY = (20, 20, 30)
|
||||
P1_COLOR = (255, 220, 50) # yellow
|
||||
P2_COLOR = (220, 40, 40) # red
|
||||
WIN_HIGHLIGHT = (100, 255, 100)
|
||||
GRID_LINE = (0, 40, 140)
|
||||
TEXT_COLOR = (220, 220, 220)
|
||||
CHART_BG = (40, 40, 55)
|
||||
POLICY_LINE = (80, 200, 255)
|
||||
VALUE_LINE = (255, 160, 60)
|
||||
P1_CHART = (255, 220, 50)
|
||||
P2_CHART = (220, 40, 40)
|
||||
DRAW_CHART = (140, 140, 140)
|
||||
|
||||
# ── Shared state between training thread and pygame loop ────────────
|
||||
_state = {
|
||||
"board": np.zeros((ROWS, COLS), dtype=np.int8),
|
||||
"iteration": 0,
|
||||
"game_num": 0,
|
||||
"phase": "init", # init / self-play / training / done
|
||||
"policy_losses": [],
|
||||
"value_losses": [],
|
||||
"win_history": [], # list of (p1_wins, p2_wins, draws) per iteration
|
||||
"move_delay": 0.3,
|
||||
"status": "Initializing...",
|
||||
"winner": 0,
|
||||
"running": True,
|
||||
}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
# ── Worker setup (same as train.py) ─────────────────────────────────
|
||||
_worker_model = None
|
||||
|
||||
|
||||
def _init_worker(weights_list):
|
||||
global _worker_model
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
|
||||
os.environ["TF_ENABLE_ONEDNN_OPTS"] = "0"
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
_worker_model = build_model()
|
||||
_worker_model.set_weights(weights_list)
|
||||
|
||||
|
||||
def _play_one_game(_):
|
||||
game = ConnectFour()
|
||||
trajectory = []
|
||||
while not game.done:
|
||||
state = game.get_state()
|
||||
visit_counts = run_mcts(game, _worker_model, MCTS_SIMULATIONS)
|
||||
if game.move_count < TEMP_DROP_MOVE:
|
||||
temp = MCTS_TEMPERATURE
|
||||
else:
|
||||
temp = 0.1
|
||||
if temp < 0.2:
|
||||
action = int(np.argmax(visit_counts))
|
||||
policy = np.zeros(7, dtype=np.float32)
|
||||
policy[action] = 1.0
|
||||
else:
|
||||
counts = visit_counts ** (1.0 / temp)
|
||||
policy = counts / counts.sum()
|
||||
action = np.random.choice(7, p=policy)
|
||||
trajectory.append((state, policy, game.current_player))
|
||||
game.step(action)
|
||||
samples = []
|
||||
for state, policy, player in trajectory:
|
||||
if game.winner == 0:
|
||||
value = DRAW_REWARD
|
||||
elif game.winner == player:
|
||||
value = WIN_REWARD
|
||||
else:
|
||||
value = LOSS_REWARD
|
||||
samples.append((state, policy, value))
|
||||
return samples
|
||||
|
||||
|
||||
def _play_showcase_game(model):
|
||||
"""Play one game slowly on the main training thread, updating shared state."""
|
||||
game = ConnectFour()
|
||||
trajectory = []
|
||||
|
||||
with _lock:
|
||||
_state["board"] = game.board.copy()
|
||||
_state["winner"] = 0
|
||||
|
||||
while not game.done and _state["running"]:
|
||||
state = game.get_state()
|
||||
visit_counts = run_mcts(game, model, MCTS_SIMULATIONS)
|
||||
|
||||
if game.move_count < TEMP_DROP_MOVE:
|
||||
temp = MCTS_TEMPERATURE
|
||||
else:
|
||||
temp = 0.1
|
||||
if temp < 0.2:
|
||||
action = int(np.argmax(visit_counts))
|
||||
policy = np.zeros(7, dtype=np.float32)
|
||||
policy[action] = 1.0
|
||||
else:
|
||||
counts = visit_counts ** (1.0 / temp)
|
||||
policy = counts / counts.sum()
|
||||
action = np.random.choice(7, p=policy)
|
||||
|
||||
trajectory.append((state, policy, game.current_player))
|
||||
game.step(action)
|
||||
|
||||
with _lock:
|
||||
_state["board"] = game.board.copy()
|
||||
|
||||
time.sleep(_state["move_delay"])
|
||||
|
||||
with _lock:
|
||||
_state["winner"] = game.winner
|
||||
|
||||
samples = []
|
||||
for state, policy, player in trajectory:
|
||||
if game.winner == 0:
|
||||
value = DRAW_REWARD
|
||||
elif game.winner == player:
|
||||
value = WIN_REWARD
|
||||
else:
|
||||
value = LOSS_REWARD
|
||||
samples.append((state, policy, value))
|
||||
return samples
|
||||
|
||||
|
||||
def _training_thread():
|
||||
"""Run the full training loop, pushing updates to shared state."""
|
||||
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
|
||||
model = build_model()
|
||||
print_model_info(model)
|
||||
|
||||
num_workers = NUM_WORKERS if NUM_WORKERS > 0 else cpu_count()
|
||||
replay_buffer = deque(maxlen=REPLAY_BUFFER_SIZE)
|
||||
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
|
||||
|
||||
with _lock:
|
||||
_state["status"] = f"Using {num_workers} workers"
|
||||
|
||||
for iteration in range(1, NUM_ITERATIONS + 1):
|
||||
if not _state["running"]:
|
||||
break
|
||||
|
||||
with _lock:
|
||||
_state["iteration"] = iteration
|
||||
_state["phase"] = "self-play"
|
||||
_state["status"] = f"Iteration {iteration}/{NUM_ITERATIONS} - Self-play"
|
||||
|
||||
# Play one showcase game visually
|
||||
with _lock:
|
||||
_state["game_num"] = 0
|
||||
showcase_samples = _play_showcase_game(model)
|
||||
replay_buffer.extend(showcase_samples)
|
||||
|
||||
# Play remaining games in parallel
|
||||
remaining = GAMES_PER_ITERATION - 1
|
||||
if remaining > 0 and _state["running"]:
|
||||
with _lock:
|
||||
_state["status"] = f"Iter {iteration} - Playing {remaining} games (parallel)..."
|
||||
|
||||
weights = model.get_weights()
|
||||
with Pool(processes=num_workers, initializer=_init_worker, initargs=(weights,)) as pool:
|
||||
results = pool.map(_play_one_game, range(remaining))
|
||||
|
||||
for samples in results:
|
||||
replay_buffer.extend(samples)
|
||||
|
||||
# Count wins across all games this iteration
|
||||
wins = {1: 0, -1: 0, 0: 0}
|
||||
# Showcase game
|
||||
if showcase_samples:
|
||||
last_val = showcase_samples[-1][2]
|
||||
if last_val == WIN_REWARD:
|
||||
wins[1] += 1
|
||||
elif last_val == LOSS_REWARD:
|
||||
wins[-1] += 1
|
||||
else:
|
||||
wins[0] += 1
|
||||
# Parallel games
|
||||
if remaining > 0 and _state["running"]:
|
||||
for samples in results:
|
||||
if samples:
|
||||
last_val = samples[-1][2]
|
||||
if last_val == WIN_REWARD:
|
||||
wins[1] += 1
|
||||
elif last_val == LOSS_REWARD:
|
||||
wins[-1] += 1
|
||||
else:
|
||||
wins[0] += 1
|
||||
|
||||
with _lock:
|
||||
_state["win_history"].append((wins[1], wins[-1], wins[0]))
|
||||
|
||||
# Train
|
||||
if len(replay_buffer) >= BATCH_SIZE and _state["running"]:
|
||||
with _lock:
|
||||
_state["phase"] = "training"
|
||||
_state["status"] = f"Iter {iteration} - Training..."
|
||||
|
||||
sample_size = min(len(replay_buffer), BATCH_SIZE * EPOCHS_PER_ITERATION)
|
||||
indices = np.random.choice(len(replay_buffer), size=sample_size, replace=False)
|
||||
batch = [replay_buffer[i] for i in indices]
|
||||
|
||||
states = np.array([s[0] for s in batch])
|
||||
policies = np.array([s[1] for s in batch])
|
||||
values = np.array([s[2] for s in batch]).reshape(-1, 1)
|
||||
|
||||
history = model.fit(
|
||||
states,
|
||||
{"policy_logits": policies, "value": values},
|
||||
batch_size=BATCH_SIZE,
|
||||
epochs=EPOCHS_PER_ITERATION,
|
||||
verbose=0,
|
||||
)
|
||||
|
||||
with _lock:
|
||||
_state["policy_losses"].append(history.history["policy_logits_loss"][-1])
|
||||
_state["value_losses"].append(history.history["value_loss"][-1])
|
||||
|
||||
# Checkpoint
|
||||
if iteration % CHECKPOINT_INTERVAL == 0:
|
||||
path = os.path.join(CHECKPOINT_DIR, f"model_iter{iteration}.keras")
|
||||
model.save(path)
|
||||
|
||||
if _state["running"]:
|
||||
final_path = os.path.join(CHECKPOINT_DIR, "model_final.keras")
|
||||
model.save(final_path)
|
||||
|
||||
with _lock:
|
||||
_state["phase"] = "done"
|
||||
_state["status"] = "Training complete!"
|
||||
|
||||
|
||||
# ── Drawing helpers ─────────────────────────────────────────────────
|
||||
|
||||
def _draw_board(surface, board, x0, y0):
|
||||
"""Draw the Connect Four board."""
|
||||
# Board background
|
||||
pygame.draw.rect(surface, BOARD_BG, (x0, y0, BOARD_W, BOARD_H), border_radius=8)
|
||||
|
||||
for r in range(ROWS):
|
||||
for c in range(COLS):
|
||||
cx = x0 + c * CELL + CELL // 2
|
||||
cy = y0 + r * CELL + CELL // 2
|
||||
radius = CELL // 2 - 6
|
||||
|
||||
val = board[r, c]
|
||||
if val == 1:
|
||||
color = P1_COLOR
|
||||
elif val == -1:
|
||||
color = P2_COLOR
|
||||
else:
|
||||
color = EMPTY
|
||||
|
||||
pygame.draw.circle(surface, color, (cx, cy), radius)
|
||||
pygame.draw.circle(surface, GRID_LINE, (cx, cy), radius, 2)
|
||||
|
||||
|
||||
def _draw_chart(surface, x, y, w, h, series_list, colors, title, font):
|
||||
"""Draw a simple line chart with multiple series."""
|
||||
pygame.draw.rect(surface, CHART_BG, (x, y, w, h), border_radius=6)
|
||||
pygame.draw.rect(surface, (60, 60, 75), (x, y, w, h), 1, border_radius=6)
|
||||
|
||||
# Title
|
||||
title_surf = font.render(title, True, TEXT_COLOR)
|
||||
surface.blit(title_surf, (x + 8, y + 4))
|
||||
|
||||
chart_x = x + 8
|
||||
chart_y = y + 24
|
||||
chart_w = w - 16
|
||||
chart_h = h - 32
|
||||
|
||||
if not any(series_list):
|
||||
return
|
||||
|
||||
# Find global min/max
|
||||
all_vals = [v for s in series_list if s for v in s]
|
||||
if not all_vals:
|
||||
return
|
||||
min_val = min(all_vals)
|
||||
max_val = max(all_vals)
|
||||
val_range = max_val - min_val if max_val != min_val else 1.0
|
||||
|
||||
for series, color in zip(series_list, colors):
|
||||
if len(series) < 2:
|
||||
continue
|
||||
points = []
|
||||
for i, v in enumerate(series):
|
||||
px = chart_x + int(i / (len(series) - 1) * chart_w)
|
||||
py = chart_y + chart_h - int((v - min_val) / val_range * chart_h)
|
||||
points.append((px, py))
|
||||
pygame.draw.lines(surface, color, False, points, 2)
|
||||
|
||||
|
||||
def _draw_stacked_bar(surface, x, y, w, h, win_history, font):
|
||||
"""Draw stacked bar chart of win rates."""
|
||||
pygame.draw.rect(surface, CHART_BG, (x, y, w, h), border_radius=6)
|
||||
pygame.draw.rect(surface, (60, 60, 75), (x, y, w, h), 1, border_radius=6)
|
||||
|
||||
title_surf = font.render("Win rates per iteration", True, TEXT_COLOR)
|
||||
surface.blit(title_surf, (x + 8, y + 4))
|
||||
|
||||
if not win_history:
|
||||
return
|
||||
|
||||
chart_x = x + 8
|
||||
chart_y = y + 24
|
||||
chart_w = w - 16
|
||||
chart_h = h - 48
|
||||
|
||||
n = len(win_history)
|
||||
bar_w = max(2, chart_w // max(n, 1))
|
||||
|
||||
for i, (p1, p2, dr) in enumerate(win_history):
|
||||
total = p1 + p2 + dr
|
||||
if total == 0:
|
||||
continue
|
||||
bx = chart_x + int(i / max(n, 1) * chart_w)
|
||||
|
||||
# Stack: P1 (bottom), draws (middle), P2 (top)
|
||||
h1 = int(p1 / total * chart_h)
|
||||
hd = int(dr / total * chart_h)
|
||||
h2 = chart_h - h1 - hd
|
||||
|
||||
by = chart_y
|
||||
pygame.draw.rect(surface, P2_CHART, (bx, by, bar_w - 1, h2))
|
||||
by += h2
|
||||
pygame.draw.rect(surface, DRAW_CHART, (bx, by, bar_w - 1, hd))
|
||||
by += hd
|
||||
pygame.draw.rect(surface, P1_CHART, (bx, by, bar_w - 1, h1))
|
||||
|
||||
# Legend
|
||||
ly = y + h - 18
|
||||
for label, color, lx in [("P1", P1_CHART, x + 8), ("Draw", DRAW_CHART, x + 70), ("P2", P2_CHART, x + 150)]:
|
||||
pygame.draw.rect(surface, color, (lx, ly, 12, 12))
|
||||
surface.blit(font.render(label, True, TEXT_COLOR), (lx + 16, ly - 2))
|
||||
|
||||
|
||||
def run_visualized():
|
||||
"""Launch pygame window and run training with live visualization."""
|
||||
pygame.init()
|
||||
screen = pygame.display.set_mode((WIN_W, WIN_H))
|
||||
pygame.display.set_caption("Connect Four RL Training")
|
||||
clock = pygame.time.Clock()
|
||||
font = pygame.font.SysFont("monospace", 14)
|
||||
font_big = pygame.font.SysFont("monospace", 18, bold=True)
|
||||
|
||||
# Start training in background thread
|
||||
train_thread = threading.Thread(target=_training_thread, daemon=True)
|
||||
train_thread.start()
|
||||
|
||||
running = True
|
||||
while running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
running = False
|
||||
_state["running"] = False
|
||||
elif event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
running = False
|
||||
_state["running"] = False
|
||||
elif event.key == pygame.K_UP:
|
||||
_state["move_delay"] = max(0.05, _state["move_delay"] - 0.05)
|
||||
elif event.key == pygame.K_DOWN:
|
||||
_state["move_delay"] = min(2.0, _state["move_delay"] + 0.05)
|
||||
|
||||
screen.fill(BG)
|
||||
|
||||
with _lock:
|
||||
board = _state["board"].copy()
|
||||
iteration = _state["iteration"]
|
||||
phase = _state["phase"]
|
||||
status = _state["status"]
|
||||
policy_losses = list(_state["policy_losses"])
|
||||
value_losses = list(_state["value_losses"])
|
||||
win_history = list(_state["win_history"])
|
||||
winner = _state["winner"]
|
||||
delay = _state["move_delay"]
|
||||
|
||||
# ── Left: game board ────────────────────────────────────
|
||||
bx, by = MARGIN, MARGIN
|
||||
_draw_board(screen, board, bx, by)
|
||||
|
||||
# Winner overlay
|
||||
if winner != 0 and phase == "self-play":
|
||||
label = f"Player {1 if winner == 1 else 2} wins!"
|
||||
color = P1_COLOR if winner == 1 else P2_COLOR
|
||||
win_surf = font_big.render(label, True, color)
|
||||
wrect = win_surf.get_rect(center=(bx + BOARD_W // 2, by + BOARD_H + 2))
|
||||
if wrect.bottom < WIN_H:
|
||||
screen.blit(win_surf, wrect)
|
||||
|
||||
# ── Right panel ────────────────────────────────────────
|
||||
px = BOARD_W + MARGIN * 2
|
||||
py = MARGIN
|
||||
|
||||
# Status
|
||||
status_surf = font_big.render(status, True, TEXT_COLOR)
|
||||
screen.blit(status_surf, (px, py))
|
||||
py += 28
|
||||
|
||||
iter_surf = font.render(f"Iteration: {iteration}/{NUM_ITERATIONS} Phase: {phase}", True, TEXT_COLOR)
|
||||
screen.blit(iter_surf, (px, py))
|
||||
py += 20
|
||||
|
||||
delay_surf = font.render(f"Move delay: {delay:.2f}s (Up/Down to adjust)", True, (150, 150, 170))
|
||||
screen.blit(delay_surf, (px, py))
|
||||
py += 28
|
||||
|
||||
# Loss chart
|
||||
chart_h = 140
|
||||
_draw_chart(
|
||||
screen, px, py, PANEL_W, chart_h,
|
||||
[policy_losses, value_losses],
|
||||
[POLICY_LINE, VALUE_LINE],
|
||||
"Loss (blue=policy, orange=value)",
|
||||
font,
|
||||
)
|
||||
py += chart_h + 12
|
||||
|
||||
# Win rate chart
|
||||
bar_h = 160
|
||||
_draw_stacked_bar(screen, px, py, PANEL_W, bar_h, win_history, font)
|
||||
py += bar_h + 12
|
||||
|
||||
# Latest stats
|
||||
if policy_losses:
|
||||
pl = font.render(f"Policy loss: {policy_losses[-1]:.4f}", True, POLICY_LINE)
|
||||
screen.blit(pl, (px, py))
|
||||
py += 18
|
||||
if value_losses:
|
||||
vl = font.render(f"Value loss: {value_losses[-1]:.4f}", True, VALUE_LINE)
|
||||
screen.blit(vl, (px, py))
|
||||
py += 18
|
||||
if win_history:
|
||||
p1, p2, dr = win_history[-1]
|
||||
ws = font.render(f"Last iter: P1={p1} P2={p2} Draw={dr}", True, TEXT_COLOR)
|
||||
screen.blit(ws, (px, py))
|
||||
|
||||
pygame.display.flip()
|
||||
clock.tick(FPS)
|
||||
|
||||
pygame.quit()
|
||||
_state["running"] = False
|
||||
train_thread.join(timeout=5)
|
||||
+566
-198
@@ -1,278 +1,646 @@
|
||||
#include <Arduino.h>
|
||||
#include <FastLED.h>
|
||||
#include <Encoder.h>
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
#ifndef SHOW_BORDER
|
||||
#define SHOW_BORDER 1
|
||||
#endif
|
||||
|
||||
#ifndef SENSITIVITY
|
||||
#define SENSITIVITY 4
|
||||
#endif
|
||||
|
||||
#ifndef LED_PIN
|
||||
#define LED_PIN 4
|
||||
#endif
|
||||
|
||||
#ifndef ENC_A
|
||||
#define ENC_A 0
|
||||
#endif
|
||||
|
||||
#ifndef ENC_B
|
||||
#define ENC_B 1
|
||||
#endif
|
||||
|
||||
#ifndef ENC_SW
|
||||
#define ENC_SW 2
|
||||
#endif
|
||||
|
||||
#define NUM_LEDS 64
|
||||
|
||||
#ifndef MAX_GAME_LOG
|
||||
#define MAX_GAME_LOG 5
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_LOOK_AHEAD
|
||||
#define DEFAULT_LOOK_AHEAD 8
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_BRIGHTNESS
|
||||
#define DEFAULT_BRIGHTNESS 25
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_IDLE_TIMEOUT
|
||||
#define DEFAULT_IDLE_TIMEOUT 60
|
||||
#endif
|
||||
|
||||
#ifndef DEMO_RESET_PAUSE
|
||||
#define DEMO_RESET_PAUSE 30000
|
||||
#endif
|
||||
|
||||
#ifndef WIFI_SSID
|
||||
#define WIFI_SSID "Connect4"
|
||||
#endif
|
||||
|
||||
const int COLS = 7;
|
||||
const int ROWS = 6;
|
||||
const int LOOK_AHEAD = 6; // Depth 6 is very stable and tough for C3
|
||||
const int colOrder[] = {3, 2, 4, 1, 5, 0, 6};
|
||||
|
||||
CRGB leds[NUM_LEDS];
|
||||
Encoder *myEnc;
|
||||
Encoder myEnc(ENC_A, ENC_B);
|
||||
WebServer server(80);
|
||||
Preferences prefs;
|
||||
|
||||
int8_t board[COLS][ROWS];
|
||||
bool winMask[NUM_LEDS];
|
||||
enum State { MENU, PLAYING, FINISHED_WIN, FINISHED_DRAW };
|
||||
int8_t board[COLS][ROWS];
|
||||
bool winMask[NUM_LEDS];
|
||||
enum State { MENU, PLAYING, AI_TURN, FINISHED_WIN, FINISHED_DRAW, DEMO };
|
||||
State gameState = MENU;
|
||||
|
||||
int8_t menuMode = 0; // 0: P1-Yellow, 1: P1-Red, 2: PvP
|
||||
int8_t currentPlayer = 1; // 1: Yellow, 2: Red
|
||||
int8_t menuMode = 0;
|
||||
int8_t currentPlayer = 1;
|
||||
int8_t winnerPlayer = 0;
|
||||
int8_t activeCol = 3;
|
||||
long oldEncPos = -999;
|
||||
uint32_t lastActivityTime = 0;
|
||||
uint32_t demoResetTimer = 0;
|
||||
uint32_t globalInputCooldown = 0;
|
||||
uint8_t demoPly[2] = {4, 4};
|
||||
bool abortAi = false;
|
||||
bool lastButtonState = HIGH;
|
||||
|
||||
uint8_t currentLookAhead = DEFAULT_LOOK_AHEAD;
|
||||
uint8_t currentBrightness = DEFAULT_BRIGHTNESS;
|
||||
uint32_t currentIdleTimeoutMs = DEFAULT_IDLE_TIMEOUT * 1000;
|
||||
bool blunderEnabled = false;
|
||||
uint8_t blunderChance = 20;
|
||||
|
||||
uint8_t aiBrightness = 0;
|
||||
bool aiFadeUp = true;
|
||||
|
||||
// --- Helper Functions ---
|
||||
struct GameEntry {
|
||||
char type;
|
||||
uint8_t level;
|
||||
char winner;
|
||||
String moves;
|
||||
};
|
||||
|
||||
GameEntry gameLog[MAX_GAME_LOG];
|
||||
uint8_t gameLogCount = 0;
|
||||
String currentMoves = "";
|
||||
int8_t gameMenuMode = 0;
|
||||
uint8_t gameLevel = 0;
|
||||
|
||||
// --- Prototypes ---
|
||||
CRGB playerColor(int8_t player);
|
||||
int getIdx(int x, int y);
|
||||
void resetBoard();
|
||||
void drawBorder(CRGB color);
|
||||
void logGame(int8_t winner);
|
||||
void saveGameLog();
|
||||
void loadGameLog();
|
||||
void drawStaticUI();
|
||||
void renderBoard();
|
||||
void showMenu();
|
||||
int getFirstEmptyRow(int col);
|
||||
bool isBoardFull();
|
||||
int8_t scanBoard();
|
||||
bool checkGameEnd();
|
||||
void updateThinkingVisuals(int8_t pColor, int8_t column);
|
||||
void animateDrop(int col, int player);
|
||||
void moveDiscToCol(int startCol, int targetCol, int player, int speed);
|
||||
int evaluateBoard(int8_t aiP, int8_t huP);
|
||||
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP, int8_t rootCol);
|
||||
void performAiMove(int8_t aiP);
|
||||
void randomizeDemoPlies();
|
||||
void handleRoot();
|
||||
void handleSave();
|
||||
void handleMenu(long newPos, bool pressed);
|
||||
void handlePlaying(long newPos, bool pressed);
|
||||
void handleAiTurn();
|
||||
void handleDemo();
|
||||
void handleFinished();
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
CRGB playerColor(int8_t player) {
|
||||
return (player == 1) ? CRGB::Yellow : CRGB::Red;
|
||||
}
|
||||
|
||||
int getIdx(int x, int y) { return (y * 8) + x; }
|
||||
|
||||
void resetBoard() {
|
||||
memset(board, 0, sizeof(board));
|
||||
winnerPlayer = 0;
|
||||
}
|
||||
|
||||
void drawBorder(CRGB color) {
|
||||
for (int x = 0; x < 7; x++) leds[getIdx(x, 1)] = color;
|
||||
for (int y = 1; y < 8; y++) leds[getIdx(7, y)] = color;
|
||||
}
|
||||
|
||||
void logGame(int8_t winner) {
|
||||
char type = (gameMenuMode == 0) ? 'Y' : (gameMenuMode == 1) ? 'R' : '2';
|
||||
char winChar = (winner == 1) ? 'Y' : (winner == 2) ? 'R' : 'D';
|
||||
GameEntry entry = { type, gameLevel, winChar, currentMoves };
|
||||
if (gameLogCount < MAX_GAME_LOG) { gameLog[gameLogCount++] = entry; }
|
||||
else { for (int i = 0; i < MAX_GAME_LOG - 1; i++) gameLog[i] = gameLog[i + 1]; gameLog[MAX_GAME_LOG - 1] = entry; }
|
||||
saveGameLog();
|
||||
}
|
||||
|
||||
void saveGameLog() {
|
||||
prefs.putUChar("glc", gameLogCount);
|
||||
for (int i = 0; i < gameLogCount; i++) {
|
||||
String val = String(gameLog[i].type) + ":" + String(gameLog[i].level) + ":" + String(gameLog[i].winner) + ":" + gameLog[i].moves;
|
||||
prefs.putString(("g" + String(i)).c_str(), val);
|
||||
}
|
||||
}
|
||||
|
||||
void loadGameLog() {
|
||||
gameLogCount = prefs.getUChar("glc", 0);
|
||||
if (gameLogCount > MAX_GAME_LOG) gameLogCount = MAX_GAME_LOG;
|
||||
for (int i = 0; i < gameLogCount; i++) {
|
||||
String val = prefs.getString(("g" + String(i)).c_str(), "");
|
||||
if (val.length() < 5) { gameLogCount = i; break; }
|
||||
gameLog[i].type = val.charAt(0);
|
||||
int sep1 = val.indexOf(':', 2);
|
||||
gameLog[i].level = val.substring(2, sep1).toInt();
|
||||
gameLog[i].winner = val.charAt(sep1 + 1);
|
||||
gameLog[i].moves = val.substring(sep1 + 3);
|
||||
}
|
||||
}
|
||||
|
||||
void randomizeDemoPlies() {
|
||||
uint8_t strong = random(4, 6);
|
||||
uint8_t weak = random(2, 4);
|
||||
if (random(2) == 0) { demoPly[0] = strong; demoPly[1] = weak; }
|
||||
else { demoPly[0] = weak; demoPly[1] = strong; }
|
||||
}
|
||||
|
||||
// --- Display ---
|
||||
|
||||
void drawStaticUI() {
|
||||
FastLED.clear();
|
||||
for(int x = 0; x < 7; x++) leds[getIdx(x, 1)] = CRGB::Blue;
|
||||
for(int y = 1; y < 8; y++) leds[getIdx(7, y)] = CRGB::Blue;
|
||||
#if SHOW_BORDER == 1
|
||||
CRGB borderColor = CRGB::Blue;
|
||||
if (gameState == DEMO || gameState == FINISHED_WIN || gameState == FINISHED_DRAW) {
|
||||
uint8_t glow = beat8(15);
|
||||
borderColor = blend(CRGB::Blue, CRGB::White, glow / 4);
|
||||
}
|
||||
drawBorder(borderColor);
|
||||
#endif
|
||||
}
|
||||
|
||||
void renderBoard() {
|
||||
drawStaticUI();
|
||||
for(int c=0; c<COLS; c++) {
|
||||
for(int r=0; r<ROWS; r++) {
|
||||
if(board[c][r] == 1) leds[getIdx(c, 7-r)] = CRGB::Yellow;
|
||||
if(board[c][r] == 2) leds[getIdx(c, 7-r)] = CRGB::Red;
|
||||
if (gameState == PLAYING || gameState == AI_TURN) {
|
||||
CRGB indicator = (menuMode == 0) ? CRGB::Yellow : (menuMode == 1) ? CRGB::Red : CRGB::Blue;
|
||||
indicator.nscale8(25);
|
||||
leds[getIdx(7, 0)] = indicator;
|
||||
}
|
||||
for (int c = 0; c < COLS; c++) {
|
||||
for (int r = 0; r < ROWS; r++) {
|
||||
if (board[c][r] != 0) leds[getIdx(c, 7 - r)] = playerColor(board[c][r]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void showMenu() {
|
||||
FastLED.clear();
|
||||
#if SHOW_BORDER == 1
|
||||
drawBorder(CRGB::Blue);
|
||||
#endif
|
||||
CRGB pCol = (menuMode == 1) ? CRGB::Red : CRGB::Yellow;
|
||||
if (menuMode < 2) {
|
||||
for (int y = 3; y <= 6; y++) leds[getIdx(3, y)] = pCol;
|
||||
leds[getIdx(2, 3)] = pCol; leds[getIdx(4, 3)] = pCol;
|
||||
leds[getIdx(2, 6)] = pCol; leds[getIdx(4, 6)] = pCol;
|
||||
} else {
|
||||
for (int y = 3; y <= 6; y++) { leds[getIdx(2, y)] = CRGB::Yellow; leds[getIdx(4, y)] = CRGB::Red; }
|
||||
leds[getIdx(1, 3)] = CRGB::Yellow; leds[getIdx(3, 3)] = CRGB::Yellow;
|
||||
leds[getIdx(1, 6)] = CRGB::Yellow; leds[getIdx(3, 6)] = CRGB::Yellow;
|
||||
leds[getIdx(3, 3)] = CRGB::Red; leds[getIdx(5, 3)] = CRGB::Red;
|
||||
leds[getIdx(3, 6)] = CRGB::Red; leds[getIdx(5, 6)] = CRGB::Red;
|
||||
}
|
||||
FastLED.show();
|
||||
}
|
||||
|
||||
// --- Game logic ---
|
||||
|
||||
int getFirstEmptyRow(int col) {
|
||||
for (int r = 0; r < ROWS; r++) { if (board[col][r] == 0) return r; }
|
||||
for (int r = 0; r < ROWS; r++) if (board[col][r] == 0) return r;
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool isBoardFull() {
|
||||
for (int c = 0; c < COLS; c++) if (board[c][5] == 0) return false;
|
||||
for (int c = 0; c < COLS; c++) if (board[c][ROWS - 1] == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scans board and fills winMask if 4+ connected
|
||||
bool scanBoard(int8_t p) {
|
||||
bool found = false;
|
||||
int8_t scanBoard() {
|
||||
memset(winMask, 0, sizeof(winMask));
|
||||
auto checkLine = [&](int x, int y, int dx, int dy) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < 7; i++) {
|
||||
int nx = x + i * dx; int ny = y + i * dy;
|
||||
if (nx >= 0 && nx < COLS && ny >= 0 && ny < ROWS && board[nx][ny] == p) {
|
||||
count++;
|
||||
} else {
|
||||
if (count >= 4) {
|
||||
for (int j = 1; j <= count; j++)
|
||||
winMask[getIdx(nx - j * dx, 7 - (ny - j * dy))] = true;
|
||||
found = true;
|
||||
}
|
||||
count = 0;
|
||||
}
|
||||
auto check = [&](int c, int r, int dc, int dr) {
|
||||
int8_t p = board[c][r];
|
||||
if (p != 0 && board[c+dc][r+dr] == p && board[c+2*dc][r+2*dr] == p && board[c+3*dc][r+3*dr] == p) {
|
||||
for (int i = 0; i < 4; i++) winMask[getIdx(c+i*dc, 7-(r+i*dr))] = true;
|
||||
return p;
|
||||
}
|
||||
return (int8_t)0;
|
||||
};
|
||||
for (int i = 0; i < ROWS; i++) checkLine(0, i, 1, 0);
|
||||
for (int i = 0; i < COLS; i++) checkLine(i, 0, 0, 1);
|
||||
for (int i = -5; i < 7; i++) { checkLine(i, 0, 1, 1); checkLine(i, 5, 1, -1); }
|
||||
return found;
|
||||
for (int r=0; r<6; r++) for (int c=0; c<4; c++) { int8_t res = check(c,r,1,0); if(res) return res; }
|
||||
for (int r=0; r<3; r++) for (int c=0; c<7; c++) { int8_t res = check(c,r,0,1); if(res) return res; }
|
||||
for (int r=0; r<3; r++) for (int c=0; c<4; c++) { int8_t res = check(c,r,1,1); if(res) return res; }
|
||||
for (int r=3; r<6; r++) for (int c=0; c<4; c++) { int8_t res = check(c,r,1,-1); if(res) return res; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- AI Thinking Visualization ---
|
||||
void updateThinkingLED() {
|
||||
int evaluateBoard(int8_t aiP, int8_t huP) {
|
||||
int score = 0;
|
||||
int aiThreats = 0, huThreats = 0;
|
||||
|
||||
// Center column bonus
|
||||
for (int r = 0; r < ROWS; r++) {
|
||||
if (board[3][r] == aiP) score += 3;
|
||||
else if (board[3][r] == huP) score -= 3;
|
||||
}
|
||||
|
||||
// Score a window of 4 cells by piece counts
|
||||
auto scoreWindow = [&](int c, int r, int dc, int dr) -> int {
|
||||
int ai = 0, hu = 0, emptyC = -1, emptyR = -1;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int cc = c + i * dc;
|
||||
int rr = r + i * dr;
|
||||
int8_t v = board[cc][rr];
|
||||
if (v == aiP) ai++;
|
||||
else if (v == huP) hu++;
|
||||
else { emptyC = cc; emptyR = rr; }
|
||||
}
|
||||
if (ai > 0 && hu > 0) return 0;
|
||||
if (ai == 3) {
|
||||
aiThreats++;
|
||||
bool playable = emptyR == 0 || board[emptyC][emptyR - 1] != 0;
|
||||
return playable ? 100 : 40;
|
||||
}
|
||||
if (ai == 2) return 5;
|
||||
if (hu == 3) {
|
||||
huThreats++;
|
||||
bool playable = emptyR == 0 || board[emptyC][emptyR - 1] != 0;
|
||||
return playable ? -100 : -40;
|
||||
}
|
||||
if (hu == 2) return -5;
|
||||
return 0;
|
||||
};
|
||||
|
||||
for (int r = 0; r < 6; r++) for (int c = 0; c < 4; c++) score += scoreWindow(c, r, 1, 0);
|
||||
for (int r = 0; r < 3; r++) for (int c = 0; c < 7; c++) score += scoreWindow(c, r, 0, 1);
|
||||
for (int r = 0; r < 3; r++) for (int c = 0; c < 4; c++) score += scoreWindow(c, r, 1, 1);
|
||||
for (int r = 3; r < 6; r++) for (int c = 0; c < 4; c++) score += scoreWindow(c, r, 1, -1);
|
||||
|
||||
// Fork bonus: multiple threats are disproportionately dangerous
|
||||
if (aiThreats >= 2) score += 200;
|
||||
if (huThreats >= 2) score -= 200;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
bool checkGameEnd() {
|
||||
winnerPlayer = scanBoard();
|
||||
bool won = winnerPlayer != 0;
|
||||
bool draw = !won && isBoardFull();
|
||||
if (!won && !draw) return false;
|
||||
if (gameState != DEMO) logGame(won ? winnerPlayer : 0);
|
||||
gameState = won ? FINISHED_WIN : FINISHED_DRAW;
|
||||
demoResetTimer = millis();
|
||||
lastActivityTime = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Animation ---
|
||||
|
||||
void updateThinkingVisuals(int8_t pColor, int8_t column) {
|
||||
static uint32_t lastCycle = 0;
|
||||
if (millis() - lastCycle < 20) return;
|
||||
if (millis() - lastCycle < 20) return;
|
||||
lastCycle = millis();
|
||||
if (aiFadeUp) { aiBrightness += 15; if (aiBrightness >= 240) aiFadeUp = false; }
|
||||
else { aiBrightness -= 15; if (aiBrightness <= 15) aiFadeUp = true; }
|
||||
|
||||
// Pulse in the computer's color
|
||||
CRGB compColor = (menuMode == 0) ? CRGB::Red : CRGB::Yellow;
|
||||
leds[getIdx(7, 0)] = compColor.nscale8(aiBrightness);
|
||||
if (aiFadeUp) { aiBrightness += 25; if (aiBrightness >= 230) aiFadeUp = false; }
|
||||
else { aiBrightness -= 25; if (aiBrightness <= 25) aiFadeUp = true; }
|
||||
for (int x = 0; x < COLS; x++) leds[getIdx(x, 0)] = CRGB::Black;
|
||||
CRGB aiColor = playerColor(pColor);
|
||||
aiColor.nscale8(aiBrightness);
|
||||
leds[getIdx(column, 0)] = aiColor;
|
||||
FastLED.show();
|
||||
}
|
||||
|
||||
// --- Minimax Logic ---
|
||||
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP) {
|
||||
// Check wins within the simulation
|
||||
if (scanBoard(aiP)) return 1000 + depth;
|
||||
if (scanBoard(huP)) return -1000 - depth;
|
||||
if (depth == 0 || isBoardFull()) return 0;
|
||||
|
||||
int order[] = {3, 2, 4, 1, 5, 0, 6};
|
||||
if (isMax) {
|
||||
int maxEval = -2000;
|
||||
for (int c : order) {
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
board[c][r] = aiP;
|
||||
int eval = minimax(depth - 1, alpha, beta, false, aiP, huP);
|
||||
board[c][r] = 0;
|
||||
maxEval = max(maxEval, eval);
|
||||
alpha = max(alpha, eval);
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
}
|
||||
return maxEval;
|
||||
} else {
|
||||
int minEval = 2000;
|
||||
for (int c : order) {
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
board[c][r] = huP;
|
||||
int eval = minimax(depth - 1, alpha, beta, true, aiP, huP);
|
||||
board[c][r] = 0;
|
||||
minEval = min(minEval, eval);
|
||||
beta = min(beta, eval);
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
}
|
||||
return minEval;
|
||||
void animateDrop(int col, int player) {
|
||||
int targetRow = getFirstEmptyRow(col);
|
||||
if (targetRow == -1) return;
|
||||
if (gameState != DEMO) currentMoves += String(col);
|
||||
for (int r = 5; r >= targetRow; r--) {
|
||||
renderBoard();
|
||||
leds[getIdx(col, 7 - r)] = playerColor(player);
|
||||
FastLED.show();
|
||||
delay(max(10, 60 - (5 - r) * 10));
|
||||
}
|
||||
board[col][targetRow] = player;
|
||||
}
|
||||
|
||||
void performAiMove() {
|
||||
int8_t aiP = (menuMode == 0) ? 2 : 1; // AI is Red if player chose Yellow
|
||||
int8_t huP = (menuMode == 0) ? 1 : 2;
|
||||
|
||||
aiBrightness = 0; aiFadeUp = true;
|
||||
int bestScore = -30000;
|
||||
int bestCol = -1;
|
||||
void moveDiscToCol(int startCol, int targetCol, int player, int speed) {
|
||||
int current = startCol;
|
||||
CRGB colr = playerColor(player);
|
||||
while (current != targetCol && !abortAi) {
|
||||
if (gameState == DEMO && digitalRead(ENC_SW) == LOW) { abortAi = true; break; }
|
||||
leds[getIdx(current, 0)] = CRGB::Black;
|
||||
current += (targetCol > current) ? 1 : -1;
|
||||
renderBoard();
|
||||
leds[getIdx(current, 0)] = colr;
|
||||
FastLED.show();
|
||||
delay(speed);
|
||||
}
|
||||
activeCol = targetCol;
|
||||
}
|
||||
|
||||
// 1. Immediate Win/Block Check
|
||||
for(int c=0; c<COLS; c++) {
|
||||
// --- AI ---
|
||||
|
||||
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP, int8_t rootCol) {
|
||||
if (gameState == DEMO && digitalRead(ENC_SW) == LOW) { abortAi = true; return 0; }
|
||||
if (depth >= currentLookAhead - 1) updateThinkingVisuals(aiP, rootCol);
|
||||
else yield();
|
||||
if (abortAi) return 0;
|
||||
|
||||
int8_t win = scanBoard();
|
||||
if (win == aiP) return 1000 + depth;
|
||||
if (win == huP) return -1000 - depth;
|
||||
if (depth == 0 || isBoardFull()) return evaluateBoard(aiP, huP);
|
||||
|
||||
int best = isMax ? -10000 : 10000;
|
||||
for (int c : colOrder) {
|
||||
if (abortAi) return 0;
|
||||
int r = getFirstEmptyRow(c);
|
||||
if(r != -1) {
|
||||
board[c][r] = aiP; if(scanBoard(aiP)) { leds[getIdx(7, 0)] = CRGB::Black; return; }
|
||||
board[c][r] = huP; if(scanBoard(huP)) { board[c][r] = aiP; leds[getIdx(7, 0)] = CRGB::Black; return; }
|
||||
if (r != -1) {
|
||||
board[c][r] = isMax ? aiP : huP;
|
||||
int score = minimax(depth - 1, alpha, beta, !isMax, aiP, huP, (depth == currentLookAhead ? c : rootCol));
|
||||
board[c][r] = 0;
|
||||
if (isMax) { if (score > best) best = score; if (best > alpha) alpha = best; }
|
||||
else { if (score < best) best = score; if (best < beta) beta = best; }
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// 2. Recursive Search
|
||||
for (int c : {3, 2, 4, 1, 5, 0, 6}) {
|
||||
void performAiMove(int8_t aiP) {
|
||||
abortAi = false;
|
||||
int huP = (aiP == 1) ? 2 : 1;
|
||||
int bestScore = -30000; int bestCol = 3;
|
||||
int originalPly = currentLookAhead;
|
||||
if (gameState == DEMO) currentLookAhead = demoPly[aiP - 1];
|
||||
|
||||
// Phase 1a: check ALL columns for instant AI win
|
||||
bool found = false;
|
||||
for (int c = 0; c < COLS && !found; c++) {
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
board[c][r] = aiP;
|
||||
int score = minimax(LOOK_AHEAD, -30000, 30000, false, aiP, huP);
|
||||
if (scanBoard() == aiP) { board[c][r] = 0; bestCol = c; found = true; break; }
|
||||
board[c][r] = 0;
|
||||
updateThinkingLED(); // Visual feedback
|
||||
if (score > bestScore) { bestScore = score; bestCol = c; }
|
||||
}
|
||||
}
|
||||
if (bestCol != -1) board[bestCol][getFirstEmptyRow(bestCol)] = aiP;
|
||||
leds[getIdx(7, 0)] = CRGB::Black;
|
||||
// Phase 1b: check ALL columns for opponent block
|
||||
for (int c = 0; c < COLS && !found; c++) {
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
board[c][r] = huP;
|
||||
if (scanBoard() == huP) { board[c][r] = 0; bestCol = c; found = true; break; }
|
||||
board[c][r] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: blunder — pick a random column instead of deep search
|
||||
if (!found && blunderEnabled && gameState != DEMO && (random(100) < blunderChance)) {
|
||||
int validCols[COLS], count = 0;
|
||||
for (int c = 0; c < COLS; c++) if (getFirstEmptyRow(c) != -1) validCols[count++] = c;
|
||||
bestCol = validCols[random(count)];
|
||||
found = true;
|
||||
}
|
||||
|
||||
// Phase 3: deep minimax search
|
||||
if (!found) {
|
||||
for (int c : colOrder) {
|
||||
if (abortAi) break;
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
board[c][r] = aiP;
|
||||
int score = minimax(currentLookAhead, -30000, 30000, false, aiP, huP, c);
|
||||
board[c][r] = 0;
|
||||
if (score > bestScore) { bestScore = score; bestCol = c; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentLookAhead = originalPly;
|
||||
if (!abortAi) { moveDiscToCol(activeCol, bestCol, aiP, 80); if (!abortAi) { delay(100); animateDrop(bestCol, aiP); } }
|
||||
}
|
||||
|
||||
void showMenu() {
|
||||
drawStaticUI();
|
||||
if (menuMode < 2) {
|
||||
CRGB p1Col = (menuMode == 1) ? CRGB::Red : CRGB::Yellow;
|
||||
for(int y=3; y<=6; y++) leds[getIdx(3, y)] = p1Col;
|
||||
leds[getIdx(2,3)] = p1Col; leds[getIdx(4,3)] = p1Col;
|
||||
leds[getIdx(2,6)] = p1Col; leds[getIdx(4,6)] = p1Col;
|
||||
// --- Web handlers ---
|
||||
|
||||
void handleRoot() {
|
||||
String html = "<html><head><meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||
"<style>"
|
||||
"body{font-family:sans-serif;background:#121212;color:white;text-align:center;}"
|
||||
".card{background:#222;padding:25px;border-radius:15px;display:inline-block;margin-top:20px;}"
|
||||
"input{width:100%;padding:10px;margin:10px 0;border-radius:5px;border:none;}"
|
||||
"table{width:100%;} th,td{padding:4px;}"
|
||||
"</style></head><body>"
|
||||
"<h1>Connect 4 Admin</h1>"
|
||||
"<div class='card'><form action='/save' method='POST'>";
|
||||
html += "Base AI Ply:<input type='number' name='ply' value='" + String(currentLookAhead) + "'>";
|
||||
html += "Brightness:<input type='number' name='br' value='" + String(currentBrightness) + "'>";
|
||||
html += "Idle Timeout (s):<input type='number' name='idle' value='" + String(currentIdleTimeoutMs / 1000) + "'>";
|
||||
html += "Blunders: <input type='checkbox' name='blunder' " + String(blunderEnabled ? "checked" : "") + ">";
|
||||
html += " Chance (%):<input type='number' name='blunderPct' min='1' max='100' value='" + String(blunderChance) + "'><br><br>";
|
||||
html += "<input type='submit' value='Save Settings' style='background:#28a745;color:white;'>";
|
||||
html += "</form></div>";
|
||||
html += "<div class='card' style='margin-top:15px;text-align:left;'><h3 style='text-align:center;'>Game Log</h3>";
|
||||
if (gameLogCount == 0) {
|
||||
html += "<p style='text-align:center;'>No games played yet.</p>";
|
||||
} else {
|
||||
for(int y=3; y<=6; y++) { leds[getIdx(2, y)] = CRGB::Yellow; leds[getIdx(4, y)] = CRGB::Red; }
|
||||
leds[getIdx(1, 3)] = CRGB::Yellow; leds[getIdx(1, 6)] = CRGB::Yellow;
|
||||
leds[getIdx(5, 3)] = CRGB::Red; leds[getIdx(5, 6)] = CRGB::Red;
|
||||
leds[getIdx(3, 3)] = CRGB::Red; leds[getIdx(3, 6)] = CRGB::Yellow;
|
||||
html += "<table><tr><th>Type</th><th>Lvl</th><th>Winner</th><th>Moves</th></tr>";
|
||||
for (int i = gameLogCount - 1; i >= 0; i--) {
|
||||
bool playerWon = gameLog[i].type != '2' && gameLog[i].type == gameLog[i].winner;
|
||||
html += playerWon ? "<tr style='color:#ff4444;'>" : "<tr>";
|
||||
html += "<td>" + String(gameLog[i].type) + "</td>";
|
||||
html += "<td>" + String(gameLog[i].level) + "</td>";
|
||||
html += "<td>" + String(gameLog[i].winner) + "</td>";
|
||||
html += "<td>" + gameLog[i].moves + "</td></tr>";
|
||||
}
|
||||
html += "</table>";
|
||||
}
|
||||
FastLED.show();
|
||||
html += "</div></body></html>";
|
||||
server.send(200, "text/html", html);
|
||||
}
|
||||
|
||||
void handleSave() {
|
||||
if (server.hasArg("ply")) { currentLookAhead = server.arg("ply").toInt(); prefs.putUChar("ply", currentLookAhead); }
|
||||
if (server.hasArg("br")) { currentBrightness = server.arg("br").toInt(); FastLED.setBrightness(currentBrightness); prefs.putUChar("br", currentBrightness); }
|
||||
if (server.hasArg("idle")) { currentIdleTimeoutMs = server.arg("idle").toInt() * 1000; prefs.putUInt("idle", currentIdleTimeoutMs / 1000); }
|
||||
blunderEnabled = server.hasArg("blunder"); prefs.putBool("blunder", blunderEnabled);
|
||||
if (server.hasArg("blunderPct")) { blunderChance = constrain(server.arg("blunderPct").toInt(), 1, 100); prefs.putUChar("blPct", blunderChance); }
|
||||
server.sendHeader("Location", "/"); server.send(303);
|
||||
}
|
||||
|
||||
// --- State handlers ---
|
||||
|
||||
void handleMenu(long newPos, bool pressed) {
|
||||
if (millis() > globalInputCooldown) {
|
||||
if (newPos != oldEncPos) { menuMode = (newPos % 3 + 3) % 3; oldEncPos = newPos; showMenu(); }
|
||||
if (pressed) {
|
||||
resetBoard();
|
||||
currentMoves = "";
|
||||
gameMenuMode = menuMode;
|
||||
gameLevel = currentLookAhead;
|
||||
currentPlayer = 1;
|
||||
if (menuMode == 1) gameState = AI_TURN;
|
||||
else gameState = PLAYING;
|
||||
globalInputCooldown = millis() + 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void handlePlaying(long newPos, bool pressed) {
|
||||
if (newPos != oldEncPos) { activeCol = (newPos % 7 + 7) % 7; oldEncPos = newPos; lastActivityTime = millis(); }
|
||||
renderBoard();
|
||||
leds[getIdx(activeCol, 0)] = playerColor(currentPlayer);
|
||||
FastLED.show();
|
||||
if (pressed) {
|
||||
int row = getFirstEmptyRow(activeCol);
|
||||
if (row != -1) {
|
||||
animateDrop(activeCol, currentPlayer);
|
||||
if (!checkGameEnd()) {
|
||||
if (menuMode < 2) gameState = AI_TURN;
|
||||
else currentPlayer = (currentPlayer == 1) ? 2 : 1;
|
||||
}
|
||||
lastActivityTime = millis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void handleAiTurn() {
|
||||
int8_t aiP = (menuMode == 0) ? 2 : 1;
|
||||
performAiMove(aiP);
|
||||
if (abortAi) { gameState = MENU; showMenu(); return; }
|
||||
if (!checkGameEnd()) {
|
||||
gameState = PLAYING;
|
||||
currentPlayer = (aiP == 1) ? 2 : 1;
|
||||
}
|
||||
lastActivityTime = millis();
|
||||
}
|
||||
|
||||
void handleDemo() {
|
||||
renderBoard(); FastLED.show(); delay(300);
|
||||
performAiMove(currentPlayer);
|
||||
if (abortAi) { gameState = MENU; showMenu(); globalInputCooldown = millis() + 600; lastButtonState = LOW; return; }
|
||||
if (!checkGameEnd()) {
|
||||
currentPlayer = (currentPlayer == 1) ? 2 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
void handleFinished() {
|
||||
static uint32_t lastFlash = 0;
|
||||
static bool toggle = true;
|
||||
if (millis() - lastFlash > 300) {
|
||||
lastFlash = millis();
|
||||
toggle = !toggle;
|
||||
renderBoard();
|
||||
for (int i = 0; i < NUM_LEDS; i++) {
|
||||
#if SHOW_BORDER == 1
|
||||
if (leds[i] == CRGB::Blue) continue;
|
||||
#endif
|
||||
if (gameState == FINISHED_WIN) {
|
||||
if (winMask[i]) leds[i] = toggle ? playerColor(winnerPlayer) : CRGB::Black;
|
||||
else { CRGB c = leds[i]; c.nscale8(60); leds[i] = c; }
|
||||
} else if (gameState == FINISHED_DRAW) {
|
||||
if (!toggle) leds[i] = CRGB::Black;
|
||||
}
|
||||
}
|
||||
FastLED.show();
|
||||
}
|
||||
if (millis() - demoResetTimer > DEMO_RESET_PAUSE) {
|
||||
resetBoard();
|
||||
randomizeDemoPlies();
|
||||
gameState = DEMO;
|
||||
demoResetTimer = 0;
|
||||
lastActivityTime = millis();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
myEnc = new Encoder(ENC_A, ENC_B);
|
||||
prefs.begin("c4-game", false);
|
||||
currentLookAhead = prefs.getUChar("ply", DEFAULT_LOOK_AHEAD);
|
||||
currentBrightness = prefs.getUChar("br", DEFAULT_BRIGHTNESS);
|
||||
currentIdleTimeoutMs = prefs.getUInt("idle", DEFAULT_IDLE_TIMEOUT) * 1000;
|
||||
blunderEnabled = prefs.getBool("blunder", false);
|
||||
blunderChance = prefs.getUChar("blPct", 20);
|
||||
loadGameLog();
|
||||
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
|
||||
FastLED.setBrightness(BRIGHTNESS);
|
||||
FastLED.setBrightness(currentBrightness);
|
||||
pinMode(ENC_SW, INPUT_PULLUP);
|
||||
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
|
||||
server.on("/", handleRoot);
|
||||
server.on("/save", HTTP_POST, handleSave);
|
||||
server.begin();
|
||||
lastActivityTime = millis();
|
||||
showMenu();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
long newPos = myEnc->read() / SENSITIVITY;
|
||||
bool pressed = (digitalRead(ENC_SW) == LOW);
|
||||
server.handleClient();
|
||||
long rawPos = myEnc.read();
|
||||
long newPos = rawPos / SENSITIVITY;
|
||||
bool currentButton = digitalRead(ENC_SW);
|
||||
bool pressed = false;
|
||||
if (currentButton == LOW && lastButtonState == HIGH) { if (millis() > globalInputCooldown) pressed = true; }
|
||||
lastButtonState = currentButton;
|
||||
|
||||
if (gameState == MENU) {
|
||||
if (newPos != oldEncPos) {
|
||||
menuMode = (newPos % 3 + 3) % 3;
|
||||
oldEncPos = newPos;
|
||||
showMenu();
|
||||
}
|
||||
if (pressed) {
|
||||
memset(board, 0, sizeof(board));
|
||||
gameState = PLAYING;
|
||||
// If Single Player RED, computer (1/Yellow) starts
|
||||
if (menuMode == 1) {
|
||||
currentPlayer = 1;
|
||||
renderBoard(); FastLED.show();
|
||||
performAiMove();
|
||||
currentPlayer = 2; // Set back to player
|
||||
} else {
|
||||
currentPlayer = 1; // Human starts
|
||||
}
|
||||
delay(300);
|
||||
}
|
||||
}
|
||||
else if (gameState == PLAYING) {
|
||||
if (newPos != oldEncPos) {
|
||||
activeCol = (newPos % 7 + 7) % 7;
|
||||
oldEncPos = newPos;
|
||||
}
|
||||
renderBoard();
|
||||
leds[getIdx(activeCol, 0)] = (currentPlayer == 1) ? CRGB::Yellow : CRGB::Red;
|
||||
FastLED.show();
|
||||
// Interrupt: return to menu from finished/demo states
|
||||
if ((newPos != oldEncPos || pressed) && (gameState == FINISHED_WIN || gameState == FINISHED_DRAW || gameState == DEMO)) {
|
||||
abortAi = true;
|
||||
resetBoard();
|
||||
for (int i = 0; i < 10; i++) { fadeToBlackBy(leds, NUM_LEDS, 50); FastLED.show(); delay(15); }
|
||||
gameState = MENU; showMenu(); oldEncPos = newPos; lastActivityTime = millis();
|
||||
globalInputCooldown = millis() + 600;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pressed) {
|
||||
int row = getFirstEmptyRow(activeCol);
|
||||
if (row != -1) {
|
||||
board[activeCol][row] = currentPlayer;
|
||||
renderBoard(); FastLED.show();
|
||||
|
||||
// 1. Check if the move just made ended the game
|
||||
if (scanBoard(currentPlayer)) {
|
||||
gameState = FINISHED_WIN;
|
||||
} else if (isBoardFull()) {
|
||||
gameState = FINISHED_DRAW;
|
||||
} else {
|
||||
// 2. Handle Turn Switching
|
||||
if (menuMode < 2) { // Single Player
|
||||
int8_t aiP = (menuMode == 0) ? 2 : 1;
|
||||
performAiMove();
|
||||
if (scanBoard(aiP)) {
|
||||
currentPlayer = aiP; // For the flashing color
|
||||
gameState = FINISHED_WIN;
|
||||
} else if (isBoardFull()) {
|
||||
gameState = FINISHED_DRAW;
|
||||
}
|
||||
} else { // PvP
|
||||
currentPlayer = (currentPlayer == 1) ? 2 : 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for(int i=0; i<3; i++) {
|
||||
leds[getIdx(activeCol, 0)] = CRGB::Black; FastLED.show(); delay(80);
|
||||
leds[getIdx(activeCol, 0)] = (currentPlayer == 1) ? CRGB::Yellow : CRGB::Red; FastLED.show(); delay(80);
|
||||
}
|
||||
}
|
||||
delay(300);
|
||||
// Idle timeout: enter demo mode
|
||||
if (gameState != DEMO && gameState != FINISHED_WIN && gameState != FINISHED_DRAW) {
|
||||
if (millis() - lastActivityTime > currentIdleTimeoutMs) {
|
||||
resetBoard();
|
||||
randomizeDemoPlies();
|
||||
gameState = DEMO;
|
||||
currentPlayer = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
static unsigned long lastFlash = 0;
|
||||
static bool toggle = true;
|
||||
if (millis() - lastFlash > 300) {
|
||||
lastFlash = millis(); toggle = !toggle;
|
||||
renderBoard();
|
||||
for (int i = 0; i < NUM_LEDS; i++) {
|
||||
if (gameState == FINISHED_WIN) {
|
||||
if (winMask[i]) leds[i] = toggle ? (currentPlayer == 1 ? CRGB::Yellow : CRGB::Red) : CRGB::Black;
|
||||
else if (leds[i] && leds[i] != CRGB::Blue) leds[i].nscale8(40);
|
||||
} else {
|
||||
if (leds[i] && leds[i] != CRGB::Blue) leds[i] = toggle ? leds[i] : CRGB::Black;
|
||||
}
|
||||
}
|
||||
FastLED.show();
|
||||
}
|
||||
if (pressed) { gameState = MENU; showMenu(); delay(300); }
|
||||
}
|
||||
|
||||
switch (gameState) {
|
||||
case MENU: handleMenu(newPos, pressed); break;
|
||||
case PLAYING: handlePlaying(newPos, pressed); break;
|
||||
case AI_TURN: handleAiTurn(); break;
|
||||
case DEMO: handleDemo(); break;
|
||||
case FINISHED_WIN:
|
||||
case FINISHED_DRAW: handleFinished(); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "bitarray"
|
||||
version = "3.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/06/92fdc84448d324ab8434b78e65caf4fb4c6c90b4f8ad9bdd4c8021bfaf1e/bitarray-3.8.0.tar.gz", hash = "sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d", size = 151991, upload-time = "2025-11-02T21:41:15.117Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/b0/411327a6c7f6b2bead64bb06fe60b92e0344957ec1ab0645d5ccc25fdafe/bitarray-3.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89", size = 148563, upload-time = "2025-11-02T21:40:01.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/bc/ff80d97c627d774f879da0ea93223adb1267feab7e07d5c17580ffe6d632/bitarray-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310", size = 145422, upload-time = "2025-11-02T21:40:02.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/e7/b4cb6c5689aacd0a32f3aa8a507155eaa33528c63de2f182b60843fbf700/bitarray-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c", size = 332852, upload-time = "2025-11-02T21:40:03.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/91/fbd1b047e3e2f4b65590f289c8151df1d203d75b005f5aae4e072fe77d76/bitarray-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d", size = 360801, upload-time = "2025-11-02T21:40:04.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/4a/63064c593627bac8754fdafcb5343999c93ab2aeb27bcd9d270a010abea5/bitarray-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5", size = 371408, upload-time = "2025-11-02T21:40:05.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/97/ddc07723767bdafd170f2ff6e173c940fa874192783ee464aa3c1dedf07d/bitarray-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2", size = 340033, upload-time = "2025-11-02T21:40:07.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1e/e1ea9f1146fd4af032817069ff118918d73e5de519854ce3860e2ed560ff/bitarray-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe", size = 330774, upload-time = "2025-11-02T21:40:08.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/9f/8242296c124a48d1eab471fd0838aeb7ea9c6fd720302d99ab7855d3e6d3/bitarray-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11", size = 358337, upload-time = "2025-11-02T21:40:10.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/6b/9095d75264c67d479f298c80802422464ce18c3cdd893252eeccf4997611/bitarray-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7", size = 355639, upload-time = "2025-11-02T21:40:11.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/af/c93c0ae5ef824136e90ac7ddf6cceccb1232f34240b2f55a922f874da9b4/bitarray-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860", size = 336999, upload-time = "2025-11-02T21:40:12.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0f/72c951f5997b2876355d5e671f78dd2362493254876675cf22dbd24389ae/bitarray-3.8.0-cp314-cp314-win32.whl", hash = "sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25", size = 142169, upload-time = "2025-11-02T21:40:14.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/55/ef1b4de8107bf13823da8756c20e1fbc9452228b4e837f46f6d9ddba3eb3/bitarray-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4", size = 148737, upload-time = "2025-11-02T21:40:15.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/26/bc0784136775024ac56cc67c0d6f9aa77a7770de7f82c3a7c9be11c217cd/bitarray-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e", size = 146083, upload-time = "2025-11-02T21:40:17.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/64/57984e64264bf43d93a1809e645972771566a2d0345f4896b041ce20b000/bitarray-3.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521", size = 149455, upload-time = "2025-11-02T21:40:18.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/c0/0d5f2eaef1867f462f764bdb07d1e116c33a1bf052ea21889aefe4282f5b/bitarray-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa", size = 146491, upload-time = "2025-11-02T21:40:19.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/c6/bc1261f7a8862c0c59220a484464739e52235fd1e2afcb24d7f7d3fb5702/bitarray-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8", size = 339721, upload-time = "2025-11-02T21:40:21.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/d8/289ca55dd2939ea17b1108dc53bffc0fdc5160ba44f77502dfaae35d08c6/bitarray-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3", size = 367823, upload-time = "2025-11-02T21:40:22.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/a2/61e7461ca9ac0fcb70f327a2e84b006996d2a840898e69037a39c87c6d06/bitarray-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df", size = 377341, upload-time = "2025-11-02T21:40:23.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/87/4a0c9c8bdb13916d443e04d8f8542eef9190f31425da3c17c3478c40173f/bitarray-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f", size = 344985, upload-time = "2025-11-02T21:40:25.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/4c/ff9259b916efe53695b631772e5213699c738efc2471b5ffe273f4000994/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8", size = 336796, upload-time = "2025-11-02T21:40:26.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/4b/51b2468bbddbade5e2f3b8d5db08282c5b309e8687b0f02f75a8b5ff559c/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8", size = 365085, upload-time = "2025-11-02T21:40:28.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/79/53473bfc2e052c6dbb628cdc1b156be621c77aaeb715918358b01574be55/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773", size = 361012, upload-time = "2025-11-02T21:40:29.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/242bf2e44bfc69e73fa2b954b425d761a8e632f78ea31008f1c3cfad0854/bitarray-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9", size = 340644, upload-time = "2025-11-02T21:40:31.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/01/12e5ecf30a5de28a32485f226cad4b8a546845f65f755ce0365057ab1e92/bitarray-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149", size = 143630, upload-time = "2025-11-02T21:40:32.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/92/6b6ade587b08024a8a890b07724775d29da9cf7497be5c3cbe226185e463/bitarray-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e", size = 150250, upload-time = "2025-11-02T21:40:33.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/40/be3858ffed004e47e48a2cefecdbf9b950d41098b780f9dc3aa609a88351/bitarray-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f", size = 147015, upload-time = "2025-11-02T21:40:35.064Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitstring"
|
||||
version = "4.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bitarray" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/a8/a80c890db75d5bdd5314b5de02c4144c7de94fd0cefcae51acaeb14c6a3f/bitstring-4.3.1.tar.gz", hash = "sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a", size = 251426, upload-time = "2025-03-22T09:39:06.978Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/2d/174566b533755ddf8efb32a5503af61c756a983de379f8ad3aed6a982d38/bitstring-4.3.1-py3-none-any.whl", hash = "sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a", size = 71930, upload-time = "2025-03-22T09:39:05.163Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "connect-four"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "esptool" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "esptool", specifier = ">=5.2.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esptool"
|
||||
version = "5.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bitstring" },
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "intelhex" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "reedsolo" },
|
||||
{ name = "rich-click" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/25/7b50d81a66f600a60f23258fa134201e97e854271b478ca4e21e9f694355/esptool-5.2.0.tar.gz", hash = "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6", size = 463000, upload-time = "2026-02-18T16:10:52.641Z" }
|
||||
|
||||
[[package]]
|
||||
name = "intelhex"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/37/1e7522494557d342a24cb236e2aec5d078fac8ed03ad4b61372586406b01/intelhex-2.3.0.tar.gz", hash = "sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093", size = 44513, upload-time = "2020-10-20T20:35:51.526Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/78/79461288da2b13ed0a13deb65c4ad1428acb674b95278fa9abf1cefe62a2/intelhex-2.3.0-py2.py3-none-any.whl", hash = "sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4", size = 50914, upload-time = "2020-10-20T20:35:50.162Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyserial"
|
||||
version = "3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reedsolo"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/61/a67338cbecf370d464e71b10e9a31355f909d6937c3a8d6b17dd5d5beb5e/reedsolo-1.7.0.tar.gz", hash = "sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732", size = 59723, upload-time = "2023-01-17T05:10:19.733Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich-click"
|
||||
version = "1.9.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user