diff --git a/.cursor/index b/.cursor/index new file mode 100644 index 00000000..4fb50b3d --- /dev/null +++ b/.cursor/index @@ -0,0 +1,6 @@ +Key Directories: + +src/game/: Contains the core game logic +src/ui/: Contains the web interface implementation +src/content/: Holds game content like cards, monsters, etc. +tests/: Contains the test suite \ No newline at end of file diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 455817d4..e62d4a84 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -8,8 +8,8 @@ In the root of this project you'll find configuration files as well as three fol - [game](src/game) contains the core game logic - [content](src/content) uses methods from the game engine to build cards, dungeon and monsters - [ui](src/ui) is the example web interface to actually play the game -- [public →](public/) Copied to the web root as-is -- [tests →](tests/) Contains all tests for the game engine. Nothing for the UI. Run `npm test`. +- [public →](public/) Copied to the web root as-is, used for static images +- [tests →](tests/) Contains tests for the game. There are no tests for the UI. ## Src diff --git a/EXPERIMENT.md b/EXPERIMENT.md new file mode 100644 index 00000000..0c46217f --- /dev/null +++ b/EXPERIMENT.md @@ -0,0 +1,238 @@ +# Slay the Web API Design + +## Core Principles + +1. **State-Based Architecture** +- Every action operates on the full game state +- Actions are pure functions: `(state, props) => newState` +- This gives us great flexibility and predictability. Makes debugging, testing, and undo/redo trivial + +```js +// Current implementation +function playCard(state, {card, target}) { + // Operates on entire state + // Returns new state + return newState +} + +// Used like +game.enqueue({type: 'playCard', card: strike}) +game.dequeue() +``` + +2. **Turn-Based Nature** +- Actions happen sequentially +- Clear state transitions +- Perfect for command pattern +- No need for complex event systems + +## Queue & Action Manager System + +A key part of the architecture is how actions are queued and processed: + +```js +// The Queue system maintains order and history +const future = new Queue() // actions to be executed +const past = new Queue() // actions that were executed + +// Action Manager handles the flow +actionManager.enqueue({type: 'playCard', card}) // queue up an action +actionManager.dequeue(state) // execute next action +actionManager.undo() // revert to previous state + +// This enables features like: +// - Undo/redo +// - Action history +// - Save states +// - Debugging/replay +``` + +Any wrapper API needs to respect this system: +1. Actions must be properly queued +2. State changes happen through dequeue +3. Past actions are tracked for undo +4. The sequence of actions matters + +For example, a fluent API would need to: +```js +// Friendly API +game.play(strike).on('enemy0') + +// Under the hood +game.enqueue({type: 'playCard', card: strike, target: 'enemy0'}) +game.dequeue() +``` + +## Design Goals + +1. **Readable & Intuitive**: Actions should read like English +2. **Predictable**: Clear what each action does +3. **Flexible**: Full state access means powerful combinations +4. **Debuggable**: Easy to inspect state changes +5. **Testable**: Pure functions are easy to test + +## API Style Explorations + +All these styles wrap the same core state-based system, just with different ergonomics: + +### Current Style (Raw Actions) +```js +// Verbose but explicit +game.enqueue({type: 'playCard', card: strike}) +game.dequeue() + +// Multiple actions +game.enqueue({type: 'drawCards', amount: 5}) +game.enqueue({type: 'playCard', card: strike, target: 'enemy0'}) +game.enqueue({type: 'endTurn'}) +game.dequeue() +``` + +The current style has some strong advantages: +- Extremely explicit about what's happening +- Makes action queuing visible and debuggable +- Perfect foundation for building friendlier wrappers +- Easy to serialize/deserialize for save states +- Clear mapping between action name and implementation + +### Style A: Fluent/Chainable +```js +// Single actions +game.play(strike).on('enemy0') +game.draw(5) + +// Action sequences +game + .draw(5) + .play(strike).on('enemy0') + .play(defend).on('player') + .endTurn() + +// With conditions +game.play(strike) + .when(state => state.player.energy >= 2) + .on('enemy0') +``` + +### Style B: Command Strings +```js +// Great for console/debugging +game.do('/play strike enemy0') +game.do('/draw 5') + +// Multiple commands +game.do(` + draw 5 + play strike enemy0 + end-turn +`) + +// With targeting +game.do('/play bash enemy0 enemy1') +``` + +### Style C: Builder Pattern +```js +// Explicit step-by-step building +game.command + .play(strike) + .target('enemy0') + .run() + +// With conditions/effects +game.command + .play(strike) + .target('enemy0') + .ifEnoughEnergy() + .thenDraw(2) + .run() +``` + +### Style D: Domain-Specific +```js +// Organized by game concepts +game.combat.play(strike).on('enemy0') +game.deck.draw(5) +game.deck.shuffle() +game.dungeon.move(2, 3) + +// Could support both styles +game.play(strike) // common actions at top +game.combat.applyBleed(3) // specific ones in domains +``` + +### Style E: Event-Like +```js +// Reminiscent of game engines +game.on.turnStart(() => { + game.draw(5) + game.gainEnergy(3) +}) + +game.on.cardPlayed(strike, () => { + game.draw(1) +}) +``` + +## Common Patterns We Need to Support + +```js +// Combat +game.play(strike).on('enemy0') +game.defend() +game.endTurn() + +// Deck Management +game.draw(5) +game.shuffle() +game.discard(strike) + +// Map Navigation +game.move(2, 3) +game.enterRoom('monster') +game.rest() // at campfire + +// Powers/Effects +game.applyPower('weak', 2).to('enemy0') +game.gainBlock(5) +game.heal(10) +``` + +## Implementation Considerations + +1. **State Management** +```js +// All styles ultimately map to state transforms +type Action = (state: GameState, props?: any) => GameState + +// Friendly APIs wrap this core +game.play(card) // -> runAction(state => playCard(state, {card})) +``` + +2. **Action Composition** +```js +// Actions can be composed +const playAndEnd = (state, {card}) => { + state = playCard(state, {card}) + return endTurn(state) +} +``` + +3. **Type Safety** +```js +interface PlayCardProps { + card: Card + target?: string +} + +function playCard(state: GameState, props: PlayCardProps): GameState +``` + +## Next Steps? + +1. Pick a primary API style (while keeping core state-based architecture) +2. Define common action patterns +3. Create type definitions +4. Build documentation that emphasizes discoverability + +The key is keeping the powerful state-based core while making it delightful to use. diff --git a/src/ui/components/debug-ui.js b/src/ui/components/debug-ui.js new file mode 100644 index 00000000..9ddce8c8 --- /dev/null +++ b/src/ui/components/debug-ui.js @@ -0,0 +1,554 @@ +import {html, Component} from '../lib.js' +import createNewGame from '../../game/new-game.js' +import actions from '../../game/actions.js' +import {createCard} from '../../game/cards.js' + +/** + +# Slay the Web - API Exploration (DEBUG UI) + +This document outlines the experimental debug UI for exploring and improving the Slay the Web game API. + +## Purpose + +The debug UI is designed to: + +1. Explore the game API in an interactive way +2. Test different action sequences and observe their effects +3. Identify areas for API improvement +4. Experiment with alternative API designs + +## Debug UI + +The debug UI is inspired by Jupyter Notebooks, providing a cell-based interface for running game actions and observing their effects: + +### Features + +- **Cell-based execution**: Write and run code in individual cells +- **Preview before execution**: See what an action would do without actually doing it +- **State visualization**: Observe the game state after each action +- **Queue inspection**: View the future and past action queues +- **Action discovery**: Browse available actions + +### Usage + +1. **Create a new game**: Start with a fresh game state +2. **Add cells**: Each cell can contain code to enqueue and execute an action +3. **Run cells**: Execute the code in a cell to see its effect on the game state +4. **Undo actions**: Revert the last action to try different approaches + +### Example Cell Code + +```javascript +// Draw cards +game.enqueue({type: "drawCards", amount: 1}) + +// Play a card +game.enqueue({type: "playCard", card: game.state.hand[0], target: "enemy0"}) + +// End turn +game.enqueue({type: "endTurn"}) +``` + +## API Exploration Goals + +1. **Ergonomics**: Identify ways to make the API more intuitive and easier to use +2. **Consistency**: Ensure actions follow consistent patterns and naming conventions +3. **Flexibility**: Test the API's ability to handle complex game scenarios +4. **Discoverability**: Make it easier to discover available actions and their parameters + +## Current API Structure + +The game uses an action queue system: + +1. Actions are enqueued with `game.enqueue({type: "actionType", ...params})` +2. Actions are executed with `game.dequeue()` +3. Actions can be undone with `game.undo()` + +## Next Steps + +- [ ] Identify common action patterns that could be simplified +- [ ] Consider a more fluent API for common actions +- [ ] Explore better ways to handle targeting +- [ ] Test error handling and edge cases +- [ ] Document all available actions and their parameters + +## References + +- `src/game/actions.js`: Contains all available actions +- `src/game/action-manager.js`: Manages the action queue +- `src/game/new-game.js`: Creates a new game state +- `src/ui/components/debug-ui.js`: The Jupyter-inspired debug UI + + */ + +export default class DebugUI extends Component { + componentDidMount() { + this.reset() + } + + reset() { + const game = createNewGame(true) + this.setState({ + game, + cells: [{code: 'game.enqueue({type: "drawCards", amount: 1})', result: null, preview: null}], + }) + // Expose game to console for direct manipulation + // @ts-ignore - Declare game property on window + window.game = game + console.log('New game created', game) + } + + updateCell(index, code) { + const cells = [...this.state.cells] + cells[index] = {...cells[index], code} + this.setState({cells}) + } + + previewCell(index) { + const {game, cells} = this.state + if (!game) return + + try { + // Clone the game state to preview without modifying + const stateBefore = JSON.parse(JSON.stringify(game.state)) + + // Evaluate the code (in a safe way) + // Note: In a real implementation, you'd need a safer way to evaluate code + const code = cells[index].code + + // Show what would happen + const preview = { + code, + message: 'Preview only - run to see actual changes', + } + + // Update the cell + const updatedCells = [...cells] + updatedCells[index] = {...updatedCells[index], preview, result: null} + this.setState({cells: updatedCells}) + } catch (error) { + const updatedCells = [...cells] + updatedCells[index] = { + ...updatedCells[index], + preview: {error: error.message}, + result: null, + } + this.setState({cells: updatedCells}) + } + } + + runCell(index) { + const {game, cells} = this.state + if (!game) return + + try { + // Store state before + const stateBefore = JSON.parse(JSON.stringify(game.state)) + + // Evaluate and run the code + eval(cells[index].code) + game.dequeue() + + // Compare states to show what changed + const stateAfter = game.state + + // Store result with more detailed information + const result = { + message: 'Action executed successfully', + queue: { + future: game.future.list.length, + past: game.past.list.length, + }, + lastAction: + game.past.list.length > 0 + ? game.past.list[game.past.list.length - 1]?.action?.type || 'unknown' + : 'none', + } + + // Update the cell + const updatedCells = [...cells] + updatedCells[index] = {...updatedCells[index], result, preview: null} + + // Add a new cell if this is the last one + if (index === cells.length - 1) { + updatedCells.push({code: '', result: null, preview: null}) + } + + this.setState({cells: updatedCells}) + } catch (error) { + const updatedCells = [...cells] + updatedCells[index] = { + ...updatedCells[index], + result: {error: error.message}, + preview: null, + } + this.setState({cells: updatedCells}) + } + } + + addCell() { + const cells = [...this.state.cells, {code: '', result: null, preview: null}] + this.setState({cells}) + } + + removeCell(index) { + if (this.state.cells.length <= 1) return + + const cells = [...this.state.cells] + cells.splice(index, 1) + this.setState({cells}) + } + + handleActionSelect(index, actionType) { + if (!actionType) return + + const cells = [...this.state.cells] + let code = `// ${actionType} action\ngame.enqueue({type: "${actionType}"` + + // Add common parameters for specific action types with helpful comments + switch (actionType) { + case 'drawCards': + code += `, amount: 1 // Number of cards to draw` + break + + case 'addHealth': + code += `, + target: "player", // Can be "player" or "enemy0", "enemy1", etc. + amount: 5 // Amount of health to add` + break + + case 'removeHealth': + code += `, + target: "player", // Can be "player" or "enemy0", "enemy1", etc. + amount: 5 // Amount of health to remove` + break + + case 'playCard': + code += `, + card: game.state.hand[0], // Card object to play + target: "enemy0" // Target of the card (if needed)` + break + + case 'addCardToHand': + code += `, + card: createCard("Strike") // Card to add to hand` + break + + case 'addCardToDeck': + code += `, + card: createCard("Strike") // Card to add to deck` + break + + case 'setPower': + code += `, + target: "player", // Can be "player" or "enemy0", "enemy1", etc. + power: "strength", // Power name (strength, dexterity, vulnerable, weak, etc.) + amount: 1 // Amount of power to apply` + break + + case 'endTurn': + // No parameters needed + break + + case 'move': + code += `, + move: {x: 1, y: 0} // Direction to move in the dungeon` + break + + case 'upgradeCard': + code += `, + card: game.state.hand[0] // Card to upgrade` + break + + default: + code += ` /* Add parameters as needed */` + break + } + + code += `})` + + cells[index] = { + ...cells[index], + code, + } + this.setState({cells}) + } + + renderCell(cell, index) { + const {game} = this.state + if (!game) return null + + // Get actions directly from the game object + const availableActions = Object.keys(actions) + + // Extract the current action type from the cell code + let currentAction = '' + const match = cell.code.match(/type:\s*"([^"]+)"/) + if (match && match[1]) { + currentAction = match[1] + } + + // Determine if the cell has been run successfully + const hasRun = cell.result && !cell.result.error + + // Handle keyboard shortcuts + const handleKeyDown = (e) => { + // Run cell on Ctrl+Enter or Cmd+Enter + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault() + this.runCell(index) + } + } + + return html` +
${JSON.stringify(cell.preview, null, 2)}+
${JSON.stringify(cell.result, null, 2)}+
${JSON.stringify(cell.result, null, 2)}+
+${JSON.stringify( + game.future.list.map((item) => ({ + type: item?.action?.type || 'unknown', + })), + null, + 2, + )}+ +
+${JSON.stringify( + game.past.list.map((item) => ({ + type: item?.action?.type || 'unknown', + })), + null, + 2, + )}+
${JSON.stringify(game.state, null, 2)}+
+ Explore the game API by writing and running code in cells below. Each cell can enqueue an action + and then dequeue it to see the results. Select an action from the dropdown in each cell to + generate code with common parameters. Press Ctrl+Enter to run a cell. +
+ +