initial commit
This commit is contained in:
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
FROM eclipse-temurin:21-jdk-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl tzdata
|
||||||
|
|
||||||
|
# Install supercronic for cron scheduling
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/supercronic \
|
||||||
|
https://github.com/aptible/supercronic/releases/download/v0.2.30/supercronic-linux-amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/supercronic
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY src/ /app/src/
|
||||||
|
COPY word-list.txt /app/word-list.txt
|
||||||
|
COPY compile.sh /app/compile.sh
|
||||||
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
COPY crontab /app/crontab
|
||||||
|
|
||||||
|
# Compile Java code
|
||||||
|
RUN chmod +x /app/compile.sh && \
|
||||||
|
mkdir -p /app/target && \
|
||||||
|
javac -d /app/target src/puzzle/*.java
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
RUN mkdir -p /data/puzzles
|
||||||
|
|
||||||
|
ENV TZ=Europe/Amsterdam
|
||||||
|
ENV OUT_DIR=/data/puzzles
|
||||||
|
ENV PUZZLES_PER_DAY=3
|
||||||
|
ENV THEME_FILTER=true
|
||||||
|
ENV THEME_MIN_SCORE=0.6
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
254
README.md
Normal file
254
README.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# Swedish-Style Crossword Puzzle Generator
|
||||||
|
|
||||||
|
A high-performance Java-based puzzle generator with theme-based word filtering and daily automated generation.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Swedish-style crossword puzzles** with arrow clues
|
||||||
|
- **Theme-based word filtering** using semantic similarity graph
|
||||||
|
- **Daily automated generation** via Docker + cron
|
||||||
|
- **JSON export format** compatible with web frontends
|
||||||
|
- **Genetic algorithm** for optimal grid layouts
|
||||||
|
- **Constraint satisfaction** for word placement
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
1. **SwedishGenerator.java** - Core puzzle generation engine
|
||||||
|
- Genetic algorithm for mask generation
|
||||||
|
- CSP solver for word filling
|
||||||
|
- Optimized for Dutch word lists
|
||||||
|
|
||||||
|
2. **ThemeGraph.java** - Theme-based word scoring system
|
||||||
|
- Predefined theme keywords (news, tech, sports, etc.)
|
||||||
|
- Edit distance similarity matching
|
||||||
|
- Automatic theme detection
|
||||||
|
|
||||||
|
3. **DailyGenerator.java** - Daily puzzle automation
|
||||||
|
- Generates themed puzzles
|
||||||
|
- JSON output with metadata
|
||||||
|
- Index file generation
|
||||||
|
|
||||||
|
4. **ExportFormat.java** - Export to standard format
|
||||||
|
- Grid cropping and optimization
|
||||||
|
- Arrow cell calculation
|
||||||
|
- Compatible with existing frontends
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compile
|
||||||
|
./compile.sh
|
||||||
|
|
||||||
|
# Run Main (interactive)
|
||||||
|
java -cp ~/dev/.target puzzle.Main --seed 42 --pop 18 --gens 100
|
||||||
|
|
||||||
|
# Generate daily puzzles
|
||||||
|
java -cp ~/dev/.target puzzle.DailyGenerator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build -t puzzle-generator .
|
||||||
|
|
||||||
|
# Run with docker-compose
|
||||||
|
docker-compose up -d puzzle_gen_java
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker logs -f puzzle_gen_java
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------------------|-------------------|----------------------------------------|
|
||||||
|
| `OUT_DIR` | `/data/puzzles` | Output directory for generated puzzles |
|
||||||
|
| `PUZZLES_PER_DAY` | `3` | Number of puzzles to generate daily |
|
||||||
|
| `WORDS_PATH` | `./word-list.txt` | Path to word list file |
|
||||||
|
| `THEME_FILTER` | `true` | Enable theme-based word filtering |
|
||||||
|
| `THEME_MIN_SCORE` | `0.6` | Minimum theme score (0.0-1.0) |
|
||||||
|
| `LM_STUDIO_BASE_URL` | - | LM Studio URL (future feature) |
|
||||||
|
| `GENERATE_ON_START` | `false` | Generate puzzles on container startup |
|
||||||
|
|
||||||
|
## Theme System
|
||||||
|
|
||||||
|
### Supported Themes
|
||||||
|
|
||||||
|
- `algemeen` - General/common words
|
||||||
|
- `nieuws` - News/politics
|
||||||
|
- `technologie` - Technology
|
||||||
|
- `sport` - Sports
|
||||||
|
- `weer` - Weather/nature
|
||||||
|
- `economie` - Economy
|
||||||
|
- `gezondheid` - Health
|
||||||
|
|
||||||
|
### Theme Filtering
|
||||||
|
|
||||||
|
Words are scored against themes using:
|
||||||
|
1. **Direct matching** - Word is in theme keyword list (score: 1.0)
|
||||||
|
2. **Substring matching** - Partial word overlap (score: 0.7)
|
||||||
|
3. **Edit distance** - Fuzzy matching for variations (score: 0.8-0.9)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
ThemeGraph.filterByTheme(words, "technologie", 0.6);
|
||||||
|
// Returns: COMPUTER, INTERNET, SOFTWARE, DATA, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
### Puzzle JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2025-12-19",
|
||||||
|
"theme": "technologie",
|
||||||
|
"difficulty": 1,
|
||||||
|
"rewards": {
|
||||||
|
"coins": 50,
|
||||||
|
"stars": 2,
|
||||||
|
"hints": 1
|
||||||
|
},
|
||||||
|
"gridv2": [
|
||||||
|
"###COMPUTER###",
|
||||||
|
"#I#O#E#E#O#"
|
||||||
|
],
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"word": "COMPUTER",
|
||||||
|
"clue": "COMPUTER",
|
||||||
|
"startRow": 0,
|
||||||
|
"startCol": 3,
|
||||||
|
"direction": "horizontal",
|
||||||
|
"answer": "COMPUTER",
|
||||||
|
"arrowRow": 0,
|
||||||
|
"arrowCol": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Index JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2025-12-19",
|
||||||
|
"files": [
|
||||||
|
"crossword_2025-12-19_01_technologie.json",
|
||||||
|
"crossword_2025-12-19_02_sport.json",
|
||||||
|
"crossword_2025-12-19_03_nieuws.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scheduling
|
||||||
|
|
||||||
|
Puzzles are generated daily at **3:15 AM** (configurable in `crontab`).
|
||||||
|
|
||||||
|
Edit `crontab` to change schedule:
|
||||||
|
```cron
|
||||||
|
# Daily at 3:15 AM
|
||||||
|
15 3 * * * java -cp /app/target puzzle.DailyGenerator
|
||||||
|
|
||||||
|
# Every 6 hours
|
||||||
|
0 */6 * * * java -cp /app/target puzzle.DailyGenerator
|
||||||
|
|
||||||
|
# Weekly on Monday at 1 AM
|
||||||
|
0 1 * * 1 java -cp /app/target puzzle.DailyGenerator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Word List Format
|
||||||
|
|
||||||
|
Plain text file, one word per line, uppercase A-Z only, 2-8 characters:
|
||||||
|
|
||||||
|
```
|
||||||
|
EU
|
||||||
|
UUR
|
||||||
|
AUTO
|
||||||
|
BOOM
|
||||||
|
COMPUTER
|
||||||
|
INTERNET
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Mask generation**: ~2-5 seconds (genetic algorithm)
|
||||||
|
- **Word filling**: ~5-30 seconds (CSP solver with MRV heuristic)
|
||||||
|
- **Total per puzzle**: ~10-40 seconds
|
||||||
|
|
||||||
|
Optimizations:
|
||||||
|
- Positional indexing for fast candidate lookup
|
||||||
|
- Sorted intersection for constraint checking
|
||||||
|
- No large array allocations during search
|
||||||
|
- Progress bar with real-time stats
|
||||||
|
|
||||||
|
## Integration with LM Studio (Future)
|
||||||
|
|
||||||
|
The system is prepared for LM Studio integration to generate themed clues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
# Set LM_STUDIO_BASE_URL in docker-compose.yml
|
||||||
|
# Container will query LM Studio for contextual clues based on themes
|
||||||
|
```
|
||||||
|
|
||||||
|
This will enhance clues from simple word repetition to semantic hints.
|
||||||
|
|
||||||
|
## Migration from Node.js
|
||||||
|
|
||||||
|
The Java version maintains module-wise compatibility with the Node.js generator:
|
||||||
|
|
||||||
|
| Node.js | Java |
|
||||||
|
|------------------------|-------------------------------------|
|
||||||
|
| `swedish_generator.js` | `SwedishGenerator.java` |
|
||||||
|
| `export_format.js` | `ExportFormat.java` |
|
||||||
|
| `main.js` | `Main.java` + `DailyGenerator.java` |
|
||||||
|
| N/A | `ThemeGraph.java` (new) |
|
||||||
|
|
||||||
|
## Volume Management
|
||||||
|
|
||||||
|
Puzzles are stored in a Docker volume outside the workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default location
|
||||||
|
/var/lib/puzzle-data
|
||||||
|
|
||||||
|
# Custom location
|
||||||
|
export PUZZLE_OUTPUT_DIR=/path/to/puzzles
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View puzzles
|
||||||
|
ls -lh /var/lib/puzzle-data/*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No puzzles generated
|
||||||
|
- Check word list has enough words (minimum 50)
|
||||||
|
- Lower `THEME_MIN_SCORE` if using theme filtering
|
||||||
|
- Increase `PUZZLES_PER_DAY` attempts
|
||||||
|
|
||||||
|
### Container not starting
|
||||||
|
```bash
|
||||||
|
docker logs puzzle_gen_java
|
||||||
|
# Check for compilation errors or missing files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Low quality puzzles
|
||||||
|
- Increase `--gens` parameter (more genetic iterations)
|
||||||
|
- Increase `--pop` parameter (larger population)
|
||||||
|
- Ensure word list has good variety of lengths 2-8
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Authors
|
||||||
|
|
||||||
|
Original Node.js version + Java port with theme system
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
mkdir -p ~/dev/.target
|
TARGET=${1:-~/dev/.target}
|
||||||
javac -d ~/dev/.target src/puzzle/*.java
|
mkdir -p "$TARGET"
|
||||||
|
javac -d "$TARGET" src/puzzle/*.java
|
||||||
|
|||||||
2
crontab
Normal file
2
crontab
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Generate puzzles daily at 3:15 AM
|
||||||
|
15 3 * * * java -cp /app/target puzzle.DailyGenerator >> /var/log/cron.log 2>&1
|
||||||
@@ -32,6 +32,23 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- puzzles_data:/data/puzzles:rw
|
- puzzles_data:/data/puzzles:rw
|
||||||
|
|
||||||
|
puzzle_gen_java:
|
||||||
|
build:
|
||||||
|
context: ${PUZZLE_ROOT_DIR:-/opt/apps/puzzle}
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: puzzle_gen_java
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [ traefik_net ]
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Amsterdam
|
||||||
|
OUT_DIR: /data/puzzles
|
||||||
|
PUZZLES_PER_DAY: "3"
|
||||||
|
LM_STUDIO_BASE_URL: "http://192.168.1.159:1234/v1"
|
||||||
|
THEME_FILTER: "true"
|
||||||
|
THEME_MIN_SCORE: "0.6"
|
||||||
|
volumes:
|
||||||
|
- puzzles_data:/data/puzzles:rw
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
puzzles_data:
|
puzzles_data:
|
||||||
|
|
||||||
|
|||||||
22
docker-entrypoint.sh
Normal file
22
docker-entrypoint.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Puzzle Generator Container Starting ==="
|
||||||
|
echo "Time: $(date)"
|
||||||
|
echo "Output directory: ${OUT_DIR}"
|
||||||
|
echo "Puzzles per day: ${PUZZLES_PER_DAY}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
mkdir -p "${OUT_DIR}"
|
||||||
|
|
||||||
|
# Generate initial puzzle on startup (optional)
|
||||||
|
if [ "${GENERATE_ON_START}" = "true" ]; then
|
||||||
|
echo "Generating initial puzzles..."
|
||||||
|
java -cp /app/target puzzle.DailyGenerator
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start cron scheduler
|
||||||
|
echo "Starting cron scheduler..."
|
||||||
|
exec /usr/local/bin/supercronic /app/crontab
|
||||||
254
src/puzzle/DailyGenerator.java
Normal file
254
src/puzzle/DailyGenerator.java
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package puzzle;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DailyGenerator - Generates daily themed puzzles with JSON output
|
||||||
|
*/
|
||||||
|
public class DailyGenerator {
|
||||||
|
|
||||||
|
private static String env(String name, String defaultValue) {
|
||||||
|
var val = System.getenv(name);
|
||||||
|
return (val == null || val.isEmpty()) ? defaultValue : val;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int envInt(String name, int defaultValue) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(env(name, String.valueOf(defaultValue)));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean envBool(String name, boolean defaultValue) {
|
||||||
|
var val = env(name, String.valueOf(defaultValue));
|
||||||
|
return "true".equalsIgnoreCase(val) || "1".equals(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
var outDir = env("OUT_DIR", "/data/puzzles");
|
||||||
|
var wordsPath = env("WORDS_PATH", "./word-list.txt");
|
||||||
|
var puzzlesPerDay = envInt("PUZZLES_PER_DAY", 3);
|
||||||
|
var seed = envInt("SEED", (int) System.currentTimeMillis());
|
||||||
|
var themeFilter = envBool("THEME_FILTER", true);
|
||||||
|
var themeMinScore = Double.parseDouble(env("THEME_MIN_SCORE", "0.6"));
|
||||||
|
|
||||||
|
var today = LocalDate.now();
|
||||||
|
var dateStr = today.toString();
|
||||||
|
|
||||||
|
System.out.println("=== Daily Puzzle Generator ===");
|
||||||
|
System.out.println("Date: " + dateStr);
|
||||||
|
System.out.println("Output: " + outDir);
|
||||||
|
System.out.println("Puzzles per day: " + puzzlesPerDay);
|
||||||
|
System.out.println("Theme filtering: " + themeFilter);
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
// Load word list
|
||||||
|
SwedishGenerator.Dict dict;
|
||||||
|
try {
|
||||||
|
dict = SwedishGenerator.loadWords(wordsPath);
|
||||||
|
System.out.println("Loaded " + dict.words.size() + " words");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Failed to load words: " + e.getMessage());
|
||||||
|
System.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory
|
||||||
|
try {
|
||||||
|
Files.createDirectories(Paths.get(outDir));
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Failed to create output dir: " + e.getMessage());
|
||||||
|
System.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate puzzles
|
||||||
|
List<String> generatedFiles = new ArrayList<>();
|
||||||
|
var themes = new String[]{ "algemeen", "nieuws", "technologie", "sport", "weer", "economie" };
|
||||||
|
|
||||||
|
for (var i = 1; i <= puzzlesPerDay; i++) {
|
||||||
|
System.out.println("\n--- Generating puzzle " + i + "/" + puzzlesPerDay + " ---");
|
||||||
|
|
||||||
|
// Select theme
|
||||||
|
var theme = themes[new Random(seed + i).nextInt(themes.length)];
|
||||||
|
System.out.println("Theme: " + theme);
|
||||||
|
|
||||||
|
// Filter word list by theme
|
||||||
|
List<String> filteredWords = dict.words;
|
||||||
|
if (themeFilter && !theme.equals("algemeen")) {
|
||||||
|
filteredWords = ThemeGraph.filterByTheme(dict.words, theme, themeMinScore);
|
||||||
|
System.out.println("Filtered to " + filteredWords.size() + " words for theme '" + theme + "'");
|
||||||
|
|
||||||
|
// If too few words, fall back to general
|
||||||
|
if (filteredWords.size() < 50) {
|
||||||
|
System.out.println("Not enough themed words, using general list");
|
||||||
|
filteredWords = dict.words;
|
||||||
|
theme = "algemeen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filtered dict
|
||||||
|
var themedDict = filterDict(dict, filteredWords);
|
||||||
|
|
||||||
|
// Generate puzzle
|
||||||
|
var opts = new Main.Opts();
|
||||||
|
opts.seed = seed + i;
|
||||||
|
opts.pop = 18;
|
||||||
|
opts.gens = 100;
|
||||||
|
opts.tries = 50;
|
||||||
|
opts.wordsPath = wordsPath;
|
||||||
|
|
||||||
|
var result = generateWithFilteredDict(opts, themedDict);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
System.out.println("Failed to generate puzzle " + i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("Generated puzzle with " + result.filled().clueMap.size() + " words");
|
||||||
|
|
||||||
|
// Export to JSON
|
||||||
|
var exported = ExportFormat.exportFormatFromFilled(
|
||||||
|
result, 1, new ExportFormat.Rewards(50, 2, 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write to JSON file
|
||||||
|
var filename = String.format("crossword_%s_%02d_%s.json", dateStr, i, safeSlug(theme));
|
||||||
|
var outputPath = Paths.get(outDir, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var json = toJson(exported, dateStr, theme);
|
||||||
|
Files.writeString(outputPath, json, StandardCharsets.UTF_8);
|
||||||
|
generatedFiles.add(filename);
|
||||||
|
System.out.println("Saved: " + filename);
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Failed to write " + filename + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write index.json
|
||||||
|
try {
|
||||||
|
var indexJson = toIndexJson(dateStr, generatedFiles);
|
||||||
|
Files.writeString(Paths.get(outDir, "index.json"), indexJson, StandardCharsets.UTF_8);
|
||||||
|
System.out.println("\n✓ Generated " + generatedFiles.size() + " puzzles for " + dateStr);
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Failed to write index.json: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SwedishGenerator.Dict filterDict(SwedishGenerator.Dict dict, List<String> allowedWords) {
|
||||||
|
Set<String> allowed = new HashSet<>(allowedWords);
|
||||||
|
var newIndex = new HashMap<Integer, SwedishGenerator.DictEntry>();
|
||||||
|
var newLenCounts = new HashMap<Integer, Integer>();
|
||||||
|
|
||||||
|
for (var word : dict.words) {
|
||||||
|
if (!allowed.contains(word)) continue;
|
||||||
|
|
||||||
|
var L = word.length();
|
||||||
|
newLenCounts.put(L, newLenCounts.getOrDefault(L, 0) + 1);
|
||||||
|
|
||||||
|
var entry = newIndex.get(L);
|
||||||
|
if (entry == null) {
|
||||||
|
entry = new SwedishGenerator.DictEntry(L);
|
||||||
|
newIndex.put(L, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
var idx = entry.words.size();
|
||||||
|
entry.words.add(word);
|
||||||
|
|
||||||
|
for (var i = 0; i < L; i++) {
|
||||||
|
var letter = word.charAt(i) - 'A';
|
||||||
|
if (letter >= 0 && letter < 26) {
|
||||||
|
entry.pos[i][letter].add(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SwedishGenerator.Dict(new ArrayList<>(allowed), newIndex, newLenCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SwedishGenerator.PuzzleResult generateWithFilteredDict(Main.Opts opts, SwedishGenerator.Dict dict) {
|
||||||
|
var rng = new SwedishGenerator.Rng(opts.seed);
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= opts.tries; attempt++) {
|
||||||
|
var mask = SwedishGenerator.generateMask(rng, dict.lenCounts, opts.pop, opts.gens);
|
||||||
|
var filled = SwedishGenerator.fillMask(rng, mask, dict.index, 200, 30000);
|
||||||
|
|
||||||
|
if (filled.ok) {
|
||||||
|
return new SwedishGenerator.PuzzleResult(mask, filled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toJson(ExportFormat.ExportedPuzzle puzzle, String date, String theme) {
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.append("{\n");
|
||||||
|
sb.append(" \"date\": \"").append(escapeJson(date)).append("\",\n");
|
||||||
|
sb.append(" \"theme\": \"").append(escapeJson(theme)).append("\",\n");
|
||||||
|
sb.append(" \"difficulty\": ").append(puzzle.difficulty()).append(",\n");
|
||||||
|
sb.append(" \"rewards\": {\n");
|
||||||
|
sb.append(" \"coins\": ").append(puzzle.rewards().coins()).append(",\n");
|
||||||
|
sb.append(" \"stars\": ").append(puzzle.rewards().stars()).append(",\n");
|
||||||
|
sb.append(" \"hints\": ").append(puzzle.rewards().hints()).append("\n");
|
||||||
|
sb.append(" },\n");
|
||||||
|
sb.append(" \"gridv2\": [\n");
|
||||||
|
for (var i = 0; i < puzzle.gridv2().size(); i++) {
|
||||||
|
sb.append(" \"").append(escapeJson(puzzle.gridv2().get(i))).append("\"");
|
||||||
|
if (i < puzzle.gridv2().size() - 1) sb.append(",");
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
sb.append(" ],\n");
|
||||||
|
sb.append(" \"words\": [\n");
|
||||||
|
for (var i = 0; i < puzzle.words().size(); i++) {
|
||||||
|
var w = puzzle.words().get(i);
|
||||||
|
sb.append(" {\n");
|
||||||
|
sb.append(" \"word\": \"").append(escapeJson(w.word())).append("\",\n");
|
||||||
|
sb.append(" \"clue\": \"").append(escapeJson(w.clue())).append("\",\n");
|
||||||
|
sb.append(" \"startRow\": ").append(w.startRow()).append(",\n");
|
||||||
|
sb.append(" \"startCol\": ").append(w.startCol()).append(",\n");
|
||||||
|
sb.append(" \"direction\": \"").append(escapeJson(w.direction())).append("\",\n");
|
||||||
|
sb.append(" \"answer\": \"").append(escapeJson(w.answer())).append("\",\n");
|
||||||
|
sb.append(" \"arrowRow\": ").append(w.arrowRow()).append(",\n");
|
||||||
|
sb.append(" \"arrowCol\": ").append(w.arrowCol()).append("\n");
|
||||||
|
sb.append(" }");
|
||||||
|
if (i < puzzle.words().size() - 1) sb.append(",");
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
sb.append(" ]\n");
|
||||||
|
sb.append("}\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toIndexJson(String date, List<String> files) {
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.append("{\n");
|
||||||
|
sb.append(" \"date\": \"").append(escapeJson(date)).append("\",\n");
|
||||||
|
sb.append(" \"files\": [\n");
|
||||||
|
for (var i = 0; i < files.size(); i++) {
|
||||||
|
sb.append(" \"").append(escapeJson(files.get(i))).append("\"");
|
||||||
|
if (i < files.size() - 1) sb.append(",");
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
sb.append(" ]\n");
|
||||||
|
sb.append("}\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeJson(String s) {
|
||||||
|
return s.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\t", "\\t");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeSlug(String s) {
|
||||||
|
return s.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package puzzle;
|
package puzzle;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,23 +39,23 @@ public final class ExportFormat {
|
|||||||
|
|
||||||
public static ExportedPuzzle exportFormatFromFilled(SwedishGenerator.PuzzleResult puz, int difficulty, Rewards rewards) {
|
public static ExportedPuzzle exportFormatFromFilled(SwedishGenerator.PuzzleResult puz, int difficulty, Rewards rewards) {
|
||||||
Objects.requireNonNull(puz, "puz");
|
Objects.requireNonNull(puz, "puz");
|
||||||
char[][] g = puz.filled.grid;
|
var g = puz.filled().grid;
|
||||||
int H = g.length;
|
var H = g.length;
|
||||||
int W = g[0].length;
|
var W = g[0].length;
|
||||||
|
|
||||||
// 1) extract "placed" list from all clue digits in the filled grid
|
// 1) extract "placed" list from all clue digits in the filled grid
|
||||||
List<Placed> placed = new ArrayList<>();
|
List<Placed> placed = new ArrayList<>();
|
||||||
Set<String> seen = new HashSet<>();
|
Set<String> seen = new HashSet<>();
|
||||||
|
|
||||||
for (int r = 0; r < H; r++) {
|
for (var r = 0; r < H; r++) {
|
||||||
for (int c = 0; c < W; c++) {
|
for (var c = 0; c < W; c++) {
|
||||||
char ch = g[r][c];
|
var ch = g[r][c];
|
||||||
if (!isDigit(ch)) continue;
|
if (!isDigit(ch)) continue;
|
||||||
|
|
||||||
Placed p = extractPlacedFromClue(g, r, c, ch, 8, 2);
|
var p = extractPlacedFromClue(g, r, c, ch, 8, 2);
|
||||||
if (p == null) continue;
|
if (p == null) continue;
|
||||||
|
|
||||||
String key = p.startRow + "," + p.startCol + ":" + p.direction + ":" + p.word;
|
var key = p.startRow + "," + p.startCol + ":" + p.direction + ":" + p.word;
|
||||||
if (seen.contains(key)) continue;
|
if (seen.contains(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
placed.add(p);
|
placed.add(p);
|
||||||
@@ -64,10 +65,10 @@ public final class ExportFormat {
|
|||||||
// If nothing placed: return full grid mapped to letters/# only
|
// If nothing placed: return full grid mapped to letters/# only
|
||||||
if (placed.isEmpty()) {
|
if (placed.isEmpty()) {
|
||||||
List<String> gridv2 = new ArrayList<>(H);
|
List<String> gridv2 = new ArrayList<>(H);
|
||||||
for (int r = 0; r < H; r++) {
|
for (var chars : g) {
|
||||||
StringBuilder sb = new StringBuilder(W);
|
var sb = new StringBuilder(W);
|
||||||
for (int c = 0; c < W; c++) {
|
for (var c = 0; c < W; c++) {
|
||||||
char ch = g[r][c];
|
var ch = chars[c];
|
||||||
sb.append(isLetter(ch) ? ch : '#');
|
sb.append(isLetter(ch) ? ch : '#');
|
||||||
}
|
}
|
||||||
gridv2.add(sb.toString());
|
gridv2.add(sb.toString());
|
||||||
@@ -77,7 +78,7 @@ public final class ExportFormat {
|
|||||||
|
|
||||||
// 2) bounding box around all word cells + arrow cells, with 1-cell margin
|
// 2) bounding box around all word cells + arrow cells, with 1-cell margin
|
||||||
List<int[]> allCells = new ArrayList<>();
|
List<int[]> allCells = new ArrayList<>();
|
||||||
for (Placed p : placed) {
|
for (var p : placed) {
|
||||||
allCells.addAll(p.cells);
|
allCells.addAll(p.cells);
|
||||||
allCells.add(p.arrow);
|
allCells.add(p.arrow);
|
||||||
}
|
}
|
||||||
@@ -85,7 +86,7 @@ public final class ExportFormat {
|
|||||||
int minR = Integer.MAX_VALUE, minC = Integer.MAX_VALUE;
|
int minR = Integer.MAX_VALUE, minC = Integer.MAX_VALUE;
|
||||||
int maxR = Integer.MIN_VALUE, maxC = Integer.MIN_VALUE;
|
int maxR = Integer.MIN_VALUE, maxC = Integer.MIN_VALUE;
|
||||||
|
|
||||||
for (int[] rc : allCells) {
|
for (var rc : allCells) {
|
||||||
int rr = rc[0], cc = rc[1];
|
int rr = rc[0], cc = rc[1];
|
||||||
minR = Math.min(minR, rr);
|
minR = Math.min(minR, rr);
|
||||||
minC = Math.min(minC, cc);
|
minC = Math.min(minC, cc);
|
||||||
@@ -100,8 +101,8 @@ public final class ExportFormat {
|
|||||||
|
|
||||||
// 3) map of only used letter cells (everything else becomes '#')
|
// 3) map of only used letter cells (everything else becomes '#')
|
||||||
Map<Long, Character> letterAt = new HashMap<>();
|
Map<Long, Character> letterAt = new HashMap<>();
|
||||||
for (Placed p : placed) {
|
for (var p : placed) {
|
||||||
for (int[] rc : p.cells) {
|
for (var rc : p.cells) {
|
||||||
int rr = rc[0], cc = rc[1];
|
int rr = rc[0], cc = rc[1];
|
||||||
if (inBounds(H, W, rr, cc) && isLetter(g[rr][cc])) {
|
if (inBounds(H, W, rr, cc) && isLetter(g[rr][cc])) {
|
||||||
letterAt.put(pack(rr, cc), g[rr][cc]);
|
letterAt.put(pack(rr, cc), g[rr][cc]);
|
||||||
@@ -111,10 +112,10 @@ public final class ExportFormat {
|
|||||||
|
|
||||||
// 4) render gridv2 over cropped bounds (out-of-bounds become '#')
|
// 4) render gridv2 over cropped bounds (out-of-bounds become '#')
|
||||||
List<String> gridv2 = new ArrayList<>(Math.max(0, maxR - minR + 1));
|
List<String> gridv2 = new ArrayList<>(Math.max(0, maxR - minR + 1));
|
||||||
for (int r = minR; r <= maxR; r++) {
|
for (var r = minR; r <= maxR; r++) {
|
||||||
StringBuilder row = new StringBuilder(Math.max(0, maxC - minC + 1));
|
var row = new StringBuilder(Math.max(0, maxC - minC + 1));
|
||||||
for (int c = minC; c <= maxC; c++) {
|
for (var c = minC; c <= maxC; c++) {
|
||||||
Character ch = letterAt.get(pack(r, c));
|
var ch = letterAt.get(pack(r, c));
|
||||||
row.append(ch != null ? ch : '#');
|
row.append(ch != null ? ch : '#');
|
||||||
}
|
}
|
||||||
gridv2.add(row.toString());
|
gridv2.add(row.toString());
|
||||||
@@ -122,7 +123,7 @@ public final class ExportFormat {
|
|||||||
|
|
||||||
// 5) words output with cropped coordinates
|
// 5) words output with cropped coordinates
|
||||||
List<WordOut> wordsOut = new ArrayList<>(placed.size());
|
List<WordOut> wordsOut = new ArrayList<>(placed.size());
|
||||||
for (Placed p : placed) {
|
for (var p : placed) {
|
||||||
wordsOut.add(new WordOut(
|
wordsOut.add(new WordOut(
|
||||||
p.word,
|
p.word,
|
||||||
p.clue, // placeholder = word (same as JS)
|
p.clue, // placeholder = word (same as JS)
|
||||||
@@ -148,7 +149,7 @@ public final class ExportFormat {
|
|||||||
*/
|
*/
|
||||||
private static Placed extractPlacedFromClue(char[][] g, int r, int c, char d, int maxLen, int minLen) {
|
private static Placed extractPlacedFromClue(char[][] g, int r, int c, char d, int maxLen, int minLen) {
|
||||||
int H = g.length, W = g[0].length;
|
int H = g.length, W = g[0].length;
|
||||||
int di = d - '0';
|
var di = d - '0';
|
||||||
int dr = DIRS[di][0], dc = DIRS[di][1];
|
int dr = DIRS[di][0], dc = DIRS[di][1];
|
||||||
|
|
||||||
// collect letter cells in ORIGINAL direction away from the clue
|
// collect letter cells in ORIGINAL direction away from the clue
|
||||||
@@ -180,14 +181,14 @@ public final class ExportFormat {
|
|||||||
arrowCol = c;
|
arrowCol = c;
|
||||||
} else if (d == '4') { // left -> canonical right
|
} else if (d == '4') { // left -> canonical right
|
||||||
direction = "horizontal";
|
direction = "horizontal";
|
||||||
int[] farLeft = cells.get(cells.size() - 1);
|
var farLeft = cells.get(cells.size() - 1);
|
||||||
startRow = farLeft[0];
|
startRow = farLeft[0];
|
||||||
startCol = farLeft[1];
|
startCol = farLeft[1];
|
||||||
arrowRow = startRow;
|
arrowRow = startRow;
|
||||||
arrowCol = startCol - 1;
|
arrowCol = startCol - 1;
|
||||||
} else if (d == '1') { // up -> canonical down
|
} else if (d == '1') { // up -> canonical down
|
||||||
direction = "vertical";
|
direction = "vertical";
|
||||||
int[] topMost = cells.get(cells.size() - 1);
|
var topMost = cells.get(cells.size() - 1);
|
||||||
startRow = topMost[0];
|
startRow = topMost[0];
|
||||||
startCol = topMost[1];
|
startCol = topMost[1];
|
||||||
arrowRow = startRow - 1;
|
arrowRow = startRow - 1;
|
||||||
@@ -197,32 +198,32 @@ public final class ExportFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read word from grid in canonical order (right/down)
|
// Read word from grid in canonical order (right/down)
|
||||||
StringBuilder wordChars = new StringBuilder();
|
var wordChars = new StringBuilder();
|
||||||
if ("horizontal".equals(direction)) {
|
if ("horizontal".equals(direction)) {
|
||||||
for (int i = 0; i < cells.size(); i++) {
|
for (var i = 0; i < cells.size(); i++) {
|
||||||
int cc2 = startCol + i;
|
var cc2 = startCol + i;
|
||||||
char ch = (inBounds(H, W, startRow, cc2) ? g[startRow][cc2] : '#');
|
var ch = (inBounds(H, W, startRow, cc2) ? g[startRow][cc2] : '#');
|
||||||
if (!isLetter(ch)) break;
|
if (!isLetter(ch)) break;
|
||||||
wordChars.append(ch);
|
wordChars.append(ch);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (int i = 0; i < cells.size(); i++) {
|
for (var i = 0; i < cells.size(); i++) {
|
||||||
int rr2 = startRow + i;
|
var rr2 = startRow + i;
|
||||||
char ch = (inBounds(H, W, rr2, startCol) ? g[rr2][startCol] : '#');
|
var ch = (inBounds(H, W, rr2, startCol) ? g[rr2][startCol] : '#');
|
||||||
if (!isLetter(ch)) break;
|
if (!isLetter(ch)) break;
|
||||||
wordChars.append(ch);
|
wordChars.append(ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String word = wordChars.toString();
|
var word = wordChars.toString();
|
||||||
if (word.length() < minLen || word.length() > maxLen) return null;
|
if (word.length() < minLen || word.length() > maxLen) return null;
|
||||||
|
|
||||||
// Build exact used cells (only for actual word length)
|
// Build exact used cells (only for actual word length)
|
||||||
List<int[]> used = new ArrayList<>(word.length());
|
List<int[]> used = new ArrayList<>(word.length());
|
||||||
if ("horizontal".equals(direction)) {
|
if ("horizontal".equals(direction)) {
|
||||||
for (int i = 0; i < word.length(); i++) used.add(new int[]{ startRow, startCol + i });
|
for (var i = 0; i < word.length(); i++) used.add(new int[]{ startRow, startCol + i });
|
||||||
} else {
|
} else {
|
||||||
for (int i = 0; i < word.length(); i++) used.add(new int[]{ startRow + i, startCol });
|
for (var i = 0; i < word.length(); i++) used.add(new int[]{ startRow + i, startCol });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Placed(
|
return new Placed(
|
||||||
@@ -246,84 +247,25 @@ public final class ExportFormat {
|
|||||||
|
|
||||||
// ---------- Data models ----------
|
// ---------- Data models ----------
|
||||||
|
|
||||||
private static final class Placed {
|
/**
|
||||||
|
* @param direction "horizontal" | "vertical"
|
||||||
final String word;
|
* @param cells word cells
|
||||||
final String clue;
|
* @param arrow [arrowRow, arrowCol] */
|
||||||
final int startRow, startCol;
|
private record Placed(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol, List<int[]> cells, int[] arrow) {
|
||||||
final String direction; // "horizontal" | "vertical"
|
|
||||||
final String answer;
|
|
||||||
final int arrowRow, arrowCol;
|
|
||||||
final List<int[]> cells; // word cells
|
|
||||||
final int[] arrow; // [arrowRow, arrowCol]
|
|
||||||
|
|
||||||
Placed(String word, String clue, int startRow, int startCol, String direction, String answer,
|
|
||||||
int arrowRow, int arrowCol, List<int[]> cells, int[] arrow) {
|
|
||||||
this.word = word;
|
|
||||||
this.clue = clue;
|
|
||||||
this.startRow = startRow;
|
|
||||||
this.startCol = startCol;
|
|
||||||
this.direction = direction;
|
|
||||||
this.answer = answer;
|
|
||||||
this.arrowRow = arrowRow;
|
|
||||||
this.arrowCol = arrowCol;
|
|
||||||
this.cells = cells;
|
|
||||||
this.arrow = arrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class Rewards {
|
|
||||||
|
|
||||||
public final int coins;
|
|
||||||
public final int stars;
|
|
||||||
public final int hints;
|
|
||||||
|
|
||||||
public Rewards(int coins, int stars, int hints) {
|
|
||||||
this.coins = coins;
|
|
||||||
this.stars = stars;
|
|
||||||
this.hints = hints;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class WordOut {
|
|
||||||
|
|
||||||
public final String word;
|
|
||||||
public final String clue;
|
|
||||||
public final int startRow;
|
|
||||||
public final int startCol;
|
|
||||||
public final String direction; // "horizontal" | "vertical"
|
|
||||||
public final String answer;
|
|
||||||
public final int arrowRow;
|
|
||||||
public final int arrowCol;
|
|
||||||
|
|
||||||
public WordOut(String word, String clue, int startRow, int startCol, String direction,
|
|
||||||
String answer, int arrowRow, int arrowCol) {
|
|
||||||
this.word = word;
|
|
||||||
this.clue = clue;
|
|
||||||
this.startRow = startRow;
|
|
||||||
this.startCol = startCol;
|
|
||||||
this.direction = direction;
|
|
||||||
this.answer = answer;
|
|
||||||
this.arrowRow = arrowRow;
|
|
||||||
this.arrowCol = arrowCol;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class ExportedPuzzle {
|
|
||||||
|
|
||||||
public final List<String> gridv2;
|
|
||||||
public final List<WordOut> words;
|
|
||||||
public final int difficulty;
|
|
||||||
public final Rewards rewards;
|
|
||||||
|
|
||||||
public ExportedPuzzle(List<String> gridv2, List<WordOut> words, int difficulty, Rewards rewards) {
|
|
||||||
this.gridv2 = gridv2;
|
|
||||||
this.words = words;
|
|
||||||
this.difficulty = difficulty;
|
|
||||||
this.rewards = rewards;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Tiny demo (optional) ----------
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record Rewards(int coins, int stars, int hints) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param direction "horizontal" | "vertical" */
|
||||||
|
public record WordOut(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ExportedPuzzle(List<String> gridv2, List<WordOut> words, int difficulty, Rewards rewards) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,20 +53,20 @@ public class Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("\n=== GENERATED MASK ===");
|
System.out.println("\n=== GENERATED MASK ===");
|
||||||
System.out.println(SwedishGenerator.gridToString(res.mask));
|
System.out.println(SwedishGenerator.gridToString(res.mask()));
|
||||||
|
|
||||||
System.out.println("\n=== FILLED PUZZLE (RAW) ===");
|
System.out.println("\n=== FILLED PUZZLE (RAW) ===");
|
||||||
System.out.println(SwedishGenerator.gridToString(res.filled.grid));
|
System.out.println(SwedishGenerator.gridToString(res.filled().grid));
|
||||||
|
|
||||||
System.out.println("\n=== FILLED PUZZLE (HUMAN) ===");
|
System.out.println("\n=== FILLED PUZZLE (HUMAN) ===");
|
||||||
System.out.println(SwedishGenerator.renderHuman(res.filled.grid));
|
System.out.println(SwedishGenerator.renderHuman(res.filled().grid));
|
||||||
var out = ExportFormat.exportFormatFromFilled(res, 1, new ExportFormat.Rewards(50, 2, 1));
|
var out = ExportFormat.exportFormatFromFilled(res, 1, new ExportFormat.Rewards(50, 2, 1));
|
||||||
System.out.println("gridv2:");
|
System.out.println("gridv2:");
|
||||||
for (String row : out.gridv2) System.out.println(row);
|
for (String row : out.gridv2()) System.out.println(row);
|
||||||
System.out.println("words: " + out.words.size());
|
System.out.println("words: " + out.words().size());
|
||||||
for (var w : out.words) {
|
for (var w : out.words()) {
|
||||||
System.out.printf("%s %s start=(%d,%d) arrow=(%d,%d)%n",
|
System.out.printf("%s %s start=(%d,%d) arrow=(%d,%d)%n",
|
||||||
w.word, w.direction, w.startRow, w.startCol, w.arrowRow, w.arrowCol);
|
w.word(), w.direction(), w.startRow(), w.startCol(), w.arrowRow(), w.arrowCol());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,18 +31,18 @@ public class SwedishGenerator {
|
|||||||
static boolean isLetter(char ch) { return ch >= 'A' && ch <= 'Z'; }
|
static boolean isLetter(char ch) { return ch >= 'A' && ch <= 'Z'; }
|
||||||
static boolean isLetterCell(char ch) { return ch == '#' || isLetter(ch); }
|
static boolean isLetterCell(char ch) { return ch == '#' || isLetter(ch); }
|
||||||
|
|
||||||
|
|
||||||
// ---------------- RNG (xorshift32) ----------------
|
// ---------------- RNG (xorshift32) ----------------
|
||||||
|
|
||||||
static final class Rng {
|
static final class Rng {
|
||||||
|
|
||||||
private int x;
|
private int x;
|
||||||
Rng(int seed) {
|
Rng(int seed) {
|
||||||
int s = seed;
|
var s = seed;
|
||||||
if (s == 0) s = 1;
|
if (s == 0) s = 1;
|
||||||
this.x = s;
|
this.x = s;
|
||||||
}
|
}
|
||||||
int nextU32() {
|
int nextU32() {
|
||||||
int y = x;
|
var y = x;
|
||||||
y ^= (y << 13);
|
y ^= (y << 13);
|
||||||
y ^= (y >>> 17);
|
y ^= (y >>> 17);
|
||||||
y ^= (y << 5);
|
y ^= (y << 5);
|
||||||
@@ -50,13 +50,13 @@ public class SwedishGenerator {
|
|||||||
return y;
|
return y;
|
||||||
}
|
}
|
||||||
int randint(int min, int max) { // inclusive
|
int randint(int min, int max) { // inclusive
|
||||||
int r = nextU32();
|
var r = nextU32();
|
||||||
long u = (r & 0xFFFFFFFFL);
|
var u = (r & 0xFFFFFFFFL);
|
||||||
long range = (long) max - (long) min + 1L;
|
var range = (long) max - (long) min + 1L;
|
||||||
return (int) (min + (u % range));
|
return (int) (min + (u % range));
|
||||||
}
|
}
|
||||||
double nextFloat() {
|
double nextFloat() {
|
||||||
long u = nextU32() & 0xFFFFFFFFL;
|
var u = nextU32() & 0xFFFFFFFFL;
|
||||||
return u / 4294967295.0; // 0xFFFFFFFF
|
return u / 4294967295.0; // 0xFFFFFFFF
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,20 +66,20 @@ public class SwedishGenerator {
|
|||||||
// ---------------- Grid helpers ----------------
|
// ---------------- Grid helpers ----------------
|
||||||
|
|
||||||
static char[][] makeEmptyGrid() {
|
static char[][] makeEmptyGrid() {
|
||||||
char[][] g = new char[H][W];
|
var g = new char[H][W];
|
||||||
for (int r = 0; r < H; r++) Arrays.fill(g[r], '#');
|
for (var r = 0; r < H; r++) Arrays.fill(g[r], '#');
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
static char[][] deepCopyGrid(char[][] g) {
|
static char[][] deepCopyGrid(char[][] g) {
|
||||||
char[][] out = new char[H][W];
|
var out = new char[H][W];
|
||||||
for (int r = 0; r < H; r++) out[r] = Arrays.copyOf(g[r], W);
|
for (var r = 0; r < H; r++) out[r] = Arrays.copyOf(g[r], W);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String gridToString(char[][] g) {
|
static String gridToString(char[][] g) {
|
||||||
StringBuilder sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
for (int r = 0; r < H; r++) {
|
for (var r = 0; r < H; r++) {
|
||||||
if (r > 0) sb.append('\n');
|
if (r > 0) sb.append('\n');
|
||||||
sb.append(g[r]);
|
sb.append(g[r]);
|
||||||
}
|
}
|
||||||
@@ -87,11 +87,11 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String renderHuman(char[][] g) {
|
static String renderHuman(char[][] g) {
|
||||||
StringBuilder sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
for (int r = 0; r < H; r++) {
|
for (var r = 0; r < H; r++) {
|
||||||
if (r > 0) sb.append('\n');
|
if (r > 0) sb.append('\n');
|
||||||
for (int c = 0; c < W; c++) {
|
for (var c = 0; c < W; c++) {
|
||||||
char ch = g[r][c];
|
var ch = g[r][c];
|
||||||
sb.append(isDigit(ch) ? ' ' : ch);
|
sb.append(isDigit(ch) ? ' ' : ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +101,7 @@ public class SwedishGenerator {
|
|||||||
// ---------------- Words / index ----------------
|
// ---------------- Words / index ----------------
|
||||||
|
|
||||||
static final class IntList {
|
static final class IntList {
|
||||||
|
|
||||||
int[] a = new int[8];
|
int[] a = new int[8];
|
||||||
int n = 0;
|
int n = 0;
|
||||||
void add(int v) {
|
void add(int v) {
|
||||||
@@ -112,17 +113,19 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static final class DictEntry {
|
static final class DictEntry {
|
||||||
|
|
||||||
final ArrayList<String> words = new ArrayList<>();
|
final ArrayList<String> words = new ArrayList<>();
|
||||||
final IntList[][] pos; // pos[i][letter] -> indices (sorted by insertion)
|
final IntList[][] pos; // pos[i][letter] -> indices (sorted by insertion)
|
||||||
DictEntry(int L) {
|
DictEntry(int L) {
|
||||||
pos = new IntList[L][26];
|
pos = new IntList[L][26];
|
||||||
for (int i = 0; i < L; i++) {
|
for (var i = 0; i < L; i++) {
|
||||||
for (int j = 0; j < 26; j++) pos[i][j] = new IntList();
|
for (var j = 0; j < 26; j++) pos[i][j] = new IntList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class Dict {
|
static final class Dict {
|
||||||
|
|
||||||
final ArrayList<String> words;
|
final ArrayList<String> words;
|
||||||
final HashMap<Integer, DictEntry> index; // len -> DictEntry
|
final HashMap<Integer, DictEntry> index; // len -> DictEntry
|
||||||
final HashMap<Integer, Integer> lenCounts; // len -> count
|
final HashMap<Integer, Integer> lenCounts; // len -> count
|
||||||
@@ -141,30 +144,30 @@ public class SwedishGenerator {
|
|||||||
raw = "EU\nUUR\nAUTO\nBOOM\nHUIS\nKAT\nZEE\nRODE\nDRAAD\nKENNIS\nNETWERK\nPAKTE\n";
|
raw = "EU\nUUR\nAUTO\nBOOM\nHUIS\nKAT\nZEE\nRODE\nDRAAD\nKENNIS\nNETWERK\nPAKTE\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
ArrayList<String> words = new ArrayList<>();
|
var words = new ArrayList<String>();
|
||||||
for (String line : raw.split("\\R")) {
|
for (var line : raw.split("\\R")) {
|
||||||
String s = line.trim().toUpperCase(Locale.ROOT);
|
var s = line.trim().toUpperCase(Locale.ROOT);
|
||||||
if (s.matches("^[A-Z]{2,8}$")) words.add(s);
|
if (s.matches("^[A-Z]{2,8}$")) words.add(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
HashMap<Integer, DictEntry> index = new HashMap<>();
|
var index = new HashMap<Integer, DictEntry>();
|
||||||
HashMap<Integer, Integer> lenCounts = new HashMap<>();
|
var lenCounts = new HashMap<Integer, Integer>();
|
||||||
|
|
||||||
for (String w : words) {
|
for (var w : words) {
|
||||||
int L = w.length();
|
var L = w.length();
|
||||||
lenCounts.put(L, lenCounts.getOrDefault(L, 0) + 1);
|
lenCounts.put(L, lenCounts.getOrDefault(L, 0) + 1);
|
||||||
|
|
||||||
DictEntry entry = index.get(L);
|
var entry = index.get(L);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
entry = new DictEntry(L);
|
entry = new DictEntry(L);
|
||||||
index.put(L, entry);
|
index.put(L, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
int idx = entry.words.size();
|
var idx = entry.words.size();
|
||||||
entry.words.add(w);
|
entry.words.add(w);
|
||||||
|
|
||||||
for (int i = 0; i < L; i++) {
|
for (var i = 0; i < L; i++) {
|
||||||
int letter = w.charAt(i) - 'A';
|
var letter = w.charAt(i) - 'A';
|
||||||
if (letter >= 0 && letter < 26) entry.pos[i][letter].add(idx);
|
if (letter >= 0 && letter < 26) entry.pos[i][letter].add(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,31 +176,35 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int[] intersectSorted(int[] a, int aLen, int[] b, int bLen) {
|
static int[] intersectSorted(int[] a, int aLen, int[] b, int bLen) {
|
||||||
int[] out = new int[Math.min(aLen, bLen)];
|
var out = new int[Math.min(aLen, bLen)];
|
||||||
int i = 0, j = 0, k = 0;
|
int i = 0, j = 0, k = 0;
|
||||||
while (i < aLen && j < bLen) {
|
while (i < aLen && j < bLen) {
|
||||||
int x = a[i], y = b[j];
|
int x = a[i], y = b[j];
|
||||||
if (x == y) { out[k++] = x; i++; j++; }
|
if (x == y) {
|
||||||
else if (x < y) i++;
|
out[k++] = x;
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (x < y) i++;
|
||||||
else j++;
|
else j++;
|
||||||
}
|
}
|
||||||
return Arrays.copyOf(out, k);
|
return Arrays.copyOf(out, k);
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class CandidateInfo {
|
static final class CandidateInfo {
|
||||||
|
|
||||||
int[] indices; // null => unconstrained
|
int[] indices; // null => unconstrained
|
||||||
int count;
|
int count;
|
||||||
}
|
}
|
||||||
|
|
||||||
static CandidateInfo candidateInfoForPattern(DictEntry entry, char[] pattern /* 0 means null */) {
|
static CandidateInfo candidateInfoForPattern(DictEntry entry, char[] pattern /* 0 means null */) {
|
||||||
ArrayList<IntList> lists = new ArrayList<>();
|
var lists = new ArrayList<IntList>();
|
||||||
for (int i = 0; i < pattern.length; i++) {
|
for (var i = 0; i < pattern.length; i++) {
|
||||||
char ch = pattern[i];
|
var ch = pattern[i];
|
||||||
if (ch != 0 && isLetter(ch)) {
|
if (ch != 0 && isLetter(ch)) {
|
||||||
lists.add(entry.pos[i][ch - 'A']);
|
lists.add(entry.pos[i][ch - 'A']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CandidateInfo ci = new CandidateInfo();
|
var ci = new CandidateInfo();
|
||||||
if (lists.isEmpty()) {
|
if (lists.isEmpty()) {
|
||||||
ci.indices = null;
|
ci.indices = null;
|
||||||
ci.count = entry.words.size();
|
ci.count = entry.words.size();
|
||||||
@@ -206,14 +213,14 @@ public class SwedishGenerator {
|
|||||||
|
|
||||||
lists.sort(Comparator.comparingInt(IntList::size));
|
lists.sort(Comparator.comparingInt(IntList::size));
|
||||||
|
|
||||||
IntList first = lists.get(0);
|
var first = lists.get(0);
|
||||||
int[] cur = Arrays.copyOf(first.data(), first.size());
|
var cur = Arrays.copyOf(first.data(), first.size());
|
||||||
int curLen = cur.length;
|
var curLen = cur.length;
|
||||||
|
|
||||||
for (int k = 1; k < lists.size(); k++) {
|
for (var k = 1; k < lists.size(); k++) {
|
||||||
IntList nxt = lists.get(k);
|
var nxt = lists.get(k);
|
||||||
int[] nextArr = nxt.data();
|
var nextArr = nxt.data();
|
||||||
int nextLen = nxt.size();
|
var nextLen = nxt.size();
|
||||||
cur = intersectSorted(cur, curLen, nextArr, nextLen);
|
cur = intersectSorted(cur, curLen, nextArr, nextLen);
|
||||||
curLen = cur.length;
|
curLen = cur.length;
|
||||||
if (curLen == 0) break;
|
if (curLen == 0) break;
|
||||||
@@ -227,38 +234,42 @@ public class SwedishGenerator {
|
|||||||
// ---------------- Slots ----------------
|
// ---------------- Slots ----------------
|
||||||
|
|
||||||
static final class Slot {
|
static final class Slot {
|
||||||
|
|
||||||
final int clueR, clueC;
|
final int clueR, clueC;
|
||||||
final char dir; // '1'..'4'
|
final char dir; // '1'..'4'
|
||||||
final int[] rs, cs; // cells
|
final int[] rs, cs; // cells
|
||||||
final int len;
|
final int len;
|
||||||
Slot(int clueR, int clueC, char dir, int[] rs, int[] cs) {
|
Slot(int clueR, int clueC, char dir, int[] rs, int[] cs) {
|
||||||
this.clueR = clueR; this.clueC = clueC; this.dir = dir;
|
this.clueR = clueR;
|
||||||
this.rs = rs; this.cs = cs;
|
this.clueC = clueC;
|
||||||
|
this.dir = dir;
|
||||||
|
this.rs = rs;
|
||||||
|
this.cs = cs;
|
||||||
this.len = rs.length;
|
this.len = rs.length;
|
||||||
}
|
}
|
||||||
String key() { return clueR + "," + clueC + ":" + dir; }
|
String key() { return clueR + "," + clueC + ":" + dir; }
|
||||||
}
|
}
|
||||||
|
|
||||||
static ArrayList<Slot> extractSlots(char[][] grid) {
|
static ArrayList<Slot> extractSlots(char[][] grid) {
|
||||||
ArrayList<Slot> slots = new ArrayList<>();
|
var slots = new ArrayList<Slot>();
|
||||||
for (int r = 0; r < H; r++) {
|
for (var r = 0; r < H; r++) {
|
||||||
for (int c = 0; c < W; c++) {
|
for (var c = 0; c < W; c++) {
|
||||||
char d = grid[r][c];
|
var d = grid[r][c];
|
||||||
if (!isDigit(d)) continue;
|
if (!isDigit(d)) continue;
|
||||||
|
|
||||||
int di = d - '0';
|
var di = d - '0';
|
||||||
int dr = DIRS[di][0], dc = DIRS[di][1];
|
int dr = DIRS[di][0], dc = DIRS[di][1];
|
||||||
|
|
||||||
int rr = r + dr, cc = c + dc;
|
int rr = r + dr, cc = c + dc;
|
||||||
if (rr < 0 || rr >= H || cc < 0 || cc >= W) continue;
|
if (rr < 0 || rr >= H || cc < 0 || cc >= W) continue;
|
||||||
if (!isLetterCell(grid[rr][cc])) continue;
|
if (!isLetterCell(grid[rr][cc])) continue;
|
||||||
|
|
||||||
int[] rs = new int[MAX_LEN + 1]; // allow MAX_LEN+1 like JS loop
|
var rs = new int[MAX_LEN + 1]; // allow MAX_LEN+1 like JS loop
|
||||||
int[] cs = new int[MAX_LEN + 1];
|
var cs = new int[MAX_LEN + 1];
|
||||||
int n = 0;
|
var n = 0;
|
||||||
|
|
||||||
while (rr >= 0 && rr < H && cc >= 0 && cc < W) {
|
while (rr >= 0 && rr < H && cc >= 0 && cc < W) {
|
||||||
char ch = grid[rr][cc];
|
var ch = grid[rr][cc];
|
||||||
if (!isLetterCell(ch)) break;
|
if (!isLetterCell(ch)) break;
|
||||||
rs[n] = rr;
|
rs[n] = rr;
|
||||||
cs[n] = cc;
|
cs[n] = cc;
|
||||||
@@ -275,10 +286,10 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static boolean hasRoomForClue(char[][] grid, int r, int c, char d) {
|
static boolean hasRoomForClue(char[][] grid, int r, int c, char d) {
|
||||||
int di = d - '0';
|
var di = d - '0';
|
||||||
int dr = DIRS[di][0], dc = DIRS[di][1];
|
int dr = DIRS[di][0], dc = DIRS[di][1];
|
||||||
int rr = r + dr, cc = c + dc;
|
int rr = r + dr, cc = c + dc;
|
||||||
int run = 0;
|
var run = 0;
|
||||||
while (rr >= 0 && rr < H && cc >= 0 && cc < W && isLetterCell(grid[rr][cc]) && run < MAX_LEN) {
|
while (rr >= 0 && rr < H && cc >= 0 && cc < W && isLetterCell(grid[rr][cc]) && run < MAX_LEN) {
|
||||||
run++;
|
run++;
|
||||||
rr += dr;
|
rr += dr;
|
||||||
@@ -292,20 +303,20 @@ public class SwedishGenerator {
|
|||||||
static long maskFitness(char[][] grid, HashMap<Integer, Integer> lenCounts) {
|
static long maskFitness(char[][] grid, HashMap<Integer, Integer> lenCounts) {
|
||||||
long penalty = 0;
|
long penalty = 0;
|
||||||
|
|
||||||
int clueCount = 0;
|
var clueCount = 0;
|
||||||
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) if (isDigit(grid[r][c])) clueCount++;
|
for (var r = 0; r < H; r++) for (var c = 0; c < W; c++) if (isDigit(grid[r][c])) clueCount++;
|
||||||
|
|
||||||
int targetClues = (int)Math.round(W * H * 0.25); // ~18
|
var targetClues = (int) Math.round(W * H * 0.25); // ~18
|
||||||
penalty += 8L * Math.abs(clueCount - targetClues);
|
penalty += 8L * Math.abs(clueCount - targetClues);
|
||||||
|
|
||||||
ArrayList<Slot> slots = extractSlots(grid);
|
var slots = extractSlots(grid);
|
||||||
if (slots.isEmpty()) return 1_000_000_000L;
|
if (slots.isEmpty()) return 1_000_000_000L;
|
||||||
|
|
||||||
int[][] covH = new int[H][W];
|
var covH = new int[H][W];
|
||||||
int[][] covV = new int[H][W];
|
var covV = new int[H][W];
|
||||||
|
|
||||||
for (Slot s : slots) {
|
for (var s : slots) {
|
||||||
boolean horiz = (s.dir == '2' || s.dir == '4');
|
var horiz = (s.dir == '2' || s.dir == '4');
|
||||||
|
|
||||||
if (s.len < MIN_LEN) penalty += 8000;
|
if (s.len < MIN_LEN) penalty += 8000;
|
||||||
if (s.len > MAX_LEN) penalty += 8000 + (long) (s.len - MAX_LEN) * 500L;
|
if (s.len > MAX_LEN) penalty += 8000 + (long) (s.len - MAX_LEN) * 500L;
|
||||||
@@ -314,45 +325,46 @@ public class SwedishGenerator {
|
|||||||
if (!lenCounts.containsKey(s.len)) penalty += 12000;
|
if (!lenCounts.containsKey(s.len)) penalty += 12000;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < s.len; i++) {
|
for (var i = 0; i < s.len; i++) {
|
||||||
int r = s.rs[i], c = s.cs[i];
|
int r = s.rs[i], c = s.cs[i];
|
||||||
if (horiz) covH[r][c] += 1;
|
if (horiz) covH[r][c] += 1;
|
||||||
else covV[r][c] += 1;
|
else covV[r][c] += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) {
|
for (var r = 0; r < H; r++)
|
||||||
|
for (var c = 0; c < W; c++) {
|
||||||
if (!isLetterCell(grid[r][c])) continue;
|
if (!isLetterCell(grid[r][c])) continue;
|
||||||
int h = covH[r][c], v = covV[r][c];
|
int h = covH[r][c], v = covV[r][c];
|
||||||
if (h == 0 && v == 0) penalty += 1500;
|
if (h == 0 && v == 0) penalty += 1500;
|
||||||
else if (h > 0 && v > 0) { /* ok */ }
|
else if (h > 0 && v > 0) { /* ok */ } else if (h + v == 1) penalty += 200;
|
||||||
else if (h + v == 1) penalty += 200;
|
|
||||||
else penalty += 600;
|
else penalty += 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
// clue clustering (8-connected)
|
// clue clustering (8-connected)
|
||||||
boolean[][] seen = new boolean[H][W];
|
var seen = new boolean[H][W];
|
||||||
int[] stack = new int[W * H];
|
var stack = new int[W * H];
|
||||||
int sp;
|
int sp;
|
||||||
int[][] nbrs8 = {
|
var nbrs8 = new int[][]{
|
||||||
{ -1, -1 }, { -1, 0 }, { -1, 1 },
|
{ -1, -1 }, { -1, 0 }, { -1, 1 },
|
||||||
{ 0, -1 }, { 0, 1 },
|
{ 0, -1 }, { 0, 1 },
|
||||||
{ 1, -1 }, { 1, 0 }, { 1, 1 }
|
{ 1, -1 }, { 1, 0 }, { 1, 1 }
|
||||||
};
|
};
|
||||||
|
|
||||||
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) {
|
for (var r = 0; r < H; r++)
|
||||||
|
for (var c = 0; c < W; c++) {
|
||||||
if (!isDigit(grid[r][c]) || seen[r][c]) continue;
|
if (!isDigit(grid[r][c]) || seen[r][c]) continue;
|
||||||
sp = 0;
|
sp = 0;
|
||||||
stack[sp++] = r * W + c;
|
stack[sp++] = r * W + c;
|
||||||
seen[r][c] = true;
|
seen[r][c] = true;
|
||||||
int size = 0;
|
var size = 0;
|
||||||
|
|
||||||
while (sp > 0) {
|
while (sp > 0) {
|
||||||
int p = stack[--sp];
|
var p = stack[--sp];
|
||||||
int x = p / W, y = p % W;
|
int x = p / W, y = p % W;
|
||||||
size++;
|
size++;
|
||||||
|
|
||||||
for (int[] d : nbrs8) {
|
for (var d : nbrs8) {
|
||||||
int nx = x + d[0], ny = y + d[1];
|
int nx = x + d[0], ny = y + d[1];
|
||||||
if (nx < 0 || nx >= H || ny < 0 || ny >= W) continue;
|
if (nx < 0 || nx >= H || ny < 0 || ny >= W) continue;
|
||||||
if (seen[nx][ny]) continue;
|
if (seen[nx][ny]) continue;
|
||||||
@@ -366,13 +378,17 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dead-end-ish letter cell (3+ walls)
|
// dead-end-ish letter cell (3+ walls)
|
||||||
int[][] nbrs4 = {{-1,0},{1,0},{0,-1},{0,1}};
|
var nbrs4 = new int[][]{ { -1, 0 }, { 1, 0 }, { 0, -1 }, { 0, 1 } };
|
||||||
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) {
|
for (var r = 0; r < H; r++)
|
||||||
|
for (var c = 0; c < W; c++) {
|
||||||
if (!isLetterCell(grid[r][c])) continue;
|
if (!isLetterCell(grid[r][c])) continue;
|
||||||
int walls = 0;
|
var walls = 0;
|
||||||
for (int[] d : nbrs4) {
|
for (var d : nbrs4) {
|
||||||
int rr = r + d[0], cc = c + d[1];
|
int rr = r + d[0], cc = c + d[1];
|
||||||
if (rr < 0 || rr >= H || cc < 0 || cc >= W) { walls++; continue; }
|
if (rr < 0 || rr >= H || cc < 0 || cc >= W) {
|
||||||
|
walls++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!isLetterCell(grid[rr][cc])) walls++;
|
if (!isLetterCell(grid[rr][cc])) walls++;
|
||||||
}
|
}
|
||||||
if (walls >= 3) penalty += 400;
|
if (walls >= 3) penalty += 400;
|
||||||
@@ -384,16 +400,16 @@ public class SwedishGenerator {
|
|||||||
// ---------------- Mask generation ----------------
|
// ---------------- Mask generation ----------------
|
||||||
|
|
||||||
static char[][] randomMask(Rng rng) {
|
static char[][] randomMask(Rng rng) {
|
||||||
char[][] g = makeEmptyGrid();
|
var g = makeEmptyGrid();
|
||||||
int targetClues = (int)Math.round(W * H * 0.25);
|
var targetClues = (int) Math.round(W * H * 0.25);
|
||||||
int placed = 0, guard = 0;
|
int placed = 0, guard = 0;
|
||||||
|
|
||||||
while (placed < targetClues && guard++ < 4000) {
|
while (placed < targetClues && guard++ < 4000) {
|
||||||
int r = rng.randint(0, H - 1);
|
var r = rng.randint(0, H - 1);
|
||||||
int c = rng.randint(0, W - 1);
|
var c = rng.randint(0, W - 1);
|
||||||
if (isDigit(g[r][c])) continue;
|
if (isDigit(g[r][c])) continue;
|
||||||
|
|
||||||
char d = (char)('0' + rng.randint(1, 4));
|
var d = (char) ('0' + rng.randint(1, 4));
|
||||||
g[r][c] = d;
|
g[r][c] = d;
|
||||||
if (!hasRoomForClue(g, r, c, d)) {
|
if (!hasRoomForClue(g, r, c, d)) {
|
||||||
g[r][c] = '#';
|
g[r][c] = '#';
|
||||||
@@ -405,20 +421,20 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static char[][] mutate(Rng rng, char[][] grid) {
|
static char[][] mutate(Rng rng, char[][] grid) {
|
||||||
char[][] g = deepCopyGrid(grid);
|
var g = deepCopyGrid(grid);
|
||||||
int cx = rng.randint(0, H - 1);
|
var cx = rng.randint(0, H - 1);
|
||||||
int cy = rng.randint(0, W - 1);
|
var cy = rng.randint(0, W - 1);
|
||||||
|
|
||||||
int steps = 4;
|
var steps = 4;
|
||||||
for (int k = 0; k < steps; k++) {
|
for (var k = 0; k < steps; k++) {
|
||||||
int rr = clamp(cx + (rng.randint(-2, 2) + rng.randint(-2, 2)), 0, H - 1);
|
var rr = clamp(cx + (rng.randint(-2, 2) + rng.randint(-2, 2)), 0, H - 1);
|
||||||
int cc = clamp(cy + (rng.randint(-2, 2) + rng.randint(-2, 2)), 0, W - 1);
|
var cc = clamp(cy + (rng.randint(-2, 2) + rng.randint(-2, 2)), 0, W - 1);
|
||||||
|
|
||||||
char cur = g[rr][cc];
|
var cur = g[rr][cc];
|
||||||
if (isDigit(cur)) {
|
if (isDigit(cur)) {
|
||||||
g[rr][cc] = '#';
|
g[rr][cc] = '#';
|
||||||
} else {
|
} else {
|
||||||
char d = (char)('0' + rng.randint(1, 4));
|
var d = (char) ('0' + rng.randint(1, 4));
|
||||||
g[rr][cc] = d;
|
g[rr][cc] = d;
|
||||||
if (!hasRoomForClue(g, rr, cc, d)) g[rr][cc] = '#';
|
if (!hasRoomForClue(g, rr, cc, d)) g[rr][cc] = '#';
|
||||||
}
|
}
|
||||||
@@ -427,34 +443,36 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static char[][] crossover(Rng rng, char[][] a, char[][] b) {
|
static char[][] crossover(Rng rng, char[][] a, char[][] b) {
|
||||||
char[][] out = makeEmptyGrid();
|
var out = makeEmptyGrid();
|
||||||
double cx = (H - 1) / 2.0;
|
var cx = (H - 1) / 2.0;
|
||||||
double cy = (W - 1) / 2.0;
|
var cy = (W - 1) / 2.0;
|
||||||
double theta = rng.nextFloat() * Math.PI;
|
var theta = rng.nextFloat() * Math.PI;
|
||||||
double nx = Math.cos(theta);
|
var nx = Math.cos(theta);
|
||||||
double ny = Math.sin(theta);
|
var ny = Math.sin(theta);
|
||||||
|
|
||||||
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) {
|
for (var r = 0; r < H; r++)
|
||||||
|
for (var c = 0; c < W; c++) {
|
||||||
double x = r - cx, y = c - cy;
|
double x = r - cx, y = c - cy;
|
||||||
double side = x * nx + y * ny;
|
var side = x * nx + y * ny;
|
||||||
out[r][c] = (side >= 0) ? a[r][c] : b[r][c];
|
out[r][c] = (side >= 0) ? a[r][c] : b[r][c];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) {
|
for (var r = 0; r < H; r++)
|
||||||
char ch = out[r][c];
|
for (var c = 0; c < W; c++) {
|
||||||
|
var ch = out[r][c];
|
||||||
if (isDigit(ch) && !hasRoomForClue(out, r, c, ch)) out[r][c] = '#';
|
if (isDigit(ch) && !hasRoomForClue(out, r, c, ch)) out[r][c] = '#';
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
static char[][] hillclimb(Rng rng, char[][] start, HashMap<Integer, Integer> lenCounts, int limit) {
|
static char[][] hillclimb(Rng rng, char[][] start, HashMap<Integer, Integer> lenCounts, int limit) {
|
||||||
char[][] best = deepCopyGrid(start);
|
var best = deepCopyGrid(start);
|
||||||
long bestF = maskFitness(best, lenCounts);
|
var bestF = maskFitness(best, lenCounts);
|
||||||
int fails = 0;
|
var fails = 0;
|
||||||
|
|
||||||
while (fails < limit) {
|
while (fails < limit) {
|
||||||
char[][] cand = mutate(rng, best);
|
var cand = mutate(rng, best);
|
||||||
long f = maskFitness(cand, lenCounts);
|
var f = maskFitness(cand, lenCounts);
|
||||||
if (f < bestF) {
|
if (f < bestF) {
|
||||||
best = cand;
|
best = cand;
|
||||||
bestF = f;
|
bestF = f;
|
||||||
@@ -467,47 +485,50 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static double similarity(char[][] a, char[][] b) {
|
static double similarity(char[][] a, char[][] b) {
|
||||||
int same = 0;
|
var same = 0;
|
||||||
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) if (a[r][c] == b[r][c]) same++;
|
for (var r = 0; r < H; r++) for (var c = 0; c < W; c++) if (a[r][c] == b[r][c]) same++;
|
||||||
return same / (double) (W * H);
|
return same / (double) (W * H);
|
||||||
}
|
}
|
||||||
|
|
||||||
static char[][] generateMask(Rng rng, HashMap<Integer, Integer> lenCounts, int popSize, int gens) {
|
static char[][] generateMask(Rng rng, HashMap<Integer, Integer> lenCounts, int popSize, int gens) {
|
||||||
System.out.println("generateMask init pop: " + popSize);
|
System.out.println("generateMask init pop: " + popSize);
|
||||||
ArrayList<char[][]> pop = new ArrayList<>();
|
var pop = new ArrayList<char[][]>();
|
||||||
|
|
||||||
for (int i = 0; i < popSize; i++) {
|
for (var i = 0; i < popSize; i++) {
|
||||||
char[][] g = randomMask(rng);
|
var g = randomMask(rng);
|
||||||
pop.add(hillclimb(rng, g, lenCounts, 180));
|
pop.add(hillclimb(rng, g, lenCounts, 180));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int gen = 0; gen < gens; gen++) {
|
for (var gen = 0; gen < gens; gen++) {
|
||||||
ArrayList<char[][]> children = new ArrayList<>();
|
var children = new ArrayList<char[][]>();
|
||||||
int pairs = Math.max(popSize, (int)Math.floor(popSize * 1.5));
|
var pairs = Math.max(popSize, (int) Math.floor(popSize * 1.5));
|
||||||
|
|
||||||
for (int k = 0; k < pairs; k++) {
|
for (var k = 0; k < pairs; k++) {
|
||||||
char[][] p1 = pop.get(rng.randint(0, pop.size() - 1));
|
var p1 = pop.get(rng.randint(0, pop.size() - 1));
|
||||||
char[][] p2 = pop.get(rng.randint(0, pop.size() - 1));
|
var p2 = pop.get(rng.randint(0, pop.size() - 1));
|
||||||
char[][] child = crossover(rng, p1, p2);
|
var child = crossover(rng, p1, p2);
|
||||||
children.add(hillclimb(rng, child, lenCounts, 70));
|
children.add(hillclimb(rng, child, lenCounts, 70));
|
||||||
}
|
}
|
||||||
|
|
||||||
pop.addAll(children);
|
pop.addAll(children);
|
||||||
pop.sort(Comparator.comparingLong(g -> maskFitness(g, lenCounts)));
|
pop.sort(Comparator.comparingLong(g -> maskFitness(g, lenCounts)));
|
||||||
|
|
||||||
ArrayList<char[][]> next = new ArrayList<>();
|
var next = new ArrayList<char[][]>();
|
||||||
for (char[][] cand : pop) {
|
for (var cand : pop) {
|
||||||
if (next.size() >= popSize) break;
|
if (next.size() >= popSize) break;
|
||||||
boolean ok = true;
|
var ok = true;
|
||||||
for (char[][] kept : next) {
|
for (var kept : next) {
|
||||||
if (similarity(cand, kept) > 0.92) { ok = false; break; }
|
if (similarity(cand, kept) > 0.92) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (ok) next.add(cand);
|
if (ok) next.add(cand);
|
||||||
}
|
}
|
||||||
pop = next;
|
pop = next;
|
||||||
|
|
||||||
if (gen % 10 == 0) {
|
if (gen % 10 == 0) {
|
||||||
long bestF = maskFitness(pop.get(0), lenCounts);
|
var bestF = maskFitness(pop.get(0), lenCounts);
|
||||||
System.out.println(" gen " + gen + "/" + gens + " bestFitness=" + bestF);
|
System.out.println(" gen " + gen + "/" + gens + " bestFitness=" + bestF);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,6 +540,7 @@ public class SwedishGenerator {
|
|||||||
// ---------------- Fill (CSP) ----------------
|
// ---------------- Fill (CSP) ----------------
|
||||||
|
|
||||||
public static final class FillStats {
|
public static final class FillStats {
|
||||||
|
|
||||||
public long nodes;
|
public long nodes;
|
||||||
public long backtracks;
|
public long backtracks;
|
||||||
public double seconds;
|
public double seconds;
|
||||||
@@ -526,53 +548,50 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static final class FillResult {
|
public static final class FillResult {
|
||||||
|
|
||||||
public boolean ok;
|
public boolean ok;
|
||||||
public char[][] grid;
|
public char[][] grid;
|
||||||
public HashMap<String, String> clueMap;
|
public HashMap<String, String> clueMap;
|
||||||
public FillStats stats;
|
public FillStats stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final class Undo {
|
record Undo(int[] rs, int[] cs, char[] prev, int n) {
|
||||||
final int[] rs, cs;
|
|
||||||
final char[] prev;
|
|
||||||
final int n;
|
|
||||||
Undo(int[] rs, int[] cs, char[] prev, int n) {
|
|
||||||
this.rs = rs; this.cs = cs; this.prev = prev; this.n = n;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static char[] patternForSlot(char[][] grid, Slot s) {
|
static char[] patternForSlot(char[][] grid, Slot s) {
|
||||||
char[] pat = new char[s.len];
|
var pat = new char[s.len];
|
||||||
for (int i = 0; i < s.len; i++) {
|
for (var i = 0; i < s.len; i++) {
|
||||||
char ch = grid[s.rs[i]][s.cs[i]];
|
var ch = grid[s.rs[i]][s.cs[i]];
|
||||||
pat[i] = isLetter(ch) ? ch : 0;
|
pat[i] = isLetter(ch) ? ch : 0;
|
||||||
}
|
}
|
||||||
return pat;
|
return pat;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int slotScore(int[][] cellCount, Slot s) {
|
static int slotScore(int[][] cellCount, Slot s) {
|
||||||
int cross = 0;
|
var cross = 0;
|
||||||
for (int i = 0; i < s.len; i++) cross += (cellCount[s.rs[i]][s.cs[i]] - 1);
|
for (var i = 0; i < s.len; i++) cross += (cellCount[s.rs[i]][s.cs[i]] - 1);
|
||||||
return cross * 10 + s.len;
|
return cross * 10 + s.len;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Undo placeWord(char[][] grid, Slot s, String w) {
|
static Undo placeWord(char[][] grid, Slot s, String w) {
|
||||||
int[] urs = new int[s.len];
|
var urs = new int[s.len];
|
||||||
int[] ucs = new int[s.len];
|
var ucs = new int[s.len];
|
||||||
char[] up = new char[s.len];
|
var up = new char[s.len];
|
||||||
int n = 0;
|
var n = 0;
|
||||||
|
|
||||||
for (int i = 0; i < s.len; i++) {
|
for (var i = 0; i < s.len; i++) {
|
||||||
int r = s.rs[i], c = s.cs[i];
|
int r = s.rs[i], c = s.cs[i];
|
||||||
char prev = grid[r][c];
|
var prev = grid[r][c];
|
||||||
char ch = w.charAt(i);
|
var ch = w.charAt(i);
|
||||||
if (prev == '#') {
|
if (prev == '#') {
|
||||||
urs[n] = r; ucs[n] = c; up[n] = prev;
|
urs[n] = r;
|
||||||
|
ucs[n] = c;
|
||||||
|
up[n] = prev;
|
||||||
n++;
|
n++;
|
||||||
grid[r][c] = ch;
|
grid[r][c] = ch;
|
||||||
} else if (prev != ch) {
|
} else if (prev != ch) {
|
||||||
// rollback immediate changes
|
// rollback immediate changes
|
||||||
for (int j = 0; j < n; j++) grid[urs[j]][ucs[j]] = up[j];
|
for (var j = 0; j < n; j++) grid[urs[j]][ucs[j]] = up[j];
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,42 +599,42 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void undoPlace(char[][] grid, Undo u) {
|
static void undoPlace(char[][] grid, Undo u) {
|
||||||
for (int i = 0; i < u.n; i++) grid[u.rs[i]][u.cs[i]] = u.prev[i];
|
for (var i = 0; i < u.n; i++) grid[u.rs[i]][u.cs[i]] = u.prev[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
static FillResult fillMask(Rng rng, char[][] mask, HashMap<Integer, DictEntry> dictIndex,
|
static FillResult fillMask(Rng rng, char[][] mask, HashMap<Integer, DictEntry> dictIndex,
|
||||||
int logEveryMs, int timeLimitMs) {
|
int logEveryMs, int timeLimitMs) {
|
||||||
|
|
||||||
char[][] grid = deepCopyGrid(mask);
|
var grid = deepCopyGrid(mask);
|
||||||
ArrayList<Slot> allSlots = extractSlots(grid);
|
var allSlots = extractSlots(grid);
|
||||||
ArrayList<Slot> slots = new ArrayList<>();
|
var slots = new ArrayList<Slot>();
|
||||||
for (Slot s : allSlots) if (s.len >= MIN_LEN && s.len <= MAX_LEN) slots.add(s);
|
for (var s : allSlots) if (s.len >= MIN_LEN && s.len <= MAX_LEN) slots.add(s);
|
||||||
|
|
||||||
HashSet<String> used = new HashSet<>();
|
var used = new HashSet<String>();
|
||||||
HashMap<String, String> assigned = new HashMap<>();
|
var assigned = new HashMap<String, String>();
|
||||||
|
|
||||||
int[][] cellCount = new int[H][W];
|
var cellCount = new int[H][W];
|
||||||
for (Slot s : slots) for (int i = 0; i < s.len; i++) cellCount[s.rs[i]][s.cs[i]]++;
|
for (var s : slots) for (var i = 0; i < s.len; i++) cellCount[s.rs[i]][s.cs[i]]++;
|
||||||
|
|
||||||
long t0 = System.currentTimeMillis();
|
var t0 = System.currentTimeMillis();
|
||||||
final java.util.concurrent.atomic.AtomicLong lastLog = new java.util.concurrent.atomic.AtomicLong(t0);
|
final var lastLog = new java.util.concurrent.atomic.AtomicLong(t0);
|
||||||
|
|
||||||
FillStats stats = new FillStats();
|
var stats = new FillStats();
|
||||||
final int TOTAL = slots.size();
|
final var TOTAL = slots.size();
|
||||||
final int BAR_LEN = 22;
|
final var BAR_LEN = 22;
|
||||||
|
|
||||||
Runnable renderProgress = () -> {
|
Runnable renderProgress = () -> {
|
||||||
long now = System.currentTimeMillis();
|
var now = System.currentTimeMillis();
|
||||||
if ((now - lastLog.get()) < logEveryMs) return;
|
if ((now - lastLog.get()) < logEveryMs) return;
|
||||||
lastLog.set(now);
|
lastLog.set(now);
|
||||||
|
|
||||||
int done = assigned.size();
|
var done = assigned.size();
|
||||||
int pct = (TOTAL == 0) ? 100 : (int)Math.floor((done / (double)TOTAL) * 100);
|
var pct = (TOTAL == 0) ? 100 : (int) Math.floor((done / (double) TOTAL) * 100);
|
||||||
int filled = Math.min(BAR_LEN, (int)Math.floor((pct / 100.0) * BAR_LEN));
|
var filled = Math.min(BAR_LEN, (int) Math.floor((pct / 100.0) * BAR_LEN));
|
||||||
String bar = "[" + "#".repeat(filled) + "-".repeat(BAR_LEN - filled) + "]";
|
var bar = "[" + "#".repeat(filled) + "-".repeat(BAR_LEN - filled) + "]";
|
||||||
String elapsed = String.format(Locale.ROOT, "%.1fs", (now - t0) / 1000.0);
|
var elapsed = String.format(Locale.ROOT, "%.1fs", (now - t0) / 1000.0);
|
||||||
|
|
||||||
String msg = String.format(
|
var msg = String.format(
|
||||||
Locale.ROOT,
|
Locale.ROOT,
|
||||||
"%s %d/%d slots | nodes=%d | backtracks=%d | mrv=%d | %s",
|
"%s %d/%d slots | nodes=%d | backtracks=%d | mrv=%d | %s",
|
||||||
bar, done, TOTAL, stats.nodes, stats.backtracks, stats.lastMRV, elapsed
|
bar, done, TOTAL, stats.nodes, stats.backtracks, stats.lastMRV, elapsed
|
||||||
@@ -625,6 +644,7 @@ public class SwedishGenerator {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class Pick {
|
class Pick {
|
||||||
|
|
||||||
Slot slot;
|
Slot slot;
|
||||||
CandidateInfo info;
|
CandidateInfo info;
|
||||||
boolean done;
|
boolean done;
|
||||||
@@ -634,23 +654,27 @@ public class SwedishGenerator {
|
|||||||
Slot best = null;
|
Slot best = null;
|
||||||
CandidateInfo bestInfo = null;
|
CandidateInfo bestInfo = null;
|
||||||
|
|
||||||
for (Slot s : slots) {
|
for (var s : slots) {
|
||||||
String k = s.key();
|
var k = s.key();
|
||||||
if (assigned.containsKey(k)) continue;
|
if (assigned.containsKey(k)) continue;
|
||||||
|
|
||||||
DictEntry entry = dictIndex.get(s.len);
|
var entry = dictIndex.get(s.len);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
Pick p = new Pick();
|
var p = new Pick();
|
||||||
p.slot = null; p.info = null; p.done = false;
|
p.slot = null;
|
||||||
|
p.info = null;
|
||||||
|
p.done = false;
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
char[] pat = patternForSlot(grid, s);
|
var pat = patternForSlot(grid, s);
|
||||||
CandidateInfo info = candidateInfoForPattern(entry, pat);
|
var info = candidateInfoForPattern(entry, pat);
|
||||||
|
|
||||||
if (info.count == 0) {
|
if (info.count == 0) {
|
||||||
Pick p = new Pick();
|
var p = new Pick();
|
||||||
p.slot = null; p.info = null; p.done = false;
|
p.slot = null;
|
||||||
|
p.info = null;
|
||||||
|
p.done = false;
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +687,7 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Pick p = new Pick();
|
var p = new Pick();
|
||||||
if (best == null) {
|
if (best == null) {
|
||||||
p.slot = null;
|
p.slot = null;
|
||||||
p.info = null;
|
p.info = null;
|
||||||
@@ -676,35 +700,39 @@ public class SwedishGenerator {
|
|||||||
return p;
|
return p;
|
||||||
};
|
};
|
||||||
|
|
||||||
final int MAX_TRIES_PER_SLOT = 500;
|
final var MAX_TRIES_PER_SLOT = 500;
|
||||||
|
|
||||||
class Solver {
|
class Solver {
|
||||||
|
|
||||||
boolean backtrack() {
|
boolean backtrack() {
|
||||||
stats.nodes++;
|
stats.nodes++;
|
||||||
|
|
||||||
if (timeLimitMs > 0 && (System.currentTimeMillis() - t0) > timeLimitMs) return false;
|
if (timeLimitMs > 0 && (System.currentTimeMillis() - t0) > timeLimitMs) return false;
|
||||||
|
|
||||||
Pick pick = chooseMRV.get();
|
var pick = chooseMRV.get();
|
||||||
if (pick.done) return true;
|
if (pick.done) return true;
|
||||||
if (pick.slot == null) { stats.backtracks++; return false; }
|
if (pick.slot == null) {
|
||||||
|
stats.backtracks++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
stats.lastMRV = pick.info.count;
|
stats.lastMRV = pick.info.count;
|
||||||
renderProgress.run();
|
renderProgress.run();
|
||||||
|
|
||||||
Slot s = pick.slot;
|
var s = pick.slot;
|
||||||
String k = s.key();
|
var k = s.key();
|
||||||
DictEntry entry = dictIndex.get(s.len);
|
var entry = dictIndex.get(s.len);
|
||||||
char[] pat = patternForSlot(grid, s);
|
var pat = patternForSlot(grid, s);
|
||||||
|
|
||||||
java.util.function.Function<String, Boolean> tryWord = (String w) -> {
|
java.util.function.Function<String, Boolean> tryWord = (String w) -> {
|
||||||
if (w == null) return false;
|
if (w == null) return false;
|
||||||
if (used.contains(w)) return false;
|
if (used.contains(w)) return false;
|
||||||
|
|
||||||
for (int i = 0; i < pat.length; i++) {
|
for (var i = 0; i < pat.length; i++) {
|
||||||
if (pat[i] != 0 && pat[i] != w.charAt(i)) return false;
|
if (pat[i] != 0 && pat[i] != w.charAt(i)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Undo undo = placeWord(grid, s, w);
|
var undo = placeWord(grid, s, w);
|
||||||
if (undo == null) return false;
|
if (undo == null) return false;
|
||||||
|
|
||||||
used.add(w);
|
used.add(w);
|
||||||
@@ -719,32 +747,35 @@ public class SwedishGenerator {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (pick.info.indices != null && pick.info.indices.length > 0) {
|
if (pick.info.indices != null && pick.info.indices.length > 0) {
|
||||||
int[] idxs = pick.info.indices;
|
var idxs = pick.info.indices;
|
||||||
int L = idxs.length;
|
var L = idxs.length;
|
||||||
int tries = Math.min(MAX_TRIES_PER_SLOT, L);
|
var tries = Math.min(MAX_TRIES_PER_SLOT, L);
|
||||||
|
|
||||||
int start = (L == 1) ? 0 : rng.randint(0, L - 1);
|
var start = (L == 1) ? 0 : rng.randint(0, L - 1);
|
||||||
int step = (L <= 1) ? 1 : rng.randint(1, L - 1);
|
var step = (L <= 1) ? 1 : rng.randint(1, L - 1);
|
||||||
|
|
||||||
for (int t = 0; t < tries; t++) {
|
for (var t = 0; t < tries; t++) {
|
||||||
int idx = idxs[(start + t * step) % L];
|
var idx = idxs[(start + t * step) % L];
|
||||||
String w = entry.words.get(idx);
|
var w = entry.words.get(idx);
|
||||||
if (tryWord.apply(w)) return true;
|
if (tryWord.apply(w)) return true;
|
||||||
}
|
}
|
||||||
stats.backtracks++;
|
stats.backtracks++;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int N = entry.words.size();
|
var N = entry.words.size();
|
||||||
if (N == 0) { stats.backtracks++; return false; }
|
if (N == 0) {
|
||||||
|
stats.backtracks++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
int tries = Math.min(MAX_TRIES_PER_SLOT, N);
|
var tries = Math.min(MAX_TRIES_PER_SLOT, N);
|
||||||
int start = (N == 1) ? 0 : rng.randint(0, N - 1);
|
var start = (N == 1) ? 0 : rng.randint(0, N - 1);
|
||||||
int step = (N <= 1) ? 1 : rng.randint(1, N - 1);
|
var step = (N <= 1) ? 1 : rng.randint(1, N - 1);
|
||||||
|
|
||||||
for (int t = 0; t < tries; t++) {
|
for (var t = 0; t < tries; t++) {
|
||||||
int idx = (start + t * step) % N;
|
var idx = (start + t * step) % N;
|
||||||
String w = entry.words.get(idx);
|
var w = entry.words.get(idx);
|
||||||
if (tryWord.apply(w)) return true;
|
if (tryWord.apply(w)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,12 +786,12 @@ public class SwedishGenerator {
|
|||||||
|
|
||||||
// initial render (same feel)
|
// initial render (same feel)
|
||||||
renderProgress.run();
|
renderProgress.run();
|
||||||
boolean ok = new Solver().backtrack();
|
var ok = new Solver().backtrack();
|
||||||
// final progress line
|
// final progress line
|
||||||
System.out.print("\r" + padRight("", 120) + "\r");
|
System.out.print("\r" + padRight("", 120) + "\r");
|
||||||
System.out.flush();
|
System.out.flush();
|
||||||
|
|
||||||
FillResult res = new FillResult();
|
var res = new FillResult();
|
||||||
res.ok = ok;
|
res.ok = ok;
|
||||||
res.grid = grid;
|
res.grid = grid;
|
||||||
res.clueMap = assigned;
|
res.clueMap = assigned;
|
||||||
@@ -784,60 +815,32 @@ public class SwedishGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Top-level generatePuzzle ----------------
|
// ---------------- Top-level generatePuzzle ----------------
|
||||||
|
public record PuzzleResult(char[][] mask, FillResult filled) { }
|
||||||
public static final class PuzzleResult {
|
|
||||||
public char[][] mask;
|
|
||||||
public FillResult filled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PuzzleResult generatePuzzle(Main.Opts opts) {
|
public static PuzzleResult generatePuzzle(Main.Opts opts) {
|
||||||
var rng = new Rng(opts.seed);
|
var rng = new Rng(opts.seed);
|
||||||
|
|
||||||
var tLoad0 = System.nanoTime();
|
var tLoad0 = System.nanoTime();
|
||||||
var dict = loadWords(opts.wordsPath);
|
var dict = loadWords(opts.wordsPath);
|
||||||
var tLoad1 = System.nanoTime();
|
var tLoad1 = System.nanoTime();
|
||||||
System.out.printf(Locale.ROOT, "LOAD_WORDS: %.3fs%n", (tLoad1 - tLoad0) / 1e9);
|
System.out.printf(Locale.ROOT, "LOAD_WORDS: %.3fs%n", (tLoad1 - tLoad0) / 1e9);
|
||||||
|
|
||||||
for (int attempt = 1; attempt <= opts.tries; attempt++) {
|
for (var attempt = 1; attempt <= opts.tries; attempt++) {
|
||||||
System.out.println("\nAttempt " + attempt + "/" + opts.tries);
|
System.out.println("\nAttempt " + attempt + "/" + opts.tries);
|
||||||
|
|
||||||
long tMask0 = System.nanoTime();
|
var tMask0 = System.nanoTime();
|
||||||
char[][] mask = generateMask(rng, dict.lenCounts, opts.pop, opts.gens);
|
var mask = generateMask(rng, dict.lenCounts, opts.pop, opts.gens);
|
||||||
long tMask1 = System.nanoTime();
|
var tMask1 = System.nanoTime();
|
||||||
System.out.printf(Locale.ROOT, "MASK: %.3fs%n", (tMask1 - tMask0) / 1e9);
|
System.out.printf(Locale.ROOT, "MASK: %.3fs%n", (tMask1 - tMask0) / 1e9);
|
||||||
|
|
||||||
long tFill0 = System.nanoTime();
|
var tFill0 = System.nanoTime();
|
||||||
var filled = fillMask(rng, mask, dict.index, 200, 30000);
|
var filled = fillMask(rng, mask, dict.index, 200, 30000);
|
||||||
long tFill1 = System.nanoTime();
|
var tFill1 = System.nanoTime();
|
||||||
System.out.printf(Locale.ROOT, "FILL: %.3fms%n", (tFill1 - tFill0) / 1e6);
|
System.out.printf(Locale.ROOT, "FILL: %.3fms%n", (tFill1 - tFill0) / 1e6);
|
||||||
|
|
||||||
if (filled.ok) {
|
if (filled.ok) {
|
||||||
var pr = new PuzzleResult();
|
return new PuzzleResult(mask, filled);
|
||||||
pr.mask = mask;
|
|
||||||
pr.filled = filled;
|
|
||||||
return pr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- main ----------------
|
|
||||||
|
|
||||||
public static void convert(Main.Opts opts) {
|
|
||||||
|
|
||||||
var res = generatePuzzle(opts);
|
|
||||||
if (res == null) {
|
|
||||||
System.out.println("No solution found within tries.");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("\n=== GENERATED MASK ===");
|
|
||||||
System.out.println(gridToString(res.mask));
|
|
||||||
|
|
||||||
System.out.println("\n=== FILLED PUZZLE (RAW) ===");
|
|
||||||
System.out.println(gridToString(res.filled.grid));
|
|
||||||
|
|
||||||
System.out.println("\n=== FILLED PUZZLE (HUMAN) ===");
|
|
||||||
System.out.println(renderHuman(res.filled.grid));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
205
src/puzzle/ThemeGraph.java
Normal file
205
src/puzzle/ThemeGraph.java
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package puzzle;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeGraph - Creates a graph between words and themes for filtering.
|
||||||
|
* Uses word embeddings approach: co-occurrence and semantic similarity.
|
||||||
|
*/
|
||||||
|
public class ThemeGraph {
|
||||||
|
|
||||||
|
// Predefined theme keywords for Dutch word filtering
|
||||||
|
private static final Map<String, Set<String>> THEME_KEYWORDS = new HashMap<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
// News/Politics
|
||||||
|
THEME_KEYWORDS.put("nieuws", Set.of(
|
||||||
|
"POLITIEK", "VERKIEZING", "MINISTER", "PARLEMENT", "WET", "BELEID",
|
||||||
|
"REGERING", "PARTIJ", "STEM", "KAMER", "RAAD", "STAAT"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Technology
|
||||||
|
THEME_KEYWORDS.put("technologie", Set.of(
|
||||||
|
"COMPUTER", "INTERNET", "SOFTWARE", "APP", "DATA", "CODE",
|
||||||
|
"NETWERK", "SYSTEEM", "DIGITAAL", "TECH", "ROBOT", "AI"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Sports
|
||||||
|
THEME_KEYWORDS.put("sport", Set.of(
|
||||||
|
"VOETBAL", "TENNIS", "WIELREN", "SPELER", "WEDSTRIJD", "TEAM",
|
||||||
|
"GOAL", "BAL", "SPEL", "WINNEN", "COACH", "ATLEET"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Weather/Nature
|
||||||
|
THEME_KEYWORDS.put("weer", Set.of(
|
||||||
|
"REGEN", "ZON", "WIND", "WOLKEN", "STORM", "SNEEUW",
|
||||||
|
"WEER", "KLIMAAT", "NATUUR", "LUCHT", "WARMTE", "KOU"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Economy
|
||||||
|
THEME_KEYWORDS.put("economie", Set.of(
|
||||||
|
"GELD", "EURO", "MARKT", "PRIJS", "KOPEN", "VERKOOP",
|
||||||
|
"BEDRIJF", "BANK", "HANDEL", "WINST", "SCHULD", "BUDGET"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Health
|
||||||
|
THEME_KEYWORDS.put("gezondheid", Set.of(
|
||||||
|
"ZORG", "DOKTER", "MEDICIJN", "PATIENT", "ZIEKENHUIS", "GEZOND",
|
||||||
|
"VIRUS", "VACCIN", "THERAPIE", "BEHANDEL", "ARTS", "KLINIEK"
|
||||||
|
));
|
||||||
|
|
||||||
|
// General/Common
|
||||||
|
THEME_KEYWORDS.put("algemeen", Set.of(
|
||||||
|
"HUIS", "AUTO", "BOOM", "WATER", "MENS", "TIJD",
|
||||||
|
"LEVEN", "WERK", "SCHOOL", "FAMILIE", "STAD", "LAND"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a word against a theme (0.0 = no match, 1.0 = perfect match)
|
||||||
|
*/
|
||||||
|
public static double scoreWordTheme(String word, String theme) {
|
||||||
|
Set<String> keywords = THEME_KEYWORDS.get(theme.toLowerCase());
|
||||||
|
if (keywords == null) {
|
||||||
|
return 0.5; // unknown theme = neutral score
|
||||||
|
}
|
||||||
|
|
||||||
|
word = word.toUpperCase();
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if (keywords.contains(word)) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substring match (partial relevance)
|
||||||
|
for (String kw : keywords) {
|
||||||
|
if (word.contains(kw) || kw.contains(word)) {
|
||||||
|
return 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit distance similarity (for typos/variations)
|
||||||
|
for (String kw : keywords) {
|
||||||
|
double similarity = editDistanceSimilarity(word, kw);
|
||||||
|
if (similarity > 0.8) {
|
||||||
|
return similarity * 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter word list by theme with minimum score threshold
|
||||||
|
*/
|
||||||
|
public static List<String> filterByTheme(List<String> words, String theme, double minScore) {
|
||||||
|
List<String> filtered = new ArrayList<>();
|
||||||
|
for (String word : words) {
|
||||||
|
double score = scoreWordTheme(word, theme);
|
||||||
|
if (score >= minScore) {
|
||||||
|
filtered.add(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme suggestions for a word (sorted by score)
|
||||||
|
*/
|
||||||
|
public static List<ThemeScore> getThemesForWord(String word) {
|
||||||
|
List<ThemeScore> scores = new ArrayList<>();
|
||||||
|
for (String theme : THEME_KEYWORDS.keySet()) {
|
||||||
|
double score = scoreWordTheme(word, theme);
|
||||||
|
if (score > 0.0) {
|
||||||
|
scores.add(new ThemeScore(theme, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scores.sort(Comparator.comparingDouble(ts -> -ts.score));
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-detect best theme from a word list
|
||||||
|
*/
|
||||||
|
public static String detectTheme(List<String> words) {
|
||||||
|
Map<String, Double> themeScores = new HashMap<>();
|
||||||
|
|
||||||
|
for (String theme : THEME_KEYWORDS.keySet()) {
|
||||||
|
double totalScore = 0;
|
||||||
|
for (String word : words) {
|
||||||
|
totalScore += scoreWordTheme(word, theme);
|
||||||
|
}
|
||||||
|
themeScores.put(theme, totalScore / words.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
return themeScores.entrySet().stream()
|
||||||
|
.max(Comparator.comparingDouble(Map.Entry::getValue))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.orElse("algemeen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple edit distance similarity (normalized Levenshtein)
|
||||||
|
*/
|
||||||
|
private static double editDistanceSimilarity(String a, String b) {
|
||||||
|
int dist = levenshtein(a, b);
|
||||||
|
int maxLen = Math.max(a.length(), b.length());
|
||||||
|
if (maxLen == 0) return 1.0;
|
||||||
|
return 1.0 - ((double) dist / maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int levenshtein(String a, String b) {
|
||||||
|
int[][] dp = new int[a.length() + 1][b.length() + 1];
|
||||||
|
|
||||||
|
for (int i = 0; i <= a.length(); i++) dp[i][0] = i;
|
||||||
|
for (int j = 0; j <= b.length(); j++) dp[0][j] = j;
|
||||||
|
|
||||||
|
for (int i = 1; i <= a.length(); i++) {
|
||||||
|
for (int j = 1; j <= b.length(); j++) {
|
||||||
|
int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1;
|
||||||
|
dp[i][j] = Math.min(
|
||||||
|
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
|
||||||
|
dp[i - 1][j - 1] + cost
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dp[a.length()][b.length()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ThemeScore(String theme, double score) {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("%s: %.2f", theme, score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Main for testing ----
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("=== Theme Graph Test ===\n");
|
||||||
|
|
||||||
|
// Test word scoring
|
||||||
|
String[] testWords = {"POLITIEK", "VOETBAL", "COMPUTER", "REGEN", "AUTO"};
|
||||||
|
for (String word : testWords) {
|
||||||
|
System.out.println("Word: " + word);
|
||||||
|
List<ThemeScore> themes = getThemesForWord(word);
|
||||||
|
for (ThemeScore ts : themes) {
|
||||||
|
System.out.println(" " + ts);
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test theme detection
|
||||||
|
List<String> techWords = Arrays.asList("COMPUTER", "INTERNET", "SOFTWARE", "DATA");
|
||||||
|
String detected = detectTheme(techWords);
|
||||||
|
System.out.println("Detected theme for tech words: " + detected);
|
||||||
|
|
||||||
|
// Test filtering
|
||||||
|
List<String> allWords = Arrays.asList(
|
||||||
|
"POLITIEK", "COMPUTER", "AUTO", "VOETBAL", "INTERNET", "BOOM"
|
||||||
|
);
|
||||||
|
List<String> filtered = filterByTheme(allWords, "technologie", 0.5);
|
||||||
|
System.out.println("\nFiltered for 'technologie' (min 0.5): " + filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user