import { h, render, useState, useEffect, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
import {
INITIAL_STATE, THEMES,
createDeck, shuffleDeck, saveHistory, saveState, loadState
} from './game.js';
import { Card, WeaponSlot, GameOver } from './components.js';
const App = () => {
const [state, setState] = useState(() => {
const loaded = loadState();
if (loaded) return loaded;
// Fallback: check legacy theme key or default
const saved_theme = localStorage.getItem('scoundrel_theme');
return {
...INITIAL_STATE,
theme: (saved_theme && THEMES[saved_theme]) ? saved_theme : 'Text'
};
});
// Save state on every change
useEffect(() => {
saveState(state);
}, [state]);
// Only start a new game on mount if we didn't load a valid active game
useEffect(() => {
const isGameActive = state.deck.length > 0 || state.room.some(c => c !== null) || state.game_over;
if (!isGameActive) {
startGame();
}
}, []);
const startGame = () => {
const full_deck = shuffleDeck(createDeck());
const room = full_deck.splice(0, 4);
// Preserve current theme when resetting
setState(prev => ({
...INITIAL_STATE,
theme: prev.theme,
deck: full_deck,
room,
msg: "A new dungeon awaits."
}));
};
const undoLastMove = () => {
if (state.history.length === 0) return;
const previousState = state.history[state.history.length - 1];
const remainingHistory = state.history.slice(0, -1);
// Fix JSON.stringify(Infinity) -> null issue
if (previousState.last_enemy_value === null) {
previousState.last_enemy_value = Infinity;
}
setState({
...previousState,
history: remainingHistory,
msg: "Undid last move."
});
};
const resolveCard = (card, use_weapon = true) => {
let new_state = {
...state,
history: saveHistory(state)
};
let card_resolved = false;
if (card.type === 'MONSTER') {
let damage_taken = 0;
if (use_weapon && new_state.weapon && card.value < new_state.last_enemy_value) {
damage_taken = Math.max(0, card.value - new_state.weapon.value);
new_state.last_enemy_value = card.value;
new_state.msg = `Slayed ${card.rank}${card.suit} with weapon. Took ${damage_taken} damage.`;
new_state.defeated_monsters = [...new_state.defeated_monsters, card];
} else {
damage_taken = card.value;
new_state.msg = `Fought ${card.rank}${card.suit} bare-handed. Took ${damage_taken} damage.`;
new_state.discard_pile = [...new_state.discard_pile, card];
}
new_state.hp -= damage_taken;
card_resolved = true;
} else if (card.type === 'WEAPON') {
if (new_state.weapon) {
new_state.discard_pile = [
...new_state.discard_pile,
new_state.weapon,
...new_state.defeated_monsters
];
}
new_state.defeated_monsters = [];
new_state.weapon = card;
new_state.last_enemy_value = Infinity;
new_state.msg = `Equipped ${card.rank}${card.suit}.`;
card_resolved = true;
} else if (card.type === 'POTION') {
if (!new_state.potion_used_this_turn) {
const old_hp = new_state.hp;
new_state.hp = Math.min(new_state.max_hp, new_state.hp + card.value);
new_state.potion_used_this_turn = true;
new_state.msg = `Healed ${new_state.hp - old_hp} HP.`;
} else {
new_state.msg = "Potion wasted!";
}
new_state.discard_pile = [...new_state.discard_pile, card];
card_resolved = true;
}
if (card_resolved) {
new_state.room = new_state.room.map(c => c && c.id === card.id ? null : c);
new_state.cards_played_this_turn += 1;
if (new_state.hp <= 0) { new_state.game_over = true; new_state.won = false; }
else if (new_state.room.filter(c => c !== null).length === 1 && new_state.deck.length > 0) {
const remaining_card = new_state.room.find(c => c !== null);
const next_cards = new_state.deck.splice(0, 3);
new_state.room = [remaining_card, ...next_cards];
new_state.cards_played_this_turn = 0;
new_state.potion_used_this_turn = false;
new_state.fled_last_turn = false;
} else if (new_state.room.every(c => c === null) && new_state.deck.length === 0) {
new_state.game_over = true; new_state.won = true;
}
setState(new_state);
}
};
const handleCardClick = (card) => {
if (state.game_over) return;
if (card.type === 'MONSTER') {
// Monsters are handled via overlay buttons
return;
} else {
resolveCard(card);
}
};
const fleeRoom = () => {
const active_cards = state.room.filter(c => c !== null);
const new_deck = [...state.deck, ...active_cards];
const new_room = new_deck.splice(0, 4);
setState({
...state,
deck: new_deck,
room: new_room,
fled_last_turn: true,
cards_played_this_turn: 0,
potion_used_this_turn: false,
msg: "Fled the room.",
history: saveHistory(state)
});
};
const can_flee_check = !state.fled_last_turn && state.cards_played_this_turn === 0 && state.deck.length > 0;
return html`
<header>
<h1>SCOUNDREL</h1>
<div class="stats-bar">
<span class="hp-bar">HP: ${state.hp} / ${state.max_hp}</span>
<span class="deck-count">Deck: ${state.deck.length}</span>
<select class="theme-selector" value=${state.theme} onChange=${(e) => setState({...state, theme: e.target.value})}>
${Object.entries(THEMES).map(([key, theme]) => html`<option value=${key}>${theme.label}</option>`)}
</select>
</div>
</header>
<div class="game-board">
<div class="weapon-area">
<div style="display: flex; gap: 20px; align-items: center;">
<${WeaponSlot} weapon=${state.weapon} last_enemy_value=${state.last_enemy_value} themeKey=${state.theme} />
</div>
<div class="defeated-container">
<strong>Defeated:</strong>
<div class="defeated-pile">
${state.defeated_monsters.map(m => html`<div class="mini-card ${m.color}" title="${m.rank}${m.suit}">${m.rank}${m.suit}</div>`)}
</div>
</div>
</div>
<div style="text-align: center; height: 1.2em; font-style: italic;">${state.msg}</div>
<div class="room-container">
<div class="room-cards">
${state.room.map((card, idx) => html`<${Card}
key=${card ? card.id : 'empty-'+idx}
card=${card}
onClick=${handleCardClick}
onAttack=${resolveCard}
can_use_weapon=${card && state.weapon && card.value < state.last_enemy_value}
themeKey=${state.theme}
/>`)}
</div>
</div>
<div class="actions">
<button onClick=${undoLastMove} disabled=${state.history.length === 0}>Undo</button>
<button onClick=${() => { if (confirm("Start a new game? Current progress will be lost.")) startGame(); }}>New Game</button>
<button onClick=${fleeRoom} disabled=${!can_flee_check}>Run Away</button>
</div>
</div>
<div class="history-area">
<div class="discard-container">
<strong>Discard Pile (Newest First):</strong>
<div class="discard-pile">
${state.discard_pile.slice().reverse().map(c => html`<div class="mini-card ${c.color}" title="${c.rank}${c.suit}">${c.rank}${c.suit}</div>`)}
</div>
</div>
<div class="rules-container">
<p><strong>Rules:</strong> Standard 52-card deck (no red face cards/aces). Play 3 of 4 cards to advance. Hearts heal (1/turn). Diamonds are weapons. Weapons durability: next monster must be weaker than last. <a href="https://rpdillon.net/scoundrel.html" target="_blank">Full Rules</a></p>
</div>
</div>
${state.game_over && html`<${GameOver} won=${state.won} hp=${state.hp} reset_game=${startGame} />`}
`;
};
render(html`<${App} />`, document.getElementById('app'));