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` +
+
+

Cell ${index + 1}${currentAction ? ` - ${currentAction}` : ''}

+ + + + + +
+ + + + + + ${cell.preview && + html` +
+

Preview

+
${JSON.stringify(cell.preview, null, 2)}
+
+ `} + ${cell.result && + !cell.result.error && + html` +
+

Success

+
${JSON.stringify(cell.result, null, 2)}
+
+ `} + ${cell.result && + cell.result.error && + html` +
+

Error

+
${JSON.stringify(cell.result, null, 2)}
+
+ `} +
+ ` + } + + renderGameState() { + const {game} = this.state + if (!game) return null + + return html` +
+

Game State

+ +
+

Player

+
Health: ${game.state.player.currentHealth}/${game.state.player.maxHealth}
+
Energy: ${game.state.player.currentEnergy}/${game.state.player.maxEnergy}
+
Block: ${game.state.player.block}
+ ${Object.keys(game.state.player.powers || {}).length > 0 && + html`
Powers: ${JSON.stringify(game.state.player.powers)}
`} +
+ +
+

Cards

+
Hand: ${game.state.hand.length} cards
+
Draw pile: ${game.state.drawPile.length} cards
+
Discard pile: ${game.state.discardPile.length} cards
+
+ +
+

Queue Information

+
+
Future actions: ${game.future.list.length}
+
+${JSON.stringify(
+								game.future.list.map((item) => ({
+									type: item?.action?.type || 'unknown',
+								})),
+								null,
+								2,
+							)}
+ +
Past actions: ${game.past.list.length}
+
+${JSON.stringify(
+								game.past.list.map((item) => ({
+									type: item?.action?.type || 'unknown',
+								})),
+								null,
+								2,
+							)}
+
+
+ +
+ +

Full Game State

+
+
${JSON.stringify(game.state, null, 2)}
+
+
+ ` + } + + render(props, state) { + const {game, cells} = state + if (!game) return html`
Loading game...
` + + console.log({game, cells}) + + return html` +
+
+

+ 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. +

+ + + + +
+ +
+
+ ${cells?.map((cell, i) => this.renderCell(cell, i))} +
+ +
+
+ +
+
+ + ` + } +} diff --git a/src/ui/pages/all-cards.astro b/src/ui/pages/all-cards.astro new file mode 100644 index 00000000..d1b33843 --- /dev/null +++ b/src/ui/pages/all-cards.astro @@ -0,0 +1,31 @@ +--- +import Layout from '../layouts/Layout.astro' +import '../styles/index.css' + +import {cards} from '../../content/cards.js' +import {Card} from '../components/cards.js' +import {createCard} from '../../game/cards' +--- + + +
+

← Highscores

+ +
+

Slay the Web

+

{cards.length} Card Collection

+
+ + +
+
+ {cards.map((card) => ( +
+ {card.name}
+ {createCard(card.name, true).name} +
+ ))} +
+
+
+
diff --git a/src/ui/pages/system.astro b/src/ui/pages/system.astro new file mode 100644 index 00000000..b2a1b5b0 --- /dev/null +++ b/src/ui/pages/system.astro @@ -0,0 +1,10 @@ +--- +import Layout from '../layouts/Layout.astro' +import DebugUI from '../components/debug-ui.js' +--- + + + + + +