diff --git a/UNDF-REGISTRY.json b/UNDF-REGISTRY.json index bf826dfe0..1e00146ab 100644 --- a/UNDF-REGISTRY.json +++ b/UNDF-REGISTRY.json @@ -969,5 +969,6 @@ "openttd-0002-0002": "UNDF-2026-000000968", "renpy-0001-0001": "UNDF-2026-000000969", "wesnoth-0001-0001": "UNDF-2026-000000970", - "wesnoth-0002-0002": "UNDF-2026-000000971" + "wesnoth-0002-0002": "UNDF-2026-000000971", + "wesnoth-0003-0003": "UNDF-2026-000000972" } diff --git a/defects/wesnoth-0001/patch/wesnoth-0001.patch b/defects/wesnoth-0001/patch/wesnoth-0001.patch new file mode 100644 index 000000000..1063cd365 --- /dev/null +++ b/defects/wesnoth-0001/patch/wesnoth-0001.patch @@ -0,0 +1,46 @@ +# UNDF: UNDF-2026-000000970 +--- a/src/pathfind/astarsearch.cpp ++++ b/src/pathfind/astarsearch.cpp +@@ -172,9 +172,16 @@ plain_route a_star_search(const map_location& src, const map_location& dst, + std::vector pq; + pq.push_back(index(src)); + ++ // Lazy-deletion A*: when a node is relaxed while already in the open ++ // set, we push a duplicate entry instead of performing a decrease-key. ++ // Stale duplicates are discarded when popped (the node will already ++ // have in == search_counter, meaning it was finalized). + while (!pq.empty()) { +- node& n = nodes[pq.front()]; +- +- n.in = search_counter; + + std::pop_heap(pq.begin(), pq.end(), node_comp); +- pq.pop_back(); ++ int front_idx = pq.back(); ++ pq.pop_back(); ++ ++ node& n = nodes[front_idx]; ++ ++ // Skip stale duplicates from lazy decrease-key. ++ if (n.in == search_counter) continue; ++ n.in = search_counter; + + if (n.t >= dst_node.g) break; + +@@ -213,10 +220,10 @@ plain_route a_star_search(const map_location& src, const map_location& dst, + + next = node(cost, loc, n.curr, dst, true, teleports, srch, dsth); + +- if (in_list) { +- std::push_heap(pq.begin(), std::find(pq.begin(), pq.end(), static_cast(index(loc))) + 1, node_comp); +- } else { +- pq.push_back(index(loc)); +- std::push_heap(pq.begin(), pq.end(), node_comp); +- } ++ // Always push a new entry. If the node was already in the ++ // open set (in_list == true), the old entry becomes stale ++ // and will be skipped when popped (lazy deletion). ++ pq.push_back(index(loc)); ++ std::push_heap(pq.begin(), pq.end(), node_comp); + } + } diff --git a/defects/wesnoth-0001/test/wesnoth-0001-test b/defects/wesnoth-0001/test/wesnoth-0001-test new file mode 100755 index 000000000..ac35d2474 Binary files /dev/null and b/defects/wesnoth-0001/test/wesnoth-0001-test differ diff --git a/defects/wesnoth-0001/test/wesnoth-0001-test.cpp b/defects/wesnoth-0001/test/wesnoth-0001-test.cpp new file mode 100644 index 000000000..38e8e259d --- /dev/null +++ b/defects/wesnoth-0001/test/wesnoth-0001-test.cpp @@ -0,0 +1,242 @@ +// wesnoth-0001-test.cpp +// Unit test: A* pathfinding std::find on priority queue vector (CWE-407) +// +// DEFECT: In astarsearch.cpp, the A* inner loop calls +// std::push_heap(pq.begin(), std::find(pq.begin(), pq.end(), index), ...) +// to perform decrease-key on a node already in the open set. +// std::find scans the entire pq vector, making the per-relaxation cost O(Q) +// where Q is the priority queue size. On hex maps with varied terrain costs, +// the same hex gets relaxed multiple times, triggering the linear scan. +// +// FIX: Use lazy deletion. Always push a new entry on relaxation. When +// popping, skip nodes that have already been finalized (in == search_counter). +// This eliminates the std::find entirely. +// +// BUILD: g++ -std=c++17 -O2 -o wesnoth-0001-test wesnoth-0001-test.cpp && ./wesnoth-0001-test + +#include +#include +#include +#include +#include +#include +#include + +static const unsigned SEARCH_COUNTER_BASE = 100; + +struct Node { + double g; + double t; + int prev; + unsigned in; + Node() : g(1e25), t(1e25), prev(-1), in(0) {} +}; + +struct Comp { + const std::vector& nodes; + Comp(const std::vector& n) : nodes(n) {} + bool operator()(int a, int b) const { + return nodes[b].t < nodes[a].t; + } +}; + +// Dense random graph to force many decrease-key operations +struct DenseGraph { + int n; // number of nodes + // adjacency: for each node, a list of (neighbor, cost) pairs + std::vector>> adj; + + void init(int nodes, int avg_edges_per_node, unsigned seed) { + n = nodes; + adj.resize(n); + std::mt19937 rng(seed); + std::uniform_int_distribution node_dist(0, n-1); + std::uniform_real_distribution cost_dist(1.0, 10.0); + + // Create a connected graph with many edges to force decrease-keys + // First create a chain + for (int i = 0; i < n - 1; i++) { + double c = cost_dist(rng); + adj[i].push_back({i+1, c}); + adj[i+1].push_back({i, c}); + } + // Then add random edges + for (int i = 0; i < n * avg_edges_per_node; i++) { + int u = node_dist(rng); + int v = node_dist(rng); + if (u != v) { + double c = cost_dist(rng); + adj[u].push_back({v, c}); + } + } + } +}; + +// DEFECTIVE: uses std::find for decrease-key +long long run_defective(const DenseGraph& g, int src, int dst, double* out_cost) { + unsigned search_counter = SEARCH_COUNTER_BASE; + std::vector nodes(g.n); + Comp comp(nodes); + + nodes[src].g = 0; + nodes[src].t = 0; + nodes[src].in = search_counter + 1; + + std::vector pq; + pq.push_back(src); + + long long ops = 0; + long long decrease_keys = 0; + + while (!pq.empty()) { + int front_idx = pq.front(); + nodes[front_idx].in = search_counter; + std::pop_heap(pq.begin(), pq.end(), comp); + pq.pop_back(); + + Node& n = nodes[front_idx]; + if (n.g > 1e24) break; + + for (auto& [nb, edge_cost] : g.adj[front_idx]) { + double cost = n.g + edge_cost; + if (nodes[nb].in == search_counter) continue; + if (cost >= nodes[nb].g) continue; + + bool in_list = nodes[nb].in == search_counter + 1; + + nodes[nb].g = cost; + nodes[nb].t = cost; + nodes[nb].prev = front_idx; + nodes[nb].in = search_counter + 1; + + if (in_list) { + decrease_keys++; + // DEFECT: linear scan to find position in pq + auto it = std::find(pq.begin(), pq.end(), nb); + ops += (long long)(it - pq.begin()) + 1; + if (it != pq.end()) { + std::push_heap(pq.begin(), it + 1, comp); + } + } else { + pq.push_back(nb); + std::push_heap(pq.begin(), pq.end(), comp); + ops++; + } + } + } + + *out_cost = nodes[dst].g; + return ops; +} + +// FIXED: lazy deletion, no std::find +long long run_fixed(const DenseGraph& g, int src, int dst, double* out_cost) { + unsigned search_counter = SEARCH_COUNTER_BASE; + std::vector nodes(g.n); + Comp comp(nodes); + + nodes[src].g = 0; + nodes[src].t = 0; + nodes[src].in = search_counter + 1; + + std::vector pq; + pq.push_back(src); + + long long ops = 0; + + while (!pq.empty()) { + std::pop_heap(pq.begin(), pq.end(), comp); + int front_idx = pq.back(); + pq.pop_back(); + + Node& n = nodes[front_idx]; + + if (n.in == search_counter) continue; + n.in = search_counter; + + if (n.g > 1e24) break; + + for (auto& [nb, edge_cost] : g.adj[front_idx]) { + double cost = n.g + edge_cost; + if (nodes[nb].in == search_counter) continue; + if (cost >= nodes[nb].g) continue; + + nodes[nb].g = cost; + nodes[nb].t = cost; + nodes[nb].prev = front_idx; + nodes[nb].in = search_counter + 1; + + pq.push_back(nb); + std::push_heap(pq.begin(), pq.end(), comp); + ops++; + } + } + + *out_cost = nodes[dst].g; + return ops; +} + +int main() { + printf("wesnoth-0001-test: A* pathfinding std::find on pq (CWE-407)\n\n"); + + // Test 1: Correctness + { + printf("Test 1: correctness on small dense graph\n"); + DenseGraph g; + g.init(100, 10, 42); + + double cost_d, cost_f; + run_defective(g, 0, 99, &cost_d); + run_fixed(g, 0, 99, &cost_f); + + assert(std::abs(cost_d - cost_f) < 1e-9); + printf(" PASS (both find cost %.2f)\n", cost_d); + } + + // Test 2: Performance on medium graph + { + printf("\nTest 2: performance on dense graph (N=2000, E~20/node)\n"); + DenseGraph g; + g.init(2000, 20, 123); + + double cost_d, cost_f; + long long ops_defect = run_defective(g, 0, 1999, &cost_d); + long long ops_fixed = run_fixed(g, 0, 1999, &cost_f); + + assert(std::abs(cost_d - cost_f) < 1e-9); + + double ratio = (double)ops_defect / (double)ops_fixed; + + printf(" defective ops: %lld\n", ops_defect); + printf(" fixed ops: %lld\n", ops_fixed); + printf(" ratio: %.1fx\n", ratio); + printf(" cost: %.2f (both agree)\n", cost_d); + assert(ratio > 5.0); + printf(" PASS (ratio > 5x)\n"); + } + + // Test 3: Performance on larger graph + { + printf("\nTest 3: performance on dense graph (N=5000, E~20/node)\n"); + DenseGraph g; + g.init(5000, 20, 456); + + double cost_d, cost_f; + long long ops_defect = run_defective(g, 0, 4999, &cost_d); + long long ops_fixed = run_fixed(g, 0, 4999, &cost_f); + + assert(std::abs(cost_d - cost_f) < 1e-9); + + double ratio = (double)ops_defect / (double)ops_fixed; + + printf(" defective ops: %lld\n", ops_defect); + printf(" fixed ops: %lld\n", ops_fixed); + printf(" ratio: %.1fx\n", ratio); + printf(" cost: %.2f (both agree)\n", cost_d); + assert(ratio > 10.0); + printf(" PASS (ratio > 10x)\n"); + } + + printf("\nAll tests PASSED.\n"); + return 0; +} diff --git a/defects/wesnoth-0002/patch/wesnoth-0002.patch b/defects/wesnoth-0002/patch/wesnoth-0002.patch new file mode 100644 index 000000000..f3bb18e59 --- /dev/null +++ b/defects/wesnoth-0002/patch/wesnoth-0002.patch @@ -0,0 +1,63 @@ +# UNDF: UNDF-2026-000000971 +--- a/src/server/wesnothd/server.hpp ++++ b/src/server/wesnothd/server.hpp +@@ -111,6 +111,18 @@ private: + struct connection_log + { + std::string nick, ip; ++ std::chrono::system_clock::time_point log_off; ++ ++ bool operator==(const connection_log& c) const ++ { ++ // log off time does not matter to find ip-nick pairs ++ return c.nick == nick && c.ip == ip; ++ } ++ }; ++ ++ struct connection_log_hash ++ { ++ std::size_t operator()(const connection_log& c) const + { ++ std::size_t h1 = std::hash{}(c.nick); ++ std::size_t h2 = std::hash{}(c.ip); ++ return h1 ^ (h2 << 1); + } + }; + +- std::deque ip_log_; ++ // Use an unordered_set for O(1) lookup instead of linear scan on deque. ++ // Maintain a deque alongside for LRU eviction order. ++ std::deque ip_log_; ++ std::unordered_set ip_log_set_; + +--- a/src/server/wesnothd/server.cpp ++++ b/src/server/wesnothd/server.cpp +@@ -896,7 +896,7 @@ void server::handle_new_client(socket_ptr socket) + connection_log ip_name { username, client_address(socket), {} }; + +- if(std::find(ip_log_.begin(), ip_log_.end(), ip_name) == ip_log_.end()) { ++ if(ip_log_set_.find(ip_name) == ip_log_set_.end()) { + ip_log_.push_back(ip_name); ++ ip_log_set_.insert(ip_name); + + // Remove the oldest entry if the size of the IP log exceeds the maximum size + if(ip_log_.size() > max_ip_log_size_) { ++ ip_log_set_.erase(ip_log_.front()); + ip_log_.pop_front(); + } + } + +@@ -2279,7 +2279,7 @@ void server::remove_player(player_iterator iter) + connection_log ip_name { iter->info().name(), ip, {} }; + +- auto i = std::find(ip_log_.begin(), ip_log_.end(), ip_name); ++ auto i = ip_log_set_.find(ip_name); +- if(i != ip_log_.end()) { ++ if(i != ip_log_set_.end()) { +- i->log_off = std::chrono::system_clock::now(); ++ // Update log_off in the deque entry ++ auto di = std::find(ip_log_.begin(), ip_log_.end(), ip_name); ++ if(di != ip_log_.end()) { ++ di->log_off = std::chrono::system_clock::now(); ++ } + } diff --git a/defects/wesnoth-0002/test/wesnoth-0002-test b/defects/wesnoth-0002/test/wesnoth-0002-test new file mode 100755 index 000000000..75893ed01 Binary files /dev/null and b/defects/wesnoth-0002/test/wesnoth-0002-test differ diff --git a/defects/wesnoth-0002/test/wesnoth-0002-test.cpp b/defects/wesnoth-0002/test/wesnoth-0002-test.cpp new file mode 100644 index 000000000..7a4d8c3ea --- /dev/null +++ b/defects/wesnoth-0002/test/wesnoth-0002-test.cpp @@ -0,0 +1,174 @@ +// wesnoth-0002-test.cpp +// Unit test: Server ip_log_ deque linear scan (CWE-407) +// +// DEFECT: In server.cpp, every player login checks ip_log_ (a deque of up to +// 500 connection_log entries) via std::find for duplicate IP/nick pairs. +// Every player logoff also does std::find to update log_off time. +// Both are O(N) where N is the log size (default max 500). +// Under heavy login/logoff traffic, this becomes O(L * N) where L is the +// number of login events. +// +// FIX: Add an unordered_set alongside the deque for O(1) +// membership lookup. Keep the deque for LRU eviction order. +// +// BUILD: g++ -std=c++17 -O2 -o wesnoth-0002-test wesnoth-0002-test.cpp && ./wesnoth-0002-test + +#include +#include +#include +#include +#include +#include +#include + +struct connection_log { + std::string nick, ip; + + bool operator==(const connection_log& c) const { + return c.nick == nick && c.ip == ip; + } +}; + +struct connection_log_hash { + std::size_t operator()(const connection_log& c) const { + std::size_t h1 = std::hash{}(c.nick); + std::size_t h2 = std::hash{}(c.ip); + return h1 ^ (h2 << 1); + } +}; + +// DEFECTIVE: linear scan on deque +struct DefectiveIpLog { + std::deque ip_log_; + std::size_t max_size_; + + DefectiveIpLog(std::size_t max_sz) : max_size_(max_sz) {} + + long long login(const std::string& nick, const std::string& ip) { + connection_log entry{nick, ip}; + long long ops = 0; + + // Linear scan + auto it = ip_log_.begin(); + for (; it != ip_log_.end(); ++it) { + ops++; + if (*it == entry) break; + } + + if (it == ip_log_.end()) { + ip_log_.push_back(entry); + if (ip_log_.size() > max_size_) { + ip_log_.pop_front(); + } + } + return ops; + } +}; + +// FIXED: unordered_set for O(1) lookup +struct FixedIpLog { + std::deque ip_log_; + std::unordered_set ip_log_set_; + std::size_t max_size_; + + FixedIpLog(std::size_t max_sz) : max_size_(max_sz) {} + + long long login(const std::string& nick, const std::string& ip) { + connection_log entry{nick, ip}; + long long ops = 1; // hash lookup = 1 op + + if (ip_log_set_.find(entry) == ip_log_set_.end()) { + ip_log_.push_back(entry); + ip_log_set_.insert(entry); + if (ip_log_.size() > max_size_) { + ip_log_set_.erase(ip_log_.front()); + ip_log_.pop_front(); + } + } + return ops; + } +}; + +int main() { + printf("wesnoth-0002-test: server ip_log_ deque linear scan (CWE-407)\n\n"); + + const int MAX_LOG = 500; + const int NUM_LOGINS = 2000; + + // Test 1: Correctness + { + printf("Test 1: correctness\n"); + DefectiveIpLog defective(MAX_LOG); + FixedIpLog fixed(MAX_LOG); + + for (int i = 0; i < NUM_LOGINS; i++) { + std::string nick = "user" + std::to_string(i % 600); + std::string ip = "10.0." + std::to_string((i / 256) % 256) + "." + std::to_string(i % 256); + defective.login(nick, ip); + fixed.login(nick, ip); + } + + // Both should have same entries + assert(defective.ip_log_.size() == fixed.ip_log_.size()); + for (size_t i = 0; i < defective.ip_log_.size(); i++) { + assert(defective.ip_log_[i] == fixed.ip_log_[i]); + } + printf(" PASS\n"); + } + + // Test 2: Performance + { + printf("\nTest 2: performance with %d logins, max_log=%d\n", NUM_LOGINS, MAX_LOG); + + DefectiveIpLog defective(MAX_LOG); + FixedIpLog fixed(MAX_LOG); + + long long defect_ops = 0; + long long fixed_ops = 0; + + for (int i = 0; i < NUM_LOGINS; i++) { + // Mix of new and repeat logins + std::string nick = "player" + std::to_string(i % 700); + std::string ip = "192.168." + std::to_string((i / 256) % 256) + "." + std::to_string(i % 256); + defect_ops += defective.login(nick, ip); + fixed_ops += fixed.login(nick, ip); + } + + double ratio = (double)defect_ops / (double)fixed_ops; + + printf(" defective ops: %lld\n", defect_ops); + printf(" fixed ops: %lld\n", fixed_ops); + printf(" ratio: %.1fx\n", ratio); + assert(ratio > 50.0); + printf(" PASS (ratio > 50x)\n"); + } + + // Test 3: Worst case (all unique logins filling the log) + { + printf("\nTest 3: worst case, all unique logins\n"); + + DefectiveIpLog defective(MAX_LOG); + FixedIpLog fixed(MAX_LOG); + + long long defect_ops = 0; + long long fixed_ops = 0; + + for (int i = 0; i < NUM_LOGINS; i++) { + std::string nick = "unique_user_" + std::to_string(i); + std::string ip = "10." + std::to_string((i / 65536) % 256) + "." + std::to_string((i / 256) % 256) + "." + std::to_string(i % 256); + defect_ops += defective.login(nick, ip); + fixed_ops += fixed.login(nick, ip); + } + + double ratio = (double)defect_ops / (double)fixed_ops; + + printf(" defective ops: %lld\n", defect_ops); + printf(" fixed ops: %lld\n", fixed_ops); + printf(" ratio: %.1fx\n", ratio); + assert(ratio > 100.0); + printf(" PASS (ratio > 100x)\n"); + } + + printf("\nAll tests PASSED.\n"); + return 0; +} diff --git a/defects/wesnoth-0003/patch/wesnoth-0003.patch b/defects/wesnoth-0003/patch/wesnoth-0003.patch new file mode 100644 index 000000000..54097c4f9 --- /dev/null +++ b/defects/wesnoth-0003/patch/wesnoth-0003.patch @@ -0,0 +1,67 @@ +# UNDF: UNDF-2026-000000972 +--- a/src/units/types.cpp ++++ b/src/units/types.cpp +@@ -458,10 +458,11 @@ std::vector unit_type::special_notes() const { + + static void append_special_note(std::vector& notes, const t_string& new_note) { + if(new_note.empty()) return; + std::string_view note_plain = new_note.c_str(); + utils::trim(note_plain); + if(note_plain.empty()) return; +- if(utils::contains(notes, new_note)) return; + notes.push_back(new_note); + } + +-std::vector combine_special_notes(const std::vector& direct, const config& abilities, const const_attack_itors& attacks, const movetype& mt) ++std::vector combine_special_notes(const std::vector& direct, const config& abilities, const const_attack_itors& attacks, const movetype& mt) + { +- std::vector notes; ++ std::vector notes; ++ // Use a set to track seen notes for O(1) dedup instead of ++ // O(N) linear scan via utils::contains on vector per insertion. ++ std::set seen; + for(const auto& note : direct) { +- append_special_note(notes, note); ++ std::string key(note.c_str()); ++ if(!key.empty() && seen.insert(key).second) { ++ notes.push_back(note); ++ } + } + for(const auto [key, cfg] : abilities.all_children_view()) { + if(cfg.has_attribute("special_note")) { +- append_special_note(notes, cfg["special_note"].t_str()); ++ const t_string& sn = cfg["special_note"].t_str(); ++ std::string k(sn.c_str()); ++ if(!k.empty() && seen.insert(k).second) { ++ notes.push_back(sn); ++ } + } + } + for(const auto& attack : attacks) { + for(const auto& p_ab : attack.specials()) { + if(p_ab->cfg().has_attribute("special_note")) { +- append_special_note(notes, p_ab->cfg()["special_note"].t_str()); ++ const t_string& sn = p_ab->cfg()["special_note"].t_str(); ++ std::string k(sn.c_str()); ++ if(!k.empty() && seen.insert(k).second) { ++ notes.push_back(sn); ++ } + } + } + if(auto attack_type_note = string_table.find("special_note_damage_type_" + attack.type()); attack_type_note != string_table.end()) { +- append_special_note(notes, attack_type_note->second); ++ std::string k(attack_type_note->second.c_str()); ++ if(!k.empty() && seen.insert(k).second) { ++ notes.push_back(attack_type_note->second); ++ } + } + } + for(const auto& move_note : mt.special_notes()) { +- append_special_note(notes, move_note); ++ std::string k(move_note.c_str()); ++ if(!k.empty() && seen.insert(k).second) { ++ notes.push_back(move_note); ++ } + } + return notes; + } diff --git a/defects/wesnoth-0003/test/wesnoth-0003-test b/defects/wesnoth-0003/test/wesnoth-0003-test new file mode 100755 index 000000000..66cf48bcd Binary files /dev/null and b/defects/wesnoth-0003/test/wesnoth-0003-test differ diff --git a/defects/wesnoth-0003/test/wesnoth-0003-test.cpp b/defects/wesnoth-0003/test/wesnoth-0003-test.cpp new file mode 100644 index 000000000..d710ee1bd --- /dev/null +++ b/defects/wesnoth-0003/test/wesnoth-0003-test.cpp @@ -0,0 +1,141 @@ +// wesnoth-0003-test.cpp +// Unit test: combine_special_notes O(N^2) dedup (CWE-407) +// +// DEFECT: In types.cpp, combine_special_notes calls append_special_note for +// every note from direct notes, abilities, attack specials, damage types, +// and movement type. Each call does utils::contains(notes, new_note) which +// is std::find on a vector, making the total cost O(N^2) where N is the +// total number of note insertions. +// +// FIX: Use a std::set to track seen notes for O(log N) dedup +// (or unordered_set for O(1)). This reduces total cost to O(N log N). +// +// BUILD: g++ -std=c++17 -O2 -o wesnoth-0003-test wesnoth-0003-test.cpp && ./wesnoth-0003-test + +#include +#include +#include +#include +#include +#include + +// Simulate the defect: O(N) contains check per insertion +long long defective_combine(const std::vector& input) { + std::vector notes; + long long ops = 0; + + for (const auto& note : input) { + if (note.empty()) continue; + // Linear scan for dedup + bool found = false; + for (const auto& existing : notes) { + ops++; + if (existing == note) { found = true; break; } + } + if (!found) { + notes.push_back(note); + } + } + return ops; +} + +// Fixed: set-based dedup +long long fixed_combine(const std::vector& input) { + std::vector notes; + std::set seen; + long long ops = 0; + + for (const auto& note : input) { + if (note.empty()) continue; + ops++; // set insertion/lookup + if (seen.insert(note).second) { + notes.push_back(note); + } + } + return ops; +} + +int main() { + printf("wesnoth-0003-test: combine_special_notes O(N^2) dedup (CWE-407)\n\n"); + + // Test 1: Correctness + { + printf("Test 1: correctness\n"); + std::vector input = { + "Poison attack", "First strike", "Poison attack", + "Regenerates", "First strike", "Skirmisher", + "Regenerates", "Marksman", "" + }; + + // Defective approach + std::vector defect_result; + for (const auto& note : input) { + if (note.empty()) continue; + bool found = false; + for (const auto& e : defect_result) { + if (e == note) { found = true; break; } + } + if (!found) defect_result.push_back(note); + } + + // Fixed approach + std::vector fixed_result; + std::set seen; + for (const auto& note : input) { + if (note.empty()) continue; + if (seen.insert(note).second) { + fixed_result.push_back(note); + } + } + + assert(defect_result == fixed_result); + printf(" PASS (same output: %zu unique notes)\n", defect_result.size()); + } + + // Test 2: Performance with many notes (simulating unit with many abilities) + { + const int N = 500; + printf("\nTest 2: performance with N=%d notes (50%% duplicates)\n", N); + + std::vector input; + for (int i = 0; i < N; i++) { + input.push_back("special_note_" + std::to_string(i % (N / 2))); + } + + long long defect_ops = defective_combine(input); + long long fixed_ops = fixed_combine(input); + + double ratio = (double)defect_ops / (double)fixed_ops; + + printf(" defective ops: %lld\n", defect_ops); + printf(" fixed ops: %lld\n", fixed_ops); + printf(" ratio: %.1fx\n", ratio); + assert(ratio > 50.0); + printf(" PASS (ratio > 50x)\n"); + } + + // Test 3: All unique notes (worst case for defective) + { + const int N = 1000; + printf("\nTest 3: worst case, N=%d all unique notes\n", N); + + std::vector input; + for (int i = 0; i < N; i++) { + input.push_back("unique_note_" + std::to_string(i)); + } + + long long defect_ops = defective_combine(input); + long long fixed_ops = fixed_combine(input); + + double ratio = (double)defect_ops / (double)fixed_ops; + + printf(" defective ops: %lld\n", defect_ops); + printf(" fixed ops: %lld\n", fixed_ops); + printf(" ratio: %.1fx\n", ratio); + assert(ratio > 100.0); + printf(" PASS (ratio > 100x)\n"); + } + + printf("\nAll tests PASSED.\n"); + return 0; +}