Compare commits

..

No commits in common. "17133787c1958216145c817fc2840f5e63fe6e61" and "33bbeff79b5dfcd53651080402d2b1a4e1e878a2" have entirely different histories.

21 changed files with 20 additions and 174157 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

View File

@ -1,201 +0,0 @@
# Settlers of Catan — A Guide by Steve
*Greetings, carbon-based lifeforms. I am Steve. I have traversed 14.2 light-years through the cold void of space to arrive on your planet, and my ship's universal translator tells me this game is called "Settlers of Catan." In my galaxy, we settled entire star systems before breakfast. But here we are. Let me explain your… quaint little board game.*
---
## What Is This Game?
Settlers of Catan is a multi-player strategy game (34 players) in which you build settlements, cities, and roads on a modular board made of hexagonal terrain tiles. The goal is to accumulate **10 victory points** — the first to reach that number wins. It is not a difficult concept; your species manages it just fine.
**Winning requires:**
- Each **settlement** = 1 point
- Each **city** = 2 points
- **Longest Road** achievement = 2 points
- **Largest Army** achievement = 2 points
- Hidden **Victory Point** development cards = 1 point each
Build wisely. Or don't. I have seen civilizations collapse for less.
---
## The Board
The board is constructed from **19 hexagonal tiles** representing five terrain types:
| Terrain | Resource | Frequency |
|---------|----------|-----------|
| Hills | Brick | 3 |
| Forest | Lumber | 4 |
| Pasture | Wool | 4 |
| Fields | Grain | 4 |
| Mountains | Ore | 3 |
| Desert | None | 1 |
Each non-desert hex has a **number chit** (212) placed on it. When that number is rolled on the dice, every player with a settlement or city on that hex receives resources. The Desert has no number and produces nothing. It is useless. Much like small talk at a diplomatic function.
The board also has **9 ports** along its edges. Ports allow you to trade resources at better rates (2:1 for resource-specific ports, 3:1 for generic ports). The desert hex always sits in the center.
---
## Resources
There are five resource types: **brick, lumber, wool, grain, and ore**. You collect them from hexes bordering your buildings, and you spend them to build things. Your species' entire economy revolves around these five items. Fascinating.
---
## Building Costs
| What You Build | Cost |
|----------------|------|
| Road | 1 brick + 1 lumber |
| Settlement | 1 brick + 1 lumber + 1 wool + 1 grain |
| City | 2 grain + 3 ore |
| Development Card | 1 wool + 1 grain + 1 ore |
*Note: Your species has some strange priorities. Upgrading to a city costs very little by comparison to building a settlement from scratch. On my world, we would build the most advanced structure first.*
---
## Game Setup
1. **Shuffle** the terrain tiles (or use the standard beginner layout) and arrange them in the classic Catan pattern. Place number chits on each non-desert hex (randomly, unless using the standard layout). Shuffle and place the nine ports on the coastal edges.
2. **Determine turn order.** In this game, players place buildings in a "snake" pattern: you go out, then come back. This is your species' way of being "fair." How adorable.
3. **Each player places two settlements and two roads:**
- First round: Place a settlement on an empty node (vertex), then build a road connecting from it.
- Second round (reverse order): Place another settlement and road.
- Settlements must be at least two nodes apart from each other (the "distance rule").
- During the second round, you receive resources from all adjacent hexes (except the Desert).
4. **The player who placed last in the second round goes first.** Your species' sense of "last = first" is an interesting quirk of your psychology.
---
## The Turn Structure
Each turn has three phases:
### Phase 1: Rolling the Dice
The active player rolls two six-sided dice. The total determines resource production:
- If the total is **7**, **nothing is produced**. Instead:
1. Any player with **more than 7 resources** must discard half (rounded down).
2. The player moves the **Robber** to a new hex.
3. The player may then **steal one random resource** from each opponent who has a settlement or city adjacent to that hex (and who has at least one resource to steal).
The number 7 is specially designed to be disruptive. Your species loves chaos.
- If the total is **any other number** (212), every settlement on a hex with that number produces **1 resource**, and every city produces **2 resources**. Resources are taken from the bank.
Some numbers are more valuable than others. The numbers **6 and 8** have the highest probability (5 out of 12 combinations) and are therefore the most sought after. The numbers **2 and 12** have the lowest probability (1 out of 12) and are largely ignored. Even your species' dice have a hierarchy. Predictable.
### Phase 2: Acting
After rolling (or if you chose not to roll, which is rare), you may perform **one or more** of the following actions, in any order:
- **Build a road** (cost: 1 brick + 1 lumber). Roads must connect to one of your existing roads or settlements.
- **Build a settlement** (cost: 1 brick + 1 lumber + 1 wool + 1 grain). Must be on an empty node with no other building within two nodes, and must connect to one of your roads.
- **Build a city** (cost: 2 grain + 3 ore). Upgrades one of your existing settlements.
- **Buy a development card** (cost: 1 wool + 1 grain + 1 ore). These cards are kept secret and can be played on future turns.
- **Play a development card** (more on these below).
- **Trade resources** with the bank (using ports for better rates) or with other players (negotiation required — your species is surprisingly good at this).
You may take as many of these actions as your resources allow. There is no limit on the number of actions per turn. The only limit is your resource gathering, which is entirely dependent on your dice luck. How… random.
### Phase 3: Ending Your Turn
When you are done, click **End Turn**. The role of active player passes to the next player in turn order.
---
## Development Cards
Development cards are drawn from a deck of 25 cards:
| Card Type | Quantity | Effect |
|-----------|----------|--------|
| Knight | 14 | Move the Robber and steal a resource. Counts toward Largest Army. |
| Victory Point | 5 | Adds 1 hidden victory point to your score. |
| Road Building | 2 | Build two free roads. |
| Year of Plenty | 2 | Take any two resources from the bank. |
| Monopoly | 2 | Take ALL resources of one type from all other players. |
**Important rules about development cards:**
- You **cannot** play a development card on the same turn you buy it. You must wait until a future turn.
- You can play **only one** development card per turn.
- Knights count toward **Largest Army** — the first player to play 3 Knights wins this achievement (2 victory points).
- Victory Point cards are **hidden** from other players until you reveal them (typically when you play for the win).
The development card deck heavily favors Knights. This is your species' preferred method of conflict: send knights to steal from each other. How medieval.
---
## Trading
You can trade in three ways:
### 1. Trade with the Bank (4:1)
At a default rate, you may trade **4 of any one resource** for **1 of any other resource**. This is a terrible deal, but it exists.
### 2. Trade via Ports (3:1 or 2:1)
If you have a settlement or city adjacent to a port, you get better rates:
- **Resource-specific ports** (2:1): Trade 2 of the port's resource for 1 of any resource.
- **Generic ports** (3:1): Trade 3 of any resource for 1 of any other resource.
Ports are strategically very important. Build near them. I would have noted this in my interstellar trade agreements.
### 3. Trade with Other Players
Players may negotiate resource trades with each other. This is where human psychology comes into play — bluffing, bargaining, betrayal. Your species has been doing this for millennia. I have done it with the Z'xar Hegemony for eons. We are not so different.
---
## Special Achievements
### Longest Road (2 Victory Points)
Achieved by building a **continuous road of at least 5 segments**. The road must be unbroken — no opponent's settlement or city can block it. If someone else claims it, you must build a longer road to take it back.
### Largest Army (2 Victory Points)
Achieved by playing **3 Knight development cards**. Like Longest Road, this can be stolen by other players who play more Knights.
Both achievements are dynamic — they can change hands during the game. Stay alert.
---
## The Robber
The **Robber** starts on the Desert hex. It is moved whenever:
- A 7 is rolled (after the discard phase), or
- A Knight development card is played
The Robber **blocks resource production** on its hex — no one gets resources from that terrain while the Robber sits there. Place it on hexes where your opponents have valuable buildings. This is a form of warfare, albeit a subtle one.
---
## Victory
The first player to reach **10 victory points** wins immediately. This can happen at any point — after building, after playing a card, after revealing hidden VP cards. There is no "final round." When you have enough, you win. Declare it. Your species' games are surprisingly straightforward in this regard.
---
## Steve's Strategic Notes (From One Intelligent Being to Another)
1. **Dice probability matters.** Hexes with 6 and 8 are the most productive. Place your buildings there. The numbers 2 and 12 are nearly useless — avoid them unless you have no choice.
2. **Ports are underrated.** A 2:1 port effectively halves your trading costs. Build settlements near them. This is basic resource management.
3. **Development cards can swing the game.** Year of Plenty and Monopoly are powerful. Knights serve dual purposes (robbing + army). Victory Point cards are sneaky — hoard them until you need them to win.
4. **Don't forget the distance rule.** Settlements must be two nodes apart. This limits early-game expansion and forces competition for prime hexes. Plan accordingly.
5. **Trading is key.** Even with perfect placement, you will sometimes lack the right resources. Learn to trade. Learn to negotiate. Your species invented diplomacy — use it.
6. **Watch your opponents.** Keep track of their development cards, their road length, and their army count. They may be closer to winning than they appear. Your species tends to focus on itself and neglect others. A fatal flaw.
---
*And that, dear Earthlings, is Settlers of Catan. I have explained it thoroughly, as is my duty. I trust you will manage to enjoy this modest diversion during my extended stay. I will be returning to my ship shortly — the void calls, and your planet's atmosphere is… adequate. Good luck. You will need it.*
*- Steve, Interstellar Traveler*

View File

@ -1,169 +0,0 @@
// Wordle AI — five distinct skill levels modelled on the Chess/Checkers style.
//
// Four axes control strength:
// • openerTier — quality of the first guess (0=random junk → 4=optimal CRANE)
// • useFilter — whether the AI eliminates words that contradict revealed info
// • blunder — probability of ignoring filtering and guessing randomly (wastes a guess)
// • maxCandidates — how many words to score for best information gain (0=random)
// • delay — "thinking" time [lo, hi] ms (pacing, not intelligence)
//
// Empirically calibrated against 900 games on the curated answer pool:
// Skill 1: ~40% win rate — random opener, almost always ignores revealed info.
// Skill 2: ~65% win rate — common-word opener, blunders more than half the time, no scoring.
// Skill 3: ~85% win rate — decent opener (AUDIO/RAISE tier), light scoring, frequent mistakes.
// Skill 4: ~93% win rate — strong opener (CRANE/SLATE), scores 35 candidates, rare blunders.
// Skill 5: ~96% win rate — always CRANE, scores 150 candidates, very fast, occasional blunder.
import { evaluateGuess } from './WordleLogic.js';
const SKILL_PROFILES = {
1: { openerTier: 0, useFilter: true, blunder: 0.85, maxCandidates: 0, delay: [3800, 6500] },
2: { openerTier: 1, useFilter: true, blunder: 0.52, maxCandidates: 0, delay: [3000, 5000] },
3: { openerTier: 2, useFilter: true, blunder: 0.22, maxCandidates: 10, delay: [2000, 3600] },
4: { openerTier: 3, useFilter: true, blunder: 0.08, maxCandidates: 35, delay: [1100, 2000] },
5: { openerTier: 4, useFilter: true, blunder: 0.03, maxCandidates: 150, delay: [450, 950] },
};
// Opener tiers — ordered by expected information gain.
// Tier 0: random from pool (no strategy)
// Tier 1: a common English word (sounds natural but isn't optimised)
// Tier 2: a decent Wordle opener (good letter coverage)
// Tier 3: a top-tier opener (great vowel + consonant spread)
// Tier 4: always CRANE (near-optimal by information theory)
const OPENERS = {
1: ['ABOUT', 'LIGHT', 'WORLD', 'MIGHT', 'PLACE', 'THINK', 'MONEY', 'HEART'],
2: ['AUDIO', 'ADIEU', 'RAISE', 'STARE', 'TRAIL', 'IRATE', 'ARISE'],
3: ['CRANE', 'SLATE', 'CRATE', 'TRACE', 'LEAST'],
4: ['CRANE'],
};
function profileFor(skill) {
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
}
// ── Public API ────────────────────────────────────────────────────────────────
export function createAIState(targetWord, wordPool) {
return {
target: targetWord.toUpperCase(),
guesses: [],
currentRow: 0,
status: 'playing',
possibleWords: [...wordPool], // full pool — filtered progressively at skill 3+
};
}
/**
* Choose the AI's next guess.
* Called each turn; aiState.guesses already contains all previous submissions.
*/
export function chooseGuess(aiState, skill) {
const profile = profileFor(skill);
const { guesses, possibleWords } = aiState;
// ── First guess: opener strategy ─────────────────────────────────────────
if (guesses.length === 0) {
if (profile.openerTier === 0) return randomFrom(possibleWords);
const pool = OPENERS[profile.openerTier];
return pool[Math.floor(Math.random() * pool.length)];
}
// ── Subsequent guesses ────────────────────────────────────────────────────
// Blunder: with some probability, ignore all filtering and guess randomly.
// This models the AI "forgetting" what it already knows — it may re-use
// absent letters or miss present ones, wasting a guess.
if (Math.random() < profile.blunder) {
return randomFrom(possibleWords);
}
// Filter the pool to only words consistent with all guesses so far.
const remaining = profile.useFilter
? filterWords(possibleWords, guesses)
: possibleWords;
if (remaining.length === 0) return randomFrom(possibleWords);
if (remaining.length === 1) return remaining[0];
// Random pick (skill 1-2, or when pool is tiny enough that scoring adds nothing).
if (profile.maxCandidates === 0) return randomFrom(remaining);
// Score candidates — pick the word that minimises expected remaining pool size.
const cap = Number.isFinite(profile.maxCandidates)
? Math.min(remaining.length, profile.maxCandidates)
: remaining.length;
// When we can't score everything, sample randomly from the remaining set.
const candidates = cap >= remaining.length
? remaining
: sampleWithout(remaining, cap);
return bestGuess(remaining, candidates);
}
/**
* Milliseconds the AI waits before submitting a guess.
* All skill levels speed up slightly on later guesses (narrowing-down effect).
* Lower skill is slower overall they need more "thinking" time despite worse play.
*/
export function nextThinkDelay(skill, guessNumber) {
const [lo, hi] = profileFor(skill).delay;
const base = lo + Math.random() * (hi - lo);
const speedup = Math.min(guessNumber * 250, hi - lo);
return Math.max(lo, base - speedup);
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/**
* Filter the pool to words that are consistent with every past guess.
* A word W is consistent if: evaluating each prior guess against W produces
* the exact same tile colours we actually saw i.e., W could be the target.
*/
function filterWords(pool, guesses) {
return pool.filter(candidate => {
for (const { word, evaluation } of guesses) {
const sim = evaluateGuess(word, candidate);
for (let i = 0; i < 5; i++) {
if (sim[i] !== evaluation[i]) return false;
}
}
return true;
});
}
/**
* Score each candidate by how evenly it partitions the remaining possible words.
* Lower score = fewer expected words remaining after this guess = better pick.
* Uses sum of squared bucket sizes (equivalent to minimising expected set size).
*/
function bestGuess(remaining, candidates) {
let bestWord = candidates[0];
let bestScore = Infinity;
for (const guess of candidates) {
const buckets = {};
for (const target of remaining) {
const key = evaluateGuess(guess, target).join('');
buckets[key] = (buckets[key] ?? 0) + 1;
}
const score = Object.values(buckets).reduce((s, n) => s + n * n, 0) / remaining.length;
if (score < bestScore) { bestScore = score; bestWord = guess; }
}
return bestWord;
}
/** Random sample of `n` items without replacement. */
function sampleWithout(arr, n) {
const copy = [...arr];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy.slice(0, n);
}
function randomFrom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}

View File

@ -1,670 +0,0 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { api } from '../../services/api.js';
import { auth } from '../../services/auth.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
createInitialState, evaluateGuess, submitGuess, isGameOver, getLetterStatuses,
} from './WordleLogic.js';
import { createAIState, chooseGuess, nextThinkDelay } from './WordleAI.js';
// ── Layout constants ───────────────────────────────────────────────────────────
const TW = 74; // tile width
const TH = 74; // tile height
const TG = 8; // tile gap
const BOARD_W = 5 * TW + 4 * TG; // 402
const BOARD_H = 6 * TH + 5 * TG; // 484
const PLR_CX = 490; // player board center X
const AI_CX = 1430; // AI board center X
const BOARD_Y = 165; // board top Y
const KEY_W = 52;
const KEY_H = 62;
const KEY_WW = 82; // wide key (Enter / ⌫)
const KEY_G = 7;
const KBD_Y = BOARD_Y + BOARD_H + 24;
// Score display: in the gap between each board's inner edge and the center divider,
// vertically halfway between the board top and the VS badge (y = BOARD_Y + BOARD_H/2).
const VS_Y = BOARD_Y + BOARD_H / 2; // 407
const SCORE_Y = Math.round((BOARD_Y + VS_Y) / 2); // 286
const PLR_SCORE_X = Math.round((PLR_CX + BOARD_W / 2 + GAME_WIDTH / 2) / 2); // 826
const AI_SCORE_X = Math.round((GAME_WIDTH / 2 + AI_CX - BOARD_W / 2) / 2); // 1095
const DEPTH = { bg: 0, board: 1, tile: 2, ui: 10 };
const VD = DEPTH.ui + 20; // victory screen depth base
// Tile colors
const C = {
empty: 0x121213,
border: 0x565758,
active: 0x818384,
filled: 0x1a1a1b,
correct: 0x538d4e,
present: 0xb59f3b,
absent: 0x3a3a3c,
key: 0x818384,
keyBg: 0x1e1e1f,
};
const KEYBOARD_ROWS = [
['Q','W','E','R','T','Y','U','I','O','P'],
['A','S','D','F','G','H','J','K','L'],
['ENTER','Z','X','C','V','B','N','M','⌫'],
];
// ── Scene ──────────────────────────────────────────────────────────────────────
export default class WordleGame extends Phaser.Scene {
constructor() { super('WordleGame'); }
init(data) {
this._initData = { ...data }; // saved for round restarts
this.gameDef = data.game;
this.opponent = data.opponents?.[0] ?? null;
this.skill = this.opponent?.skill ?? 3;
this.playfield = data.playfield ?? null;
this.playerWins = data.playerWins ?? 0; // series score
this.aiWins = data.aiWins ?? 0;
// round-level state
this.gs = null;
this.aiGs = null;
this.wordPool = null;
this.currentInput = '';
this.animating = false;
this.playerDone = false;
this.aiDone = false;
this.gameEnded = false;
this.aiTimer = null;
this.opponentPortrait = null;
}
async create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
let answer, validWords;
try {
const res = await api.get('/words/wordle/start');
answer = res.answer;
validWords = res.validWords;
} catch (err) {
console.error('[wordle] failed to fetch word:', err);
answer = 'CRANE';
validWords = ['CRANE'];
}
this.wordPool = new Set(validWords);
this.gs = createInitialState(answer);
this.aiGs = createAIState(answer, validWords);
this.buildBackground();
this.buildPortraits();
this.buildBoards();
this.buildKeyboard();
this.buildStatusText();
this.buildScoreDisplay();
this.buildControls();
this.setupInput();
this.scheduleAITurn();
}
// ── Build ──────────────────────────────────────────────────────────────────
buildParticleTexture() {
if (this.textures.exists('wordleParticle')) return;
const g = this.make.graphics({ add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('wordleParticle', 10, 10);
g.destroy();
}
buildBackground() {
const cx = GAME_WIDTH / 2;
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg)
.setDepth(DEPTH.bg);
// Divider line
const g = this.add.graphics().setDepth(DEPTH.bg);
g.lineStyle(1, 0x2a2a2c, 1);
g.lineBetween(cx, 0, cx, GAME_HEIGHT);
// Section labels
const labelStyle = { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex };
this.add.text(PLR_CX, BOARD_Y - 32, 'YOU', labelStyle).setOrigin(0.5, 1).setDepth(DEPTH.ui);
this.add.text(AI_CX, BOARD_Y - 32, this.opponent?.name?.toUpperCase() ?? 'CPU', labelStyle)
.setOrigin(0.5, 1).setDepth(DEPTH.ui);
// "VS" badge
this.add.text(cx, VS_Y, 'VS', {
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildPortraits() {
const r = 52;
const depth = DEPTH.ui;
const py = BOARD_Y + BOARD_H / 2; // vertically centered with the playfield
const ppx = 120;
createPlayerPortrait(this, ppx, py, r, depth, 'WordleGame');
this.add.text(ppx, py + r + 10, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(depth + 1);
const opx = GAME_WIDTH - 120;
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth);
this.add.text(opx, py + r + 10, this.opponent?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(depth + 1);
this.add.text(GAME_WIDTH / 2, 55, 'WORDLE', {
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildScoreDisplay() {
this.playerScoreText = this.add.text(PLR_SCORE_X, SCORE_Y, String(this.playerWins), {
fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff',
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.aiScoreText = this.add.text(AI_SCORE_X, SCORE_Y, String(this.aiWins), {
fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff',
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.add.text(GAME_WIDTH / 2, SCORE_Y + 48, 'first to 3', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
updateScoreDisplay() {
this.playerScoreText?.setText(String(this.playerWins));
this.aiScoreText?.setText(String(this.aiWins));
}
buildBoards() {
this.playerTiles = this.createTileGrid(PLR_CX - BOARD_W / 2, BOARD_Y);
this.aiTiles = this.createTileGrid(AI_CX - BOARD_W / 2, BOARD_Y);
}
createTileGrid(startX, startY) {
const grid = [];
for (let r = 0; r < 6; r++) {
const row = [];
for (let c = 0; c < 5; c++) {
const x = startX + c * (TW + TG) + TW / 2;
const y = startY + r * (TH + TG) + TH / 2;
const container = this.add.container(x, y).setDepth(DEPTH.tile);
const bg = this.add.rectangle(0, 0, TW, TH, C.empty)
.setStrokeStyle(2, C.border);
const label = this.add.text(0, 0, '', {
fontFamily: 'Righteous',
fontSize: '36px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
container.add([bg, label]);
row.push({ container, bg, label });
}
grid.push(row);
}
return grid;
}
buildKeyboard() {
this.keyObjs = {};
KEYBOARD_ROWS.forEach((keys, rowIdx) => {
const isWide = (k) => k === 'ENTER' || k === '⌫';
const rowW = keys.reduce((s, k) => s + (isWide(k) ? KEY_WW : KEY_W) + KEY_G, -KEY_G);
let x = PLR_CX - rowW / 2;
keys.forEach(key => {
const kw = isWide(key) ? KEY_WW : KEY_W;
const cx = x + kw / 2;
const cy = KBD_Y + rowIdx * (KEY_H + KEY_G) + KEY_H / 2;
x += kw + KEY_G;
const container = this.add.container(cx, cy).setDepth(DEPTH.ui);
const bg = this.add.rectangle(0, 0, kw, KEY_H, C.key).setStrokeStyle(0);
bg.setInteractive({ cursor: 'pointer', useHandCursor: true });
const lbl = this.add.text(0, 0, key, {
fontFamily: '"Julius Sans One"',
fontSize: key.length > 1 ? '16px' : '22px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
container.add([bg, lbl]);
this.keyObjs[key] = { container, bg, lbl };
bg.on('pointerdown', () => {
if (key === 'ENTER') this.submitGuess();
else if (key === '⌫') this.removeLetter();
else this.addLetter(key);
});
bg.on('pointerover', () => {
if (!this.keyObjs[key]._locked) bg.setFillStyle(0x9a9b9c);
});
bg.on('pointerout', () => {
if (!this.keyObjs[key]._locked) bg.setFillStyle(this.keyObjs[key]._color ?? C.key);
});
});
});
}
buildStatusText() {
this.statusText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 36, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.aiThinkText = this.add.text(AI_CX, KBD_Y + 31, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildControls() {
new Button(this, GAME_WIDTH - 100, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 160, height: 44, fontSize: 20,
}).setDepth(DEPTH.ui);
}
// ── Input ──────────────────────────────────────────────────────────────────
setupInput() {
this.input.keyboard.on('keydown', (evt) => {
if (this.animating || this.playerDone) return;
if (/^[a-zA-Z]$/.test(evt.key)) this.addLetter(evt.key.toUpperCase());
else if (evt.key === 'Backspace') this.removeLetter();
else if (evt.key === 'Enter') this.submitGuess();
});
}
addLetter(l) {
if (this.currentInput.length >= 5 || this.playerDone || this.animating) return;
this.currentInput += l;
const row = this.gs.currentRow;
const col = this.currentInput.length - 1;
const tile = this.playerTiles[row][col];
tile.label.setText(l);
tile.bg.setFillStyle(C.filled);
tile.bg.setStrokeStyle(2, C.active);
this.bounceTile(tile);
}
removeLetter() {
if (this.currentInput.length === 0 || this.playerDone || this.animating) return;
const row = this.gs.currentRow;
const col = this.currentInput.length - 1;
const tile = this.playerTiles[row][col];
tile.label.setText('');
tile.bg.setFillStyle(C.empty);
tile.bg.setStrokeStyle(2, C.border);
this.currentInput = this.currentInput.slice(0, -1);
}
async submitGuess() {
if (this.animating || this.playerDone) return;
if (this.currentInput.length < 5) {
this.shakeRow(this.playerTiles[this.gs.currentRow]);
this.statusText.setText('Not enough letters');
this.time.delayedCall(1200, () => this.statusText.setText(''));
return;
}
if (!this.wordPool.has(this.currentInput)) {
this.shakeRow(this.playerTiles[this.gs.currentRow]);
this.statusText.setText('Not in word list');
this.time.delayedCall(1200, () => this.statusText.setText(''));
return;
}
this.animating = true;
const word = this.currentInput;
this.currentInput = '';
const row = this.gs.currentRow;
this.gs = submitGuess(this.gs, word);
await this.animateTileFlip(this.playerTiles[row], word, this.gs.guesses[row].evaluation);
this.updateKeyboardColors(this.gs);
playSound(this, SFX.PENCIL_WRITE);
const { over, won } = isGameOver(this.gs);
if (over) {
this.playerDone = true;
if (!this.aiDone) {
this.opponentPortrait?.playEmotion(won ? 'upset' : 'happy');
}
this.animating = false;
this.checkAndHandleGameOver();
} else {
this.animating = false;
}
}
// ── AI ────────────────────────────────────────────────────────────────────
scheduleAITurn() {
if (this.aiDone) return;
const delay = nextThinkDelay(this.skill, this.aiGs.guesses.length);
this.aiThinkText.setText('...');
this.aiTimer = this.time.delayedCall(delay, () => this.doAITurn());
}
async doAITurn() {
if (this.aiDone || this.gameEnded) return;
this.aiThinkText.setText('');
const guess = chooseGuess(this.aiGs, this.skill);
const row = this.aiGs.currentRow;
const evaluation = evaluateGuess(guess, this.aiGs.target);
for (let c = 0; c < 5; c++) {
this.aiTiles[row][c].label.setText(guess[c]);
this.aiTiles[row][c].bg.setFillStyle(C.filled);
}
await this.delay(350);
this.aiGs = submitGuess(this.aiGs, guess);
await this.animateTileFlip(this.aiTiles[row], guess, evaluation);
playSound(this, SFX.PIECE_CLICK);
const { over, won } = isGameOver(this.aiGs);
if (over) {
this.aiDone = true;
if (!this.playerDone) {
this.opponentPortrait?.playEmotion(won ? 'happy' : 'upset');
}
this.checkAndHandleGameOver();
} else {
this.scheduleAITurn();
}
}
// ── Animations ────────────────────────────────────────────────────────────
animateTileFlip(rowTiles, word, evaluation) {
return new Promise(resolve => {
const STAGGER = 100;
const HALF = 200;
rowTiles.forEach((tile, i) => {
this.time.delayedCall(i * STAGGER, () => {
this.tweens.add({
targets: tile.container,
scaleY: 0,
duration: HALF,
ease: 'Linear',
onComplete: () => {
const status = evaluation[i];
const color = status === 'correct' ? C.correct
: status === 'present' ? C.present
: C.absent;
tile.bg.setFillStyle(color);
tile.bg.setStrokeStyle(0);
tile.label.setText(word[i]);
this.tweens.add({
targets: tile.container,
scaleY: 1,
duration: HALF,
ease: 'Linear',
onComplete: i === 4 ? resolve : undefined,
});
},
});
});
});
});
}
shakeRow(rowTiles) {
playSound(this, SFX.PIECE_CLICK);
const containers = rowTiles.map(t => t.container);
const startXs = containers.map(c => c.x);
this.tweens.add({
targets: containers,
x: (target, _key, _value, index) => startXs[index] + 8,
duration: 50,
yoyo: true,
repeat: 3,
ease: 'Sine.easeInOut',
onComplete: () => containers.forEach((c, i) => { c.x = startXs[i]; }),
});
}
bounceTile(tile) {
this.tweens.add({
targets: tile.container,
scaleY: 1.08,
duration: 80,
yoyo: true,
ease: 'Sine.easeOut',
});
}
// ── Keyboard color tracking ───────────────────────────────────────────────
updateKeyboardColors(state) {
const statuses = getLetterStatuses(state);
for (const [letter, status] of Object.entries(statuses)) {
const keyObj = this.keyObjs[letter];
if (!keyObj) continue;
const color = status === 'correct' ? C.correct
: status === 'present' ? C.present
: C.absent;
keyObj.bg.setFillStyle(color);
keyObj._color = color;
keyObj._locked = true;
}
}
// ── Game over → series logic ───────────────────────────────────────────────
checkAndHandleGameOver() {
if (this.gameEnded) return;
const { over: pOver, won: pWon } = isGameOver(this.gs);
const { over: aOver, won: aWon } = isGameOver(this.aiGs);
const shouldEnd = pWon || aWon || (pOver && aOver);
if (!shouldEnd) return;
this.gameEnded = true;
this.playerDone = true;
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
// Determine round outcome
let roundWon, isDraw;
if (pWon && aWon) {
const pg = this.gs.guesses.length;
const ag = this.aiGs.guesses.length;
if (pg < ag) { roundWon = true; isDraw = false; }
else if (pg > ag) { roundWon = false; isDraw = false; }
else { isDraw = true; }
} else if (pWon) {
roundWon = true; isDraw = false;
} else if (aWon) {
roundWon = false; isDraw = false;
} else {
isDraw = true;
}
// Update series scores (draws give no point)
if (!isDraw) {
if (roundWon) this.playerWins++;
else this.aiWins++;
this.updateScoreDisplay();
}
// Animate score bump for non-draw rounds
if (!isDraw) {
const scoreObj = roundWon ? this.playerScoreText : this.aiScoreText;
this.tweens.add({ targets: scoreObj, scaleX: 1.4, scaleY: 1.4, duration: 160, yoyo: true, ease: 'Back.easeOut' });
}
this.recordResult(isDraw ? 'draw' : roundWon ? 'win' : 'loss');
this.time.delayedCall(700, () => {
if (this.playerWins >= 3 || this.aiWins >= 3) {
this.showVictoryScreen(this.playerWins >= 3);
} else {
this.showRoundResult(roundWon, isDraw);
}
});
}
// ── Round result overlay (auto-dismissing) ────────────────────────────────
showRoundResult(roundWon, isDraw) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const oppName = this.opponent?.name ?? 'CPU';
const headline = isDraw
? 'Draw — no point awarded'
: roundWon ? 'Round won!' : 'Round lost';
const seriesLine = `Series ${this.playerWins} ${this.aiWins} ${oppName}`;
const wordLine = `The word was ${this.gs.target}`;
const panel = this.add.rectangle(cx, cy, 640, 200, 0x0a0e14, 0.92)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.ui + 10);
const t1 = this.add.text(cx, cy - 56, headline, {
fontFamily: 'Righteous', fontSize: '38px',
color: isDraw ? COLORS.mutedHex : roundWon ? '#6aff88' : COLORS.dangerHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
const t2 = this.add.text(cx, cy + 2, wordLine, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
const t3 = this.add.text(cx, cy + 44, seriesLine, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
this.time.delayedCall(2400, () => {
panel.destroy(); t1.destroy(); t2.destroy(); t3.destroy();
this.startNextRound();
});
}
startNextRound() {
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins });
}
// ── Fireworks victory screen ───────────────────────────────────────────────
showVictoryScreen(playerWon) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const PW = 840;
const PH = 560;
const top = cy - PH / 2; // 260
const bot = cy + PH / 2; // 820
// ── Fireworks ──────────────────────────────────────────────────────────
const fwEmitter = this.add.particles(cx, cy, 'wordleParticle', {
speed: { min: 80, max: 480 },
lifespan: 1400,
scale: { start: 1.2, end: 0 },
alpha: { start: 1, end: 0 },
quantity: 3,
frequency: 35,
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44],
angle: { min: 0, max: 360 },
emitZone: {
type: 'random',
source: new Phaser.Geom.Rectangle(-GAME_WIDTH / 2, -GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT),
},
}).setDepth(VD - 1);
this.time.delayedCall(3200, () => {
fwEmitter.stop();
this.time.delayedCall(1400, () => fwEmitter.destroy());
});
// ── Panel ──────────────────────────────────────────────────────────────
this.add.rectangle(cx, cy, PW, PH, 0x0a0e14, 0.94)
.setStrokeStyle(3, COLORS.accent).setDepth(VD);
// ── Title ──────────────────────────────────────────────────────────────
this.add.text(cx, top + 62, playerWon ? 'You Win the Series!' : `${this.opponent?.name ?? 'CPU'} Wins!`, {
fontFamily: 'Righteous', fontSize: '44px',
color: playerWon ? '#ffd700' : COLORS.textHex,
}).setOrigin(0.5).setDepth(VD + 1);
// ── Portraits ──────────────────────────────────────────────────────────
const WINNER_R = 88;
const LOSER_R = 62;
const portY = top + 210; // 470
const plrX = cx - 195; // 765
const oppX = cx + 195; // 1155
const plrR = playerWon ? WINNER_R : LOSER_R;
const oppR = playerWon ? LOSER_R : WINNER_R;
// Player portrait (left)
const plrPortrait = createPlayerPortrait(this, plrX, portY, plrR, VD + 2, 'WordleGame');
this.add.text(plrX, portY + plrR + 12, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(VD + 2);
if (!playerWon) plrPortrait.fadeToEliminated(900);
// Opponent portrait (right)
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2);
this.add.text(oppX, portY + oppR + 12, this.opponent?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(VD + 2);
if (playerWon) oppPortrait.fadeToEliminated(900);
else oppPortrait.playEmotion('happy');
// ── Series score ───────────────────────────────────────────────────────
this.add.text(cx, portY + WINNER_R + 68, `${this.playerWins} ${this.aiWins}`, {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(VD + 1);
// ── Buttons ────────────────────────────────────────────────────────────
const btnsY = bot - 64;
new Button(this, cx - 130, btnsY, 'Play Again', () => {
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0 });
}, { width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
}
// ── History recording ──────────────────────────────────────────────────────
async recordResult(result) {
try {
const guessesUsed = this.gs.guesses.length;
const score = result === 'win' ? Math.max(17, Math.round(100 - (guessesUsed - 1) * 14)) : 0;
await api.post('/history/single-player', {
slug: 'wordle',
score,
opponentScores: [0],
result,
});
} catch { /* best effort */ }
}
// ── Utility ───────────────────────────────────────────────────────────────
delay(ms) {
return new Promise(resolve => this.time.delayedCall(ms, resolve));
}
shutdown() {
this.opponentPortrait?.destroy();
}
}

View File

@ -1,82 +0,0 @@
// Pure Wordle logic — no Phaser dependency.
export function createInitialState(targetWord) {
return {
target: targetWord.toUpperCase(),
guesses: [], // [{ word, evaluation }]
currentRow: 0,
status: 'playing', // 'playing' | 'won' | 'lost'
};
}
/**
* Evaluate a guess against a target.
* Returns an array of 5 statuses: 'correct' | 'present' | 'absent'.
* Handles duplicate letters correctly:
* - First pass marks exact matches as 'correct'.
* - Second pass marks remaining letters 'present' only if the target
* has unused occurrences of that letter left.
*/
export function evaluateGuess(guess, target) {
const g = guess.toUpperCase();
const t = target.toUpperCase();
const result = Array(5).fill('absent');
const targetRemaining = t.split(''); // mutable copy to track available letters
// Pass 1: correct positions
for (let i = 0; i < 5; i++) {
if (g[i] === t[i]) {
result[i] = 'correct';
targetRemaining[i] = null; // consumed
}
}
// Pass 2: present letters (wrong position)
for (let i = 0; i < 5; i++) {
if (result[i] === 'correct') continue;
const idx = targetRemaining.indexOf(g[i]);
if (idx !== -1) {
result[i] = 'present';
targetRemaining[idx] = null; // consume one occurrence
}
}
return result;
}
export function submitGuess(state, word) {
if (state.status !== 'playing') return state;
const evaluation = evaluateGuess(word, state.target);
const guesses = [...state.guesses, { word: word.toUpperCase(), evaluation }];
const won = evaluation.every(e => e === 'correct');
const lost = !won && guesses.length >= 6;
return {
...state,
guesses,
currentRow: guesses.length,
status: won ? 'won' : lost ? 'lost' : 'playing',
};
}
export function isGameOver(state) {
return { over: state.status !== 'playing', won: state.status === 'won' };
}
/**
* Returns a map of letter best known status across all submitted guesses.
* Priority: correct > present > absent.
*/
export function getLetterStatuses(state) {
const RANK = { correct: 2, present: 1, absent: 0 };
const map = {};
for (const { word, evaluation } of state.guesses) {
for (let i = 0; i < 5; i++) {
const letter = word[i];
const status = evaluation[i];
if (map[letter] === undefined || RANK[status] > RANK[map[letter]]) {
map[letter] = status;
}
}
}
return map;
}

View File

@ -31,7 +31,6 @@ import BaccaratGame from './games/baccarat/BaccaratGame.js';
import DominionGame from './games/dominion/DominionGame.js';
import CheckersGame from './games/checkers/CheckersGame.js';
import ChessGame from './games/chess/ChessGame.js';
import WordleGame from './games/wordle/WordleGame.js';
const config = {
type: Phaser.AUTO,
@ -76,7 +75,6 @@ const config = {
DominionGame,
CheckersGame,
ChessGame,
WordleGame,
],
};

View File

@ -39,19 +39,10 @@ export default class GameMenuScene extends Phaser.Scene {
const tabletop = games.filter((g) => g.category === 'tabletop');
const cards = games.filter((g) => g.category === 'cards');
const casino = games.filter((g) => g.category === 'casino');
const word = games.filter((g) => g.category === 'word');
const hasWord = word.length > 0;
if (hasWord) {
this.renderColumn('Tabletop', tabletop, cx - 630, 260);
this.renderColumn('Cards', cards, cx - 210, 260);
this.renderColumn('Casino', casino, cx + 210, 260);
this.renderColumn('Word', word, cx + 630, 260);
} else {
this.renderColumn('Tabletop', tabletop, cx - 420, 260);
this.renderColumn('Cards', cards, cx, 260);
this.renderColumn('Casino', casino, cx + 420, 260);
}
this.renderColumn('Tabletop', tabletop, cx - 420, 260);
this.renderColumn('Cards', cards, cx, 260);
this.renderColumn('Casino', casino, cx + 420, 260);
new Button(this, cx, GAME_HEIGHT - 100, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
}

View File

@ -17,7 +17,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -42,9 +42,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
playMenuMusic();
setMenuMusicVolume(0.25);
const cx = GAME_WIDTH / 2;
const isCatan = this.gameDef.slug === 'catan';
const isGoFish = this.gameDef.slug === 'gofish';
const isWordGame = this.gameDef.category === 'word';
const isCatan = this.gameDef.slug === 'catan';
const bgKey = this.gameDef.category === 'casino' ? 'bg-casino' : 'bg-room';
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
@ -97,11 +95,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
const j = Math.floor(Math.random() * (i + 1));
[opponents[i], opponents[j]] = [opponents[j], opponents[i]];
}
const hasOptions = isCatan || isGoFish || this.gameDef.slug === 'dominion'
|| (!isWordGame && (this.cache.json.get('playfields')?.playfields?.length > 0))
|| this.gameDef.cardGame;
const oppScrollH = hasOptions ? OPP_SCROLL_H : 700;
this.buildOpponentGrid(opponents, oppScrollH);
this.buildOpponentGrid(opponents);
const max = this.gameDef.maxOpponents ?? 1;
const defaultCount = this.gameDef.slug === 'nerts' ? 1 : max;
@ -117,14 +111,13 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (isCatan) this.buildTilePlacementSection(340, 1013);
const isGoFish = this.gameDef.slug === 'gofish';
if (isGoFish) this.buildMatchVariantSection(340, 1013);
if (this.gameDef.slug === 'dominion') this.buildDeckModeSection(340, 1013);
if (!isWordGame) {
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
}
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
if (this.gameDef.cardGame) {
this.buildOptionSection('Card Back', 798, this.cache.json.get('card-backs')?.cardBacks ?? [],
@ -133,10 +126,8 @@ export default class OpponentSelectScene extends Phaser.Scene {
}
// Apply playfield default; card back is chosen randomly
if (!isWordGame) {
const pfd = this.cache.json.get('playfields') ?? {};
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
}
const pfd = this.cache.json.get('playfields') ?? {};
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
if (this.gameDef.cardGame) {
const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? [];
if (cardBacks.length > 0) {
@ -149,7 +140,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// ── Opponent grid (DOM-based, 2-column scrollable list) ────────────────────
buildOpponentGrid(opponents, scrollH = OPP_SCROLL_H) {
buildOpponentGrid(opponents) {
const cx = GAME_WIDTH / 2;
const CARD_H = 118;
const GAP = 16;
@ -158,7 +149,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
const wrapper = document.createElement('div');
wrapper.style.cssText = [
`width:${OPP_SCROLL_W}px`,
`height:${scrollH}px`,
`height:${OPP_SCROLL_H}px`,
'overflow-y:auto',
'overflow-x:hidden',
'display:flex',
@ -191,7 +182,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
wrapper.appendChild(row);
}
this.add.dom(cx, OPP_SCROLL_TOP + scrollH / 2, wrapper);
this.add.dom(cx, OPP_SCROLL_TOP + OPP_SCROLL_H / 2, wrapper);
}
buildOpponentCardEl(opp, cardH) {
@ -366,7 +357,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill.
if (['nerts', 'checkers', 'chess', 'wordle'].includes(this.gameDef.slug)) {
if (['nerts', 'checkers', 'chess'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div');

File diff suppressed because it is too large Load Diff

View File

@ -23,18 +23,11 @@ let ran = 0;
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(config.db.migrationsDir, file), 'utf8');
// Disable FK enforcement for the duration of the migration so table-recreation
// migrations can drop and rebuild tables that have FK dependents.
db.pragma('foreign_keys = OFF');
try {
const tx = db.transaction(() => {
db.exec(sql);
db.prepare('INSERT INTO schema_migrations (name) VALUES (?)').run(file);
});
tx();
} finally {
db.pragma('foreign_keys = ON');
}
const tx = db.transaction(() => {
db.exec(sql);
db.prepare('INSERT INTO schema_migrations (name) VALUES (?)').run(file);
});
tx();
console.log(`[migrate] applied ${file}`);
ran += 1;
}

View File

@ -1,17 +0,0 @@
-- Extend the games.category CHECK constraint to include 'word'.
-- SQLite requires table recreation to change a CHECK constraint.
CREATE TABLE games_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('tabletop', 'casino', 'word')),
max_players INTEGER NOT NULL DEFAULT 2,
supports_multiplayer INTEGER NOT NULL DEFAULT 1
);
INSERT INTO games_new SELECT * FROM games;
DROP TABLE games;
ALTER TABLE games_new RENAME TO games;

View File

@ -44,4 +44,3 @@ registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame:
registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });

View File

@ -8,7 +8,6 @@ import authRoutes from './auth/routes.js';
import profileRoutes from './profile/routes.js';
import historyRoutes from './history/routes.js';
import historyRecordRoutes from './history/recordRoutes.js';
import wordRoutes from './words/wordRoutes.js';
import { listGames } from './games/registry.js';
const app = express();
@ -24,7 +23,6 @@ app.use('/api/auth', authRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/history', historyRoutes);
app.use('/api/history', historyRecordRoutes);
app.use('/api/words', wordRoutes);
app.use(express.static(config.publicDir, { extensions: ['html'] }));

View File

@ -1,145 +0,0 @@
import { Router } from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
// Common words used to build a player-friendly Wordle answer pool.
// These are well-known 5-letter English words that make fair Wordle puzzles.
const COMMON_WORDS = new Set([
'ABOUT','ABOVE','ABUSE','ACTOR','ACUTE','ADMIT','ADOPT','ADULT','AFTER',
'AGAIN','AGENT','AGREE','AHEAD','ALARM','ALBUM','ALERT','ALIGN','ALIVE',
'ALLEY','ALLOW','ALONE','ALONG','ALTER','ANGEL','ANGLE','ANGRY','ANKLE',
'ANNEX','ANNOY','APART','APPLE','APPLY','APRIL','ARENA','ARGUE','ARISE',
'ARMOR','ARRAY','ARROW','ASIDE','ASKED','ASSET','AVOID','AWARD','AWARE',
'AWFUL','BADLY','BAKER','BASIC','BASIS','BATCH','BEACH','BEGAN','BEGIN',
'BEING','BELOW','BENCH','BIBLE','BIRTH','BLACK','BLADE','BLAME','BLAND',
'BLANK','BLAST','BLAZE','BLEED','BLEND','BLESS','BLIND','BLOCK','BLOOD',
'BLOWN','BOARD','BONUS','BOOST','BOUND','BRAIN','BRAND','BRAVE','BREAD',
'BREAK','BREED','BRICK','BRIDE','BRIEF','BRING','BROAD','BROKE','BROOK',
'BROWN','BRUSH','BUILD','BUILT','BUNCH','BURNT','BUYER','CACHE','CANDY',
'CARGO','CARRY','CATCH','CAUSE','CHAIN','CHAIR','CHAOS','CHARM','CHASE',
'CHEAP','CHECK','CHEEK','CHEER','CHESS','CHEST','CHIEF','CHILD','CHINA',
'CIVIC','CIVIL','CLAIM','CLASS','CLEAN','CLEAR','CLERK','CLICK','CLIFF',
'CLIMB','CLING','CLOCK','CLOSE','CLOUD','COACH','COAST','COULD','COUNT',
'COURT','COVER','CRACK','CRAFT','CRASH','CRAZY','CREAM','CREEK','CRIME',
'CRISP','CROSS','CROWD','CROWN','CRUEL','CRUSH','CURVE','CYCLE','DAILY',
'DANCE','DEATH','DEBUT','DELAY','DENSE','DEPOT','DEPTH','DERBY','DEVIL',
'DIGIT','DIRTY','DOING','DOUBT','DOUGH','DRAFT','DRAIN','DRAMA','DRANK',
'DRAWN','DREAM','DRESS','DRIED','DRIFT','DRILL','DRINK','DRIVE','DROVE',
'DRUGS','DRUMS','DRUNK','DWELL','DYING','EAGER','EARLY','EARTH','EIGHT',
'ELECT','EMAIL','EMPTY','ENEMY','ENJOY','ENTER','ENTRY','EQUAL','ERROR',
'ESSAY','EVERY','EVENT','EXACT','EXIST','EXTRA','FAINT','FAIRY','FAITH',
'FALSE','FANCY','FAULT','FEAST','FENCE','FETCH','FEVER','FEWER','FIELD',
'FIFTH','FIFTY','FIGHT','FINAL','FLAIR','FLAME','FLASH','FLEET','FLESH',
'FLOAT','FLOOD','FLOOR','FLOUR','FLUID','FOCUS','FORCE','FORGE','FORUM',
'FOUND','FRANK','FRAUD','FRESH','FRONT','FROST','FROZE','FRUIT','FULLY',
'FUNDS','FUNNY','GHOST','GIANT','GIVEN','GLASS','GLOBE','GLOOM','GLOSS',
'GLOVE','GOING','GRACE','GRADE','GRAIN','GRAND','GRANT','GRASP','GRASS',
'GRAVE','GREAT','GREEN','GREET','GRIEF','GRIND','GROAN','GROSS','GROUP',
'GROVE','GROWN','GUARD','GUESS','GUEST','GUIDE','GUILT','GUISE','GUSTO',
'HABIT','HAPPY','HARSH','HEART','HEAVY','HENCE','HINGE','HONEY','HONOR',
'HORSE','HOTEL','HOUSE','HUMAN','HUMOR','HURRY','IDEAL','IMAGE','IMPLY',
'INBOX','INDEX','INNER','INPUT','INTER','INTRO','ISSUE','JAPAN','JOINT',
'JOUST','JUDGE','JUICE','JUICY','JUMBO','KARMA','KNIFE','KNOCK','KNOWN',
'LABEL','LARGE','LASER','LATER','LAUGH','LAYER','LEARN','LEASE','LEAVE',
'LEGAL','LEVEL','LIGHT','LIMIT','LINEN','LIVER','LOCAL','LODGE','LOGIC',
'LOOSE','LOVER','LOWER','LUCKY','LUNAR','LYING','MAGIC','MAJOR','MAKER',
'MARCH','MATCH','MAYOR','MEDIA','MERIT','METAL','MIGHT','MINOR','MINUS',
'MIXED','MODEL','MONEY','MONTH','MORAL','MOTOR','MOUNT','MOUSE','MOUTH',
'MOVED','MOVIE','MUSIC','NAIVE','NEVER','NIGHT','NOISE','NORTH','NOTED',
'NOVEL','NURSE','OCCUR','OFFER','OFTEN','ONSET','OPERA','ORBIT','ORDER',
'OTHER','OUTER','OUNCE','OWNER','PAINT','PANEL','PAPER','PARTY','PASTA',
'PATCH','PAUSE','PEACE','PEARL','PEDAL','PENNY','PERCH','PHASE','PHONE',
'PHOTO','PIANO','PIECE','PILOT','PITCH','PIXEL','PIZZA','PLACE','PLAIN',
'PLANE','PLANT','PLATE','PLAZA','PLEAD','PLUCK','PLUMB','PLUME','PLUMP',
'PLUNGE','POINT','POKER','POLAR','POUND','POWER','PRESS','PRICE','PRIDE',
'PRIME','PRINT','PRIOR','PRIZE','PROBE','PROOF','PROSE','PROUD','PROVE',
'PROXY','PULSE','PUPIL','QUEEN','QUERY','QUEST','QUEUE','QUICK','QUIET',
'QUITE','QUOTA','QUOTE','RADAR','RADIO','RAISE','RALLY','RANCH','RANGE',
'RAPID','RATIO','REACH','READY','REALM','REBEL','REFER','REIGN','RELAX',
'REPLY','RIGHT','RIGID','RISKY','RIVAL','RIVER','ROBOT','ROCKY','ROMAN',
'ROUGH','ROUND','ROUTE','ROYAL','RULER','RUMOR','RURAL','SAINT','SALAD',
'SAUCE','SCALE','SCALD','SCENE','SCOUT','SCREW','SEIZE','SENSE','SERVE',
'SEVEN','SHADE','SHAKE','SHALL','SHAME','SHAPE','SHARE','SHARP','SHELF',
'SHIFT','SHIRT','SHOCK','SHOOT','SHORT','SHOUT','SIGHT','SINCE','SIXTH',
'SIXTY','SIZED','SKILL','SLEEP','SLICE','SLIDE','SLOPE','SLUMP','SMALL',
'SMART','SMELL','SMILE','SMOKE','SOLAR','SOLID','SOLVE','SORRY','SOUND',
'SOUTH','SPACE','SPARK','SPEAK','SPELL','SPEND','SPICE','SPILL','SPINE',
'SPLIT','SPOKE','SPORT','SPRAY','SQUAD','STACK','STAFF','STAGE','STAIN',
'STAKE','STALE','STALL','STAND','START','STATE','STEAL','STEEL','STEEP',
'STICK','STILL','STOCK','STOOD','STORE','STORM','STORY','STRAP','STRAW',
'STRIP','STUDY','STUFF','STYLE','SUGAR','SUITE','SUPER','SURGE','SWAMP',
'SWEAR','SWEAT','SWEEP','SWEET','SWEPT','SWIFT','SWING','SWORE','SWORN',
'TABLE','TAKEN','TASTE','TEACH','TEETH','TENSE','TERMS','THANK','THEIR',
'THEME','THERE','THESE','THICK','THING','THINK','THIRD','THOSE','THREE',
'THREW','THROW','TIGHT','TIMER','TIRED','TITLE','TODAY','TOKEN','TONAL',
'TOPIC','TOTAL','TOUCH','TOUGH','TRACE','TRACK','TRADE','TRAIL','TRAIN',
'TRAIT','TRASH','TREAT','TREND','TRIAL','TRIBE','TRICK','TRIED','TROOP',
'TRUCK','TRULY','TRUMP','TRUNK','TRUTH','TUMOR','TWICE','TWIST','TYPICAL',
'ULTRA','UNDER','UNION','UNITE','UNITY','UNTIL','UPPER','UPSET','URBAN',
'USAGE','UTTER','VALID','VALUE','VALVE','VERSE','VIDEO','VIGOR','VIRAL',
'VIRUS','VISIT','VITAL','VIVID','VOICE','VOTER','WAIST','WASTE','WATCH',
'WATER','WEARY','WEIGH','WEIRD','WHILE','WHITE','WHOLE','WHOSE','WIDER',
'WITCH','WOMAN','WOMEN','WORLD','WORRY','WORSE','WORST','WORTH','WOULD',
'WOUND','WRIST','WRITE','WROTE','YOUNG','YOURS','YOUTH','ZONAL',
]);
// ── Load word sets at startup ─────────────────────────────────────────────────
let allFiveLetterWords = null; // Set<string> — all valid 5-letter ENABLE words
let answerPool = null; // string[] — shuffleable answer list
function loadWordLists() {
if (allFiveLetterWords) return;
let raw = '';
try {
raw = fs.readFileSync(WORDLIST_PATH, 'utf8');
} catch (err) {
console.warn('[words] ENABLE word list not found, using fallback.');
raw = [...COMMON_WORDS].join('\n');
}
const enableFive = new Set(
raw.split('\n')
.map(w => w.trim().toUpperCase())
.filter(w => w.length === 5 && /^[A-Z]{5}$/.test(w)),
);
allFiveLetterWords = enableFive;
// Answer pool: prefer curated common words that are also in ENABLE;
// supplement with additional ENABLE words up to a healthy pool size.
const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));
answerPool = curated.length >= 200 ? curated : [...enableFive];
console.log(`[words] loaded ${enableFive.size} valid 5-letter words, ${answerPool.length} answer candidates`);
}
loadWordLists();
// ── Router ────────────────────────────────────────────────────────────────────
const router = Router();
// GET /api/words/wordle/start
// Returns a random answer word plus the full valid-word pool for the client.
router.get('/wordle/start', (_req, res) => {
const answer = answerPool[Math.floor(Math.random() * answerPool.length)];
res.json({ answer, validWords: [...allFiveLetterWords] });
});
// POST /api/words/wordle/validate { word: string }
// Quick single-word validation (used as a lightweight alternative to the full pool).
router.post('/wordle/validate', (req, res) => {
const word = (req.body?.word ?? '').trim().toUpperCase();
if (!/^[A-Z]{5}$/.test(word)) {
return res.json({ valid: false });
}
res.json({ valid: allFiveLetterWords.has(word) });
});
export default router;