add: absorb uno-online as regular subdirectory

UNO card game web app (Node.js/React) with Miku bot integration.
Previously an independent git repo (fork of mizanxali/uno-online).
Removed .git/ and absorbed into main repo for unified tracking.

Includes bot integration code: botActionExecutor, cardParser,
gameStateBuilder, and server-side bot action support.
37 files, node_modules excluded via local .gitignore.
This commit is contained in:
2026-03-04 00:21:38 +02:00
parent c708770266
commit 34b184a05a
37 changed files with 26885 additions and 0 deletions

View File

@@ -0,0 +1,387 @@
.App {
text-align: center;
}
a {
color: #ffffff;
}
/* Homepage */
.Homepage {
background-image: url('./assets/Landing-Page.gif');
background-size: cover;
margin: 0;
height: 100vh;
}
.homepage-menu {
position: relative;
top: 120px;
}
.homepage-form {
display: flex;
justify-content: center;
margin-top: 50px;
}
.homepage-form>h1 {
margin: 0 30px;
}
.homepage-join {
display: flex;
flex-direction: column;
}
.homepage-join>input {
font-size: 15px;
width: 150px;
line-height: 1.5em;
}
/* Game.js parent div */
.Game {
background-size: cover;
margin: 0;
height: 100vh;
}
/* Game.js Background */
.backgroundColorR {
background-image: url('./assets/backgrounds/bgR.png');
}
.backgroundColorG {
background-image: url('./assets/backgrounds/bgG.png');
}
.backgroundColorB {
background-image: url('./assets/backgrounds/bgB.png');
}
.backgroundColorY {
background-image: url('./assets/backgrounds/bgY.png');
}
/* UNO Cards */
.Card {
width: 6rem;
margin: 2px;
cursor: pointer;
}
/* Game.js Top Row */
.topInfo {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 60px;
height: 100px;
}
.topInfo>img {
width: 7%;
margin: 0;
padding: 0;
}
.topInfo>h1 {
margin-top: 10px;
font-size: 1.8rem;
padding-top: 2%;
}
.topInfoText {
font-size: 2rem;
margin: 0;
}
/* Player Decks */
.player1Deck {
display: flex;
flex-direction: row;
align-items: center;
}
.player1Deck>img {
transition: transform 350ms;
}
.player1Deck>img:hover {
transform: scale(1.08);
opacity: 1;
}
.player2Deck {
display: flex;
flex-direction: row-reverse;
align-items: center;
}
.player2Deck>img {
transition: transform 350ms;
}
.player2Deck>img:hover {
transform: scale(1.08);
opacity: 1;
}
.playerDeckText {
font-size: 2rem;
margin: 0 20px;
}
/* Game.js Middle Row */
.middleInfo {
display: flex;
flex-direction: row;
justify-content: space-around;
}
.middleInfo>button {
align-self: center;
}
/* Game Buttons */
.game-button {
position: relative;
top: 0;
cursor: pointer;
text-decoration: none !important;
outline: none !important;
font-family: 'Carter One', sans-serif;
font-size: 15px;
line-height: 1.5em;
letter-spacing: .1em;
text-shadow: 2px 2px 1px #0066a2, -2px 2px 1px #0066a2, 2px -2px 1px #0066a2, -2px -2px 1px #0066a2, 0px 2px 1px #0066a2, 0px -2px 1px #0066a2, 0px 4px 1px #004a87, 2px 4px 1px #004a87, -2px 4px 1px #004a87;
border: none;
margin: 15px 15px 30px;
background: repeating-linear-gradient( 45deg, #3ebbf7, #3ebbf7 5px, #45b1f4 5px, #45b1f4 10px);
border-bottom: 3px solid rgba(16, 91, 146, 0.5);
border-top: 3px solid rgba(255,255,255,.3);
color: #fff !important;
border-radius: 8px;
padding: 8px 15px 10px;
box-shadow: 0 6px 0 #266b91, 0 8px 1px 1px rgba(0,0,0,.3), 0 10px 0 5px #12517d, 0 12px 0 5px #1a6b9a, 0 15px 0 5px #0c405e, 0 15px 1px 6px rgba(0,0,0,.3);
}
.game-button:hover {
top:2px;
box-shadow: 0 4px 0 #266b91, 0 6px 1px 1px rgba(0,0,0,.3), 0 8px 0 5px #12517d, 0 10px 0 5px #1a6b9a, 0 13px 0 5px #0c405e, 0 13px 1px 6px rgba(0,0,0,.3);
}
.game-button::before {
content: '';
height: 10%;
position: absolute;
width: 40%;
background: #fff;
right: 13%;
top: -3%;
border-radius: 99px;
}
.game-button::after {
content: '';
height: 10%;
position: absolute;
width: 5%;
background: #fff;
right: 5%;
top: -3%;
border-radius: 99px;
}
.game-button.orange {
background: repeating-linear-gradient( 45deg, #ffc800, #ffc800 5px, #ffc200 5px, #ffc200 10px);
box-shadow: 0 6px 0 #b76113, 0 8px 1px 1px rgba(0,0,0,.3), 0 10px 0 5px #75421f, 0 12px 0 5px #8a542b, 0 15px 0 5px #593116, 0 15px 1px 6px rgba(0,0,0,.3);
border-bottom: 3px solid rgba(205, 102, 0, 0.5);
text-shadow: 2px 2px 1px #e78700, -2px 2px 1px #e78700, 2px -2px 1px #e78700, -2px -2px 1px #e78700, 0px 2px 1px #e78700, 0px -2px 1px #e78700, 0px 4px 1px #c96100, 2px 4px 1px #c96100, -2px 4px 1px #c96100;
}
.game-button.orange:hover {
top:2px;
box-shadow: 0 4px 0 #b76113, 0 6px 1px 1px rgba(0,0,0,.3), 0 8px 0 5px #75421f, 0 10px 0 5px #8a542b, 0 13px 0 5px #593116, 0 13px 1px 6px rgba(0,0,0,.3);
}
.game-button.red {
background: repeating-linear-gradient( 45deg, #ff4f4c, #ff4f4c 5px, #ff4643 5px, #ff4643 10px);
box-shadow: 0 6px 0 #ae2725, 0 8px 1px 1px rgba(0,0,0,.3), 0 10px 0 5px #831614, 0 12px 0 5px #a33634, 0 15px 0 5px #631716, 0 15px 1px 6px rgba(0,0,0,.3);
border-bottom: 3px solid rgba(160, 25, 23, 0.5);
text-shadow: 2px 2px 1px #d72d21, -2px 2px 1px #d72d21, 2px -2px 1px #d72d21, -2px -2px 1px #d72d21, 0px 2px 1px #d72d21, 0px -2px 1px #d72d21, 0px 4px 1px #930704, 2px 4px 1px #930704, -2px 4px 1px #930704;
}
.game-button.red:hover {
top:2px;
box-shadow: 0 4px 0 #ae2725, 0 6px 1px 1px rgba(0,0,0,.3), 0 8px 0 5px #831614, 0 10px 0 5px #a33634, 0 13px 0 5px #631716, 0 13px 1px 6px rgba(0,0,0,.3);
}
.game-button.green {
background: repeating-linear-gradient( 45deg, #54d440, #54d440 5px, #52cc3f 5px, #52cc3f 10px);
box-shadow: 0 6px 0 #348628, 0 8px 1px 1px rgba(0,0,0,.3), 0 10px 0 5px #2a6d20, 0 12px 0 5px #39822e, 0 15px 0 5px #1d4c16, 0 15px 1px 6px rgba(0,0,0,.3);
border-bottom: 3px solid rgba(40, 117, 29, 0.5);
text-shadow: 2px 2px 1px #348628, -2px 2px 1px #348628, 2px -2px 1px #348628, -2px -2px 1px #348628, 0px 2px 1px #348628, 0px -2px 1px #348628, 0px 4px 1px #1d4c16, 2px 4px 1px #1d4c16, -2px 4px 1px #1d4c16;
}
.game-button.green:hover {
top:2px;
box-shadow: 0 4px 0 #348628, 0 6px 1px 1px rgba(0,0,0,.3), 0 8px 0 5px #2a6d20, 0 10px 0 5px #39822e, 0 13px 0 5px #1d4c16, 0 13px 1px 6px rgba(0,0,0,.3);
}
/* Spinner */
.loader,
.loader:before,
.loader:after {
border-radius: 50%;
width: 2.5em;
height: 2.5em;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation: load7 1.8s infinite ease-in-out;
animation: load7 1.8s infinite ease-in-out;
}
.loader {
color: #ffffff;
font-size: 5px;
margin: 0 50px 0 50px;
position: relative;
text-indent: -9999em;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
.loader:before,
.loader:after {
content: '';
position: absolute;
top: 0;
}
.loader:before {
left: -3.5em;
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.loader:after {
left: 3.5em;
}
@-webkit-keyframes load7 {
0%,
80%,
100% {
box-shadow: 0 2.5em 0 -1.3em;
}
40% {
box-shadow: 0 2.5em 0 0;
}
}
@keyframes load7 {
0%,
80%,
100% {
box-shadow: 0 2.5em 0 -1.3em;
}
40% {
box-shadow: 0 2.5em 0 0;
}
}
/* Chat Box */
.chat-box{
position: absolute;
bottom: 0px;
background: white;
width: 355px;
border-radius: 5px 5px 0px 0px;
z-index: 100;
}
.chat-box-player1{
right: 20px;
}
.chat-box-player2{
left: 20px;
}
.chat-head{
width: inherit;
height: 45px;
background: #2c3e50;
border-radius: 5px 5px 0px 0px;
}
.chat-head h2{
color: white;
padding-top: 5px;
display: inline-block;
}
.chat-head span{
cursor: pointer;
float: right;
width: 25px;
margin: 10px;
}
.chat-body{
display: none;
height: 205px;
width: inherit;
overflow: hidden auto;
margin-bottom: 45px;
}
.chat-text{
position: fixed;
bottom: 0px;
height: 45px;
width: inherit;
}
.chat-text input{
width: inherit;
height: inherit;
box-sizing: border-box;
border: 1px solid #bdc3c7;
padding: 10px;
resize: none;
outline: none;
}
.chat-text input:active, .chat-text input:focus, .chat-text input:hover{
border-color: royalblue;
}
.msg-send{
background: #406a4b;
}
.msg-receive{
background: #595080;
}
.msg-send, .msg-receive{
width: 285px;
height: 35px;
padding: 5px 5px 5px 10px;
margin: 5px auto;
border-radius: 3px;
line-height: 30px;
position: relative;
color: white;
}
.msg-receive:before{
content: '';
width: 0px;
height: 0px;
position: absolute;
border: 15px solid;
border-color: transparent #595080 transparent transparent;
left: -29px;
top: 7px;
}
.msg-send:after{
content: '';
width: 0px;
height: 0px;
position: absolute;
border: 15px solid;
border-color: transparent transparent transparent #406a4b;
right: -29px;
top: 7px;
}
.msg-receive:hover, .msg-send:hover{
opacity: .9;
}

View File

@@ -0,0 +1,17 @@
import './App.css'
import { Routes, Route } from 'react-router-dom'
import Homepage from './components/Homepage'
import Game from './components/Game'
const App = () => {
return (
<div className="App">
<Routes>
<Route path='/' element={<Homepage />} />
<Route path='/play' element={<Game />} />
</Routes>
</div>
)
}
export default App

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import randomCodeGenerator from '../utils/randomCodeGenerator'
const Homepage = () => {
const [roomCode, setRoomCode] = useState('')
return (
<div className='Homepage'>
<div className='homepage-menu'>
<img src={require('../assets/logo.png').default} width='200px' />
<div className='homepage-form'>
<div className='homepage-join'>
<input type='text' placeholder='Game Code' onChange={(event) => setRoomCode(event.target.value)} />
<Link to={`/play?roomCode=${roomCode}`}><button className="game-button green">JOIN GAME</button></Link>
</div>
<h1>OR</h1>
<div className='homepage-create'>
<Link to={`/play?roomCode=${randomCodeGenerator(5)}`}><button className="game-button orange">CREATE GAME</button></Link>
</div>
</div>
</div>
</div>
)
}
export default Homepage

View File

@@ -0,0 +1,9 @@
import React from 'react'
const Spinner = () => {
return (
<div className="loader">Loading...</div>
)
}
export default Spinner

View File

@@ -0,0 +1,14 @@
@import url('https://fonts.googleapis.com/css?family=Carter+One');
* {
margin: 0;
padding: 0;
}
body {
margin: 0;
font-family: 'Carter One', sans-serif;
color: white;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import { BrowserRouter } from 'react-router-dom'
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
)

View File

@@ -0,0 +1,231 @@
/**
* Bot Action Executor
* Processes bot actions and executes them in the game
*/
import { isCardPlayable } from './cardParser';
/**
* Execute a bot action in the game
* @param {Object} action - The bot action to execute
* @param {Object} gameContext - Current game context
* @returns {Object} Result of the action execution
*/
export const executeBotAction = (action, gameContext) => {
const {
turn,
currentUser,
currentColor,
currentNumber,
player2Deck,
onCardPlayedHandler,
onCardDrawnHandler,
setUnoButtonPressed
} = gameContext
// Validate it's Player 2's turn and the current user is Player 2
if (turn !== 'Player 2') {
console.warn('❌ Bot action rejected: Not Player 2\'s turn')
return {
success: false,
error: 'Not your turn',
message: 'It\'s not Player 2\'s turn'
}
}
if (currentUser !== 'Player 2') {
console.warn('❌ Bot action rejected: Current user is not Player 2')
return {
success: false,
error: 'Not Player 2',
message: 'Current user is not Player 2'
}
}
// Process the action based on type
switch (action.action) {
case 'play':
return executePlayAction(action, gameContext)
case 'draw':
return executeDrawAction(gameContext)
case 'uno':
return executeUnoAction(gameContext)
default:
console.warn('❌ Unknown bot action:', action.action)
return {
success: false,
error: 'Unknown action',
message: `Unknown action type: ${action.action}`
}
}
}
/**
* Execute a play card action
*/
const executePlayAction = (action, gameContext) => {
const { card, color, callUno } = action
const { player2Deck, onCardPlayedHandler, setUnoButtonPressed } = gameContext
// Validate card parameter
if (!card) {
console.warn('❌ Bot play action rejected: No card specified')
return {
success: false,
error: 'No card specified',
message: 'Play action requires a card parameter'
}
}
// Check if card is in Player 2's hand
if (!player2Deck.includes(card)) {
console.warn('❌ Bot play action rejected: Card not in hand:', card)
return {
success: false,
error: 'Card not in hand',
message: `Card ${card} is not in Player 2's hand`
}
}
// Validate wild card has color specified
if ((card === 'W' || card === 'D4W') && !color) {
console.warn('❌ Bot play action rejected: Wild card without color')
return {
success: false,
error: 'No color specified',
message: 'Wild cards require a color parameter (R/G/B/Y)'
}
}
// Handle UNO call if specified
if (callUno) {
console.log('🔥 Bot called UNO!')
setUnoButtonPressed(true)
}
// Store color for wild cards in a way the game can access it
if (color && (card === 'W' || card === 'D4W')) {
// Set a global or context variable for the color choice
// The onCardPlayedHandler will check this
window.botChosenColor = color
console.log(`🌈 Bot chose color: ${color}`)
}
// Execute the play
console.log(`🎴 Bot playing card: ${card}`)
console.log(`🎴 Current game state:`, {
turn: gameContext.turn,
currentUser: gameContext.currentUser,
player2DeckLength: gameContext.player2Deck?.length,
hasCard: gameContext.player2Deck?.includes(card)
})
// Track deck size before play to verify card was actually played
const deckSizeBefore = gameContext.player2Deck.length
onCardPlayedHandler(card)
// Wait a moment for state to update
// Note: This is a hack because onCardPlayedHandler doesn't return success/failure
// In a real implementation, we'd need to refactor the game logic
return new Promise((resolve) => {
setTimeout(() => {
const deckSizeAfter = gameContext.player2Deck.length
const cardWasPlayed = deckSizeAfter < deckSizeBefore
if (cardWasPlayed) {
resolve({
success: true,
message: `Played card ${card}${color ? ` (chose ${color})` : ''}${callUno ? ' and called UNO' : ''}`
})
} else {
console.warn('❌ Card was not actually played (deck size unchanged)')
resolve({
success: false,
error: 'Invalid play',
message: `Card ${card} could not be played (invalid move)`
})
}
}, 100) // Wait 100ms for state update
})
}
/**
* Execute a draw card action
*/
const executeDrawAction = (gameContext) => {
const { onCardDrawnHandler } = gameContext
console.log('📥 Bot drawing a card')
onCardDrawnHandler()
return {
success: true,
message: 'Drew a card'
}
}
/**
* Execute a UNO call action
*/
const executeUnoAction = (gameContext) => {
const { setUnoButtonPressed, player2Deck } = gameContext
if (player2Deck.length !== 2) {
console.warn('❌ Bot UNO rejected: Player 2 doesn\'t have exactly 2 cards')
return {
success: false,
error: 'Invalid UNO call',
message: `Can only call UNO with 2 cards, currently have ${player2Deck.length}`
}
}
console.log('🔥 Bot called UNO!')
setUnoButtonPressed(true)
return {
success: true,
message: 'Called UNO'
}
}
/**
* Validate a bot action before execution
*/
export const validateBotAction = (action, gameState) => {
if (!action || typeof action !== 'object') {
return { valid: false, error: 'Invalid action format' }
}
if (!action.action) {
return { valid: false, error: 'Missing action type' }
}
// Validate specific action types
switch (action.action) {
case 'play':
if (!action.card) {
return { valid: false, error: 'Play action requires card parameter' }
}
if ((action.card === 'W' || action.card === 'D4W') && !action.color) {
return { valid: false, error: 'Wild cards require color parameter' }
}
if (action.color && !['R', 'G', 'B', 'Y'].includes(action.color)) {
return { valid: false, error: 'Invalid color. Must be R, G, B, or Y' }
}
break
case 'draw':
case 'uno':
// No additional validation needed
break
default:
return { valid: false, error: `Unknown action type: ${action.action}` }
}
return { valid: true }
}

View File

@@ -0,0 +1,26 @@
// Build a map of all card front images using webpack's require.context
import CARD_BACK from '../assets/card-back.png'
// require all png files in cards-front directory
const req = require.context('../assets/cards-front', false, /\.png$/)
const cardMap = {}
req.keys().forEach((key) => {
// key is like './5R.png' -> strip './' and '.png'
const code = key.replace('./', '').replace('.png', '')
try {
const resolved = req(key)
cardMap[code] = resolved && resolved.default ? resolved.default : resolved
} catch (e) {
cardMap[code] = CARD_BACK
}
})
export const getCardImage = (code) => {
if (!code) return CARD_BACK
return cardMap[code] || CARD_BACK
}
export default {
getCardImage,
}

View File

@@ -0,0 +1,150 @@
/**
* Utility functions for parsing UNO card codes into readable JSON objects
* Card codes format: '5R', 'D2G', 'skipB', 'W', 'D4W', '_Y' (reverse)
*/
/**
* Parse a card code into a detailed card object
* @param {string} cardCode - The card code (e.g., '5R', 'D2G', 'W')
* @returns {object} Card object with type, value, color, and display name
*/
export const parseCard = (cardCode) => {
if (!cardCode) return null;
const card = {
code: cardCode,
type: 'unknown',
value: null,
color: null,
colorName: null,
displayName: ''
};
// Extract color (last character for most cards)
const lastChar = cardCode.charAt(cardCode.length - 1).toUpperCase();
const colorMap = {
'R': 'red',
'G': 'green',
'B': 'blue',
'Y': 'yellow'
};
// Wild cards (no color)
if (cardCode === 'W') {
card.type = 'wild';
card.value = 300;
card.displayName = 'Wild';
return card;
}
if (cardCode === 'D4W') {
card.type = 'draw4_wild';
card.value = 600;
card.displayName = 'Draw 4 Wild';
return card;
}
// Cards with color
card.color = lastChar;
card.colorName = colorMap[lastChar] || 'unknown';
// Number cards (0-9)
const firstChar = cardCode.charAt(0);
if (firstChar >= '0' && firstChar <= '9') {
card.type = 'number';
card.value = parseInt(firstChar, 10);
card.displayName = `${firstChar} ${card.colorName}`;
return card;
}
// Skip cards
if (cardCode.startsWith('skip')) {
card.type = 'skip';
card.value = 404;
card.displayName = `Skip ${card.colorName}`;
return card;
}
// Draw 2 cards
if (cardCode.startsWith('D2')) {
card.type = 'draw2';
card.value = 252;
card.displayName = `Draw 2 ${card.colorName}`;
return card;
}
// Reverse cards
if (cardCode === '_' + lastChar) {
card.type = 'reverse';
card.value = 0;
card.displayName = `Reverse ${card.colorName}`;
return card;
}
return card;
};
/**
* Check if a card can be played on the current card
* @param {string} cardCode - The card to check
* @param {string} currentColor - Current color in play
* @param {string|number} currentNumber - Current number/value in play
* @returns {boolean} Whether the card can be played
*/
export const isCardPlayable = (cardCode, currentColor, currentNumber) => {
const card = parseCard(cardCode);
// Wild cards can always be played
if (card.type === 'wild' || card.type === 'draw4_wild') {
return true;
}
// Normalize currentColor: accept 'R' or 'red' (case-insensitive)
let normColor = null;
if (typeof currentColor === 'string') {
const c = currentColor.trim().toUpperCase();
const nameToLetter = { RED: 'R', GREEN: 'G', BLUE: 'B', YELLOW: 'Y' };
normColor = nameToLetter[c] || c.charAt(0);
}
// Check color match
if (card.color && normColor && card.color === normColor) {
return true;
}
// Check number/value match (coerce currentNumber to number)
const normNumber = (currentNumber === null || currentNumber === undefined) ? null : Number(currentNumber);
if (card.value !== null && normNumber !== null && card.value === normNumber) {
return true;
}
// Special case: reverse cards
if (card.type === 'reverse' && normNumber === 0) {
return true;
}
return false;
};
/**
* Parse multiple cards into detailed objects
* @param {string[]} cardCodes - Array of card codes
* @returns {object[]} Array of parsed card objects
*/
export const parseCards = (cardCodes) => {
return cardCodes.map(parseCard);
};
/**
* Get playable cards from a hand
* @param {string[]} hand - Player's card codes
* @param {string} currentColor - Current color in play
* @param {string|number} currentNumber - Current number/value in play
* @returns {object[]} Array of playable cards with their details
*/
export const getPlayableCards = (hand, currentColor, currentNumber) => {
return hand
.filter(cardCode => isCardPlayable(cardCode, currentColor, currentNumber))
.map(cardCode => ({
...parseCard(cardCode),
isPlayable: true
}));
};

View File

@@ -0,0 +1,167 @@
/**
* Utility for building comprehensive game state JSON for bot integration
*/
import { parseCard, parseCards, getPlayableCards, isCardPlayable } from './cardParser';
/**
* Build a complete game state object for external consumption (e.g., bot/AI)
* @param {object} gameState - Current game state from React component
* @param {string} currentUser - The current user's player name (Player 1 or Player 2)
* @returns {object} Comprehensive game state in JSON format
*/
export const buildGameStateJSON = (gameState, currentUser) => {
const {
gameOver,
winner,
turn,
player1Deck,
player2Deck,
currentColor,
currentNumber,
playedCardsPile,
drawCardPile
} = gameState;
// Get last 5 played cards (or all if less than 5)
const recentCards = playedCardsPile.slice(-5);
const currentCard = playedCardsPile[playedCardsPile.length - 1];
// Determine which player is the bot (Player 2)
const botDeck = player2Deck;
const opponentDeck = player1Deck;
const isBotTurn = turn === 'Player 2';
// Parse bot's cards with playability info
// Parse bot's cards and mark playability based on game rules (independent of whose turn)
const botParsedCards = botDeck.map(cardCode => {
const card = parseCard(cardCode);
const playable = isCardPlayable(cardCode, currentColor, currentNumber);
return {
...card,
isPlayable: playable
};
});
// Build the comprehensive state object
return {
// Game meta info
game: {
isOver: gameOver,
winner: winner || null,
currentTurn: turn,
turnNumber: playedCardsPile.length, // Approximate turn count
},
// Current card on the pile
currentCard: {
code: currentCard,
...parseCard(currentCard),
currentColor: currentColor,
currentNumber: currentNumber
},
// Recently played cards (last 5)
recentlyPlayed: recentCards.map((cardCode, index) => ({
code: cardCode,
...parseCard(cardCode),
position: recentCards.length - index // 1 = most recent
})),
// Player 1 info (opponent to bot)
player1: {
name: 'Player 1',
cardCount: player1Deck.length,
isCurrentTurn: turn === 'Player 1',
cards: [] // Hidden from bot
},
// Player 2 info (bot)
player2: {
name: 'Player 2',
cardCount: player2Deck.length,
isCurrentTurn: turn === 'Player 2',
cards: botParsedCards, // Visible to bot
playableCards: botParsedCards.filter(c => c.isPlayable)
},
// Deck info
deck: {
drawPileCount: drawCardPile.length,
playedPileCount: playedCardsPile.length
},
// Bot decision context
botContext: {
canPlay: isBotTurn && botParsedCards.some(c => c.isPlayable),
mustDraw: isBotTurn && !botParsedCards.some(c => c.isPlayable),
hasUno: player2Deck.length === 2, // Should press UNO button next turn
isWinning: player2Deck.length === 1,
actions: isBotTurn ? getAvailableActions(botParsedCards, currentColor, currentNumber) : []
}
};
};
/**
* Get available actions for the bot
* @param {object[]} parsedCards - Bot's parsed cards
* @param {string} currentColor - Current color
* @param {number|string} currentNumber - Current number
* @returns {object[]} Array of available action objects
*/
const getAvailableActions = (parsedCards, currentColor, currentNumber) => {
const actions = [];
// Check each card for playability
parsedCards.forEach(card => {
if (card.isPlayable) {
actions.push({
action: 'play_card',
card: {
code: card.code,
type: card.type,
value: card.value,
color: card.color,
displayName: card.displayName
},
// For wild cards, need to choose a color
requiresColorChoice: card.type === 'wild' || card.type === 'draw4_wild'
});
}
});
// If no playable cards, must draw
if (actions.length === 0) {
actions.push({
action: 'draw_card',
card: null
});
}
return actions;
};
/**
* Format game state for console logging
* @param {object} gameStateJSON - The game state object
* @returns {string} Formatted JSON string
*/
export const formatGameStateForLog = (gameStateJSON) => {
return JSON.stringify(gameStateJSON, null, 2);
};
/**
* Create a simplified game state for quick display
* @param {object} gameStateJSON - The full game state object
* @returns {object} Simplified state
*/
export const simplifyGameState = (gameStateJSON) => {
return {
turn: gameStateJSON.game.currentTurn,
currentCard: `${gameStateJSON.currentCard.displayName} (${gameStateJSON.currentCard.code})`,
player1Cards: gameStateJSON.player1.cardCount,
player2Cards: gameStateJSON.player2.cardCount,
botCanPlay: gameStateJSON.botContext.canPlay,
playableCards: gameStateJSON.player2.playableCards.length
};
};

View File

@@ -0,0 +1,8 @@
//pack of 108 cards (_ = reverse)
export default [
'0R', '1R', '1R', '2R', '2R', '3R', '3R', '4R', '4R', '5R', '5R', '6R', '6R', '7R', '7R', '8R', '8R', '9R', '9R', 'skipR', 'skipR', '_R', '_R', 'D2R', 'D2R',
'0G', '1G', '1G', '2G', '2G', '3G', '3G', '4G', '4G', '5G', '5G', '6G', '6G', '7G', '7G', '8G', '8G', '9G', '9G', 'skipG', 'skipG', '_G', '_G', 'D2G', 'D2G',
'0B', '1B', '1B', '2B', '2B', '3B', '3B', '4B', '4B', '5B', '5B', '6B', '6B', '7B', '7B', '8B', '8B', '9B', '9B', 'skipB', 'skipB', '_B', '_B', 'D2B', 'D2B',
'0Y', '1Y', '1Y', '2Y', '2Y', '3Y', '3Y', '4Y', '4Y', '5Y', '5Y', '6Y', '6Y', '7Y', '7Y', '8Y', '8Y', '9Y', '9Y', 'skipY', 'skipY', '_Y', '_Y', 'D2Y', 'D2Y',
'W', 'W', 'W', 'W', 'D4W', 'D4W', 'D4W', 'D4W'
]

View File

@@ -0,0 +1,9 @@
export default function makeid(length) {
var result = '';
var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var charactersLength = characters.length;
for ( var i = 0; i < length; i++ ) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}

View File

@@ -0,0 +1,9 @@
export default function shuffleArray(array) {
for (var i = array.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1))
var temp = array[i]
array[i] = array[j]
array[j] = temp;
}
return array
}