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

20304
uno-online/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
{
"name": "uno-online",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.7.0",
"query-string": "^6.14.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.2",
"socket.io-client": "^3.1.1",
"use-sound": "^2.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Two player online game of UNO."
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>UNO Online</title>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script type="text/javascript">
window.onbeforeunload = function() {
return true
}
</script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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
}