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,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
}