main.swift (8872B)
1 import Foundation 2 3 let gridSize = 15 4 5 struct Options { 6 var gridsPath = "Sources/Fillmake/Resources/grid_list.json" 7 var limit: Int? 8 var format = "tsv" 9 } 10 11 struct GridEntry: Decodable { 12 let grid: [String] 13 let date: String 14 } 15 16 struct Slot { 17 enum Direction: String { 18 case across 19 case down 20 } 21 22 let id: Int 23 let cells: [Int] 24 let direction: Direction 25 } 26 27 struct GridStats { 28 let index: Int 29 let date: String 30 let score: Int 31 let slotCount: Int 32 let openCellCount: Int 33 let threeLetterSlots: Int 34 let fourLetterSlots: Int 35 let fiveSixLetterSlots: Int 36 let sevenPlusLetterSlots: Int 37 let maxShortCluster: Int 38 let averageSlotLength: Double 39 } 40 41 struct GridmakeError: Error, CustomStringConvertible { 42 let description: String 43 } 44 45 func printUsage() { 46 print(""" 47 Usage: Gridmake [options] 48 49 Options: 50 --grids PATH Grid list JSON path. Default: Sources/Fillmake/Resources/grid_list.json 51 --limit N Print only the top N ranked grids. 52 --format tsv|json Output format. Default: tsv. 53 -h, --help Show this help. 54 """) 55 } 56 57 func requireValue(_ arguments: [String], _ index: inout Int, _ name: String) throws -> String { 58 index += 1 59 guard index < arguments.count else { 60 throw GridmakeError(description: "Missing value for \(name)") 61 } 62 return arguments[index] 63 } 64 65 func parseOptions() throws -> Options { 66 var options = Options() 67 let arguments = Array(CommandLine.arguments.dropFirst()) 68 var index = 0 69 70 while index < arguments.count { 71 let argument = arguments[index] 72 switch argument { 73 case "--grids": 74 options.gridsPath = try requireValue(arguments, &index, argument) 75 case "--limit": 76 let value = try requireValue(arguments, &index, argument) 77 guard let limit = Int(value), limit > 0 else { 78 throw GridmakeError(description: "--limit must be a positive integer") 79 } 80 options.limit = limit 81 case "--format": 82 let format = try requireValue(arguments, &index, argument) 83 guard ["tsv", "json"].contains(format) else { 84 throw GridmakeError(description: "--format must be tsv or json") 85 } 86 options.format = format 87 case "-h", "--help": 88 printUsage() 89 exit(0) 90 default: 91 throw GridmakeError(description: "Unknown argument: \(argument)") 92 } 93 index += 1 94 } 95 96 return options 97 } 98 99 func loadJSON<T: Decodable>(_ type: T.Type, from path: String) throws -> T { 100 let url = URL(fileURLWithPath: path) 101 let data = try Data(contentsOf: url) 102 return try JSONDecoder().decode(T.self, from: data) 103 } 104 105 func buildSlots(black: [Bool]) -> [Slot] { 106 var slots: [Slot] = [] 107 108 for row in 0..<gridSize { 109 var column = 0 110 while column < gridSize { 111 let cell = row * gridSize + column 112 if black[cell] { 113 column += 1 114 continue 115 } 116 var cells: [Int] = [] 117 while column < gridSize && !black[row * gridSize + column] { 118 cells.append(row * gridSize + column) 119 column += 1 120 } 121 if cells.count > 1 { 122 slots.append(Slot(id: slots.count, cells: cells, direction: .across)) 123 } 124 } 125 } 126 127 for column in 0..<gridSize { 128 var row = 0 129 while row < gridSize { 130 let cell = row * gridSize + column 131 if black[cell] { 132 row += 1 133 continue 134 } 135 var cells: [Int] = [] 136 while row < gridSize && !black[row * gridSize + column] { 137 cells.append(row * gridSize + column) 138 row += 1 139 } 140 if cells.count > 1 { 141 slots.append(Slot(id: slots.count, cells: cells, direction: .down)) 142 } 143 } 144 } 145 146 return slots 147 } 148 149 func maxShortCluster(in slots: [Slot]) -> Int { 150 let shortSlotIDs = Set(slots.filter { $0.cells.count <= 4 }.map(\.id)) 151 guard !shortSlotIDs.isEmpty else { 152 return 0 153 } 154 155 var cellToShortSlots: [Int: [Int]] = [:] 156 for slot in slots where shortSlotIDs.contains(slot.id) { 157 for cell in slot.cells { 158 cellToShortSlots[cell, default: []].append(slot.id) 159 } 160 } 161 162 var adjacency: [Int: Set<Int>] = [:] 163 for connectedSlots in cellToShortSlots.values where connectedSlots.count > 1 { 164 for left in connectedSlots { 165 for right in connectedSlots where right != left { 166 adjacency[left, default: []].insert(right) 167 } 168 } 169 } 170 171 var visited = Set<Int>() 172 var largest = 1 173 174 for slotID in shortSlotIDs where !visited.contains(slotID) { 175 var stack = [slotID] 176 visited.insert(slotID) 177 var size = 0 178 179 while let current = stack.popLast() { 180 size += 1 181 for next in adjacency[current, default: []] where !visited.contains(next) { 182 visited.insert(next) 183 stack.append(next) 184 } 185 } 186 187 largest = max(largest, size) 188 } 189 190 return largest 191 } 192 193 func stats(for entry: GridEntry, index: Int) throws -> GridStats { 194 guard entry.grid.count == gridSize * gridSize else { 195 throw GridmakeError(description: "Grid \(entry.date) has \(entry.grid.count) cells, expected \(gridSize * gridSize)") 196 } 197 198 let black = entry.grid.map { $0 == "." } 199 let slots = buildSlots(black: black) 200 let lengths = slots.map { $0.cells.count } 201 let slotCount = slots.count 202 let openCellCount = black.filter { !$0 }.count 203 let threeLetterSlots = lengths.filter { $0 == 3 }.count 204 let fourLetterSlots = lengths.filter { $0 == 4 }.count 205 let fiveSixLetterSlots = lengths.filter { (5...6).contains($0) }.count 206 let sevenPlusLetterSlots = lengths.filter { $0 >= 7 }.count 207 let shortCluster = maxShortCluster(in: slots) 208 let averageSlotLength = lengths.isEmpty ? 0 : Double(lengths.reduce(0, +)) / Double(lengths.count) 209 210 let score = sevenPlusLetterSlots * 20 211 + fiveSixLetterSlots * 8 212 - threeLetterSlots * 35 213 - fourLetterSlots * 15 214 - shortCluster * 50 215 + Int(averageSlotLength * 6) 216 - max(0, slotCount - 78) * 6 217 218 return GridStats( 219 index: index, 220 date: entry.date, 221 score: score, 222 slotCount: slotCount, 223 openCellCount: openCellCount, 224 threeLetterSlots: threeLetterSlots, 225 fourLetterSlots: fourLetterSlots, 226 fiveSixLetterSlots: fiveSixLetterSlots, 227 sevenPlusLetterSlots: sevenPlusLetterSlots, 228 maxShortCluster: shortCluster, 229 averageSlotLength: averageSlotLength 230 ) 231 } 232 233 func printTSV(_ rankedStats: [GridStats]) { 234 print("rank\tindex\tdate\tscore\tslots\topen_cells\tlen3\tlen4\tlen5_6\tlen7_plus\tmax_short_cluster\tavg_slot_length") 235 for (rank, stats) in rankedStats.enumerated() { 236 let average = String(format: "%.2f", stats.averageSlotLength) 237 print("\(rank + 1)\t\(stats.index)\t\(stats.date)\t\(stats.score)\t\(stats.slotCount)\t\(stats.openCellCount)\t\(stats.threeLetterSlots)\t\(stats.fourLetterSlots)\t\(stats.fiveSixLetterSlots)\t\(stats.sevenPlusLetterSlots)\t\(stats.maxShortCluster)\t\(average)") 238 } 239 } 240 241 func jsonString(_ value: String) -> String { 242 let data = try! JSONEncoder().encode(value) 243 return String(data: data, encoding: .utf8) ?? "\"\"" 244 } 245 246 func printJSON(_ rankedStats: [GridStats]) { 247 print("[") 248 for (offset, stats) in rankedStats.enumerated() { 249 let comma = offset == rankedStats.count - 1 ? "" : "," 250 let average = String(format: "%.4f", stats.averageSlotLength) 251 print(""" 252 {"rank":\(offset + 1),"index":\(stats.index),"date":\(jsonString(stats.date)),"score":\(stats.score),"slots":\(stats.slotCount),"open_cells":\(stats.openCellCount),"len3":\(stats.threeLetterSlots),"len4":\(stats.fourLetterSlots),"len5_6":\(stats.fiveSixLetterSlots),"len7_plus":\(stats.sevenPlusLetterSlots),"max_short_cluster":\(stats.maxShortCluster),"avg_slot_length":\(average)}\(comma) 253 """) 254 } 255 print("]") 256 } 257 258 do { 259 let options = try parseOptions() 260 let grids = try loadJSON([GridEntry].self, from: options.gridsPath) 261 let rankedStats = try grids.enumerated() 262 .map { try stats(for: $0.element, index: $0.offset) } 263 .sorted { 264 if $0.score != $1.score { 265 return $0.score > $1.score 266 } 267 return $0.index < $1.index 268 } 269 .prefix(options.limit ?? grids.count) 270 271 if options.format == "json" { 272 printJSON(Array(rankedStats)) 273 } else { 274 printTSV(Array(rankedStats)) 275 } 276 } catch { 277 fputs("Gridmake: \(error)\n", stderr) 278 exit(1) 279 }