Solving Azur Lane's Auction Game
I got obsessed with a minigame then I tried to solve it.
I got hooked during Azur Lane’s Top Bidder event. I even pulled an all nighter that one Saturday night to play the mini game. Once I hit my goal of getting 100M tokens (the minigame currency), I was then more interested in building something to solve the game. It may or may not be live by the time you read this at Azur Lane Bidder.
The game
The auction is a four-player price-estimation game.
A hidden warehouse contains items which belong to a fixed catalog of known prices, rarities, and rectangular sizes.
In each of the five bidding rounds, there is a ‘global clue’ which all players get, and each player can then select a clue only they can see out of a set of 3. The clues are, for example:
- The number of SR items
- The average/total value of UR items
- The total/average number of slots occupied by Elite items
- The average/total value of items of size 2x3
- The highest value per slot/among all SR items/among items of size 1x2
- The total number of slots occupied
- The reveal of either location or all information of items of size/rarity.
Everyone is trying to buy the whole warehouse for less than its real value. A large enough lead can end one of the early rounds immediately; otherwise the highest bidder in the fifth round wins.1 The tension comes from bidding while nobody has the complete picture.
Getting more out of each clue
Every possible item already has a known price, rarity, and size.
While playing the game, I eventually noticed that the game gives us more information than it first appears to.
Suppose the catalog has possible items. Let their prices, occupied slot counts, and rarities be:
The only part we do not know is the quantity of each item in the warehouse:
Rarity and size only act as filters. When we got a clue, we apply them (the rarity/sizing filters) first, then relabel the remaining candidates from to . For example, 2x3 size filter means “fits inside a 2-by-3 boundary,” not “is exactly 2x3,” so it also keeps sizes such as 1x3 and 2x2.
After that filtering step, all the clue types reduce to a few one-liners:
Item count
As an example, “four SR items” means . It gives us an exact count, but not any prices. It also gives us the lower and upper bounds on their total value but I just discard that info. Compared to the other kinds of clues this one isn’t of much use.
Total value
A total-value clue gives us after applying its rarity or size filter. The solver searches for a combination with that total; the current implementation assumes solution uniqueness in practice and returns the first match.
Average value
Let the number of items be
Then the average equation can be rearranged into
Complexity-wise, this is the same linear problem as the total-value clue, with one extra variable: . In my implementation, I handle that extra variable by trying each possible value of rather than passing it to a general linear solver.
In practice, the two clues reveal the same amount of information: either one will usually leave the solver with a single plausible item set.
Total occupied slots
This is another filter over possible sets: keep only combinations whose item areas satisfy . By itself it narrows the possible mixture of sizes; combined with an item count, it becomes much stronger.
Average occupied slots
The average-slot clue is . It is among the less useful clues.
Highest value item
This is a simple catalog lookup after applying the clue’s rarity or size filter. If only one item has the displayed price, we know that item appears at least once, but the clue tells us little about the rest of the warehouse.
Highest value per slot
This is another lookup: divide each candidate’s price by the number of slots it occupies, then compare that ratio with the clue. A unique match identifies one item that appears at least once, but again gives us little information about the other items.
The search I ended up building
The most interesting clues are the averages (average prices of items of a rarity/size). Because the game drops the decimal part, the displayed value is a range rather than an exact equality: 513,399 can mean anything from 513,399 up to, but not including, 513,400.
Suppose the game shows an average of 513,399. Looking for a set whose average is exactly that integer will miss this perfectly valid combination of catalog-scale prices:
659,511 + 499,425 + 381,262 = 1,540,198
1,540,198 / 3 = 513,399.333333...
Once we guess that there are three items, that small range becomes a total between 1,540,197 and 1,540,199.
An obvious implementation would recursively try every possible quantity for every catalog item. Since each quantity can be 0, 1, 2, or 3, this creates assignments for candidates. That gets expensive very quickly, so I chose a branch-and-bound search. The basic idea is to stop exploring a branch as soon as we can prove it cannot reach the target.
The search is roughly:
-
**Filter the candidates: **Apply the rarity and size filters before doing any recursion.
-
Try each item count: For an average clue, try and convert the displayed average into a target range for that count.
-
Build completion bounds: For every candidate-list suffix and remaining item count, precompute the cheapest and most expensive completion. These two small tables are the dynamic-programming part of the implementation.
-
Prune branches: Add the current total to both completion bounds. If even the cheapest completion overshoots, or even the most expensive completion falls short, return immediately:
const searchMinimum = minimumTotal - tolerance; const searchMaximum = maximumTotal + tolerance; if ( currentTotal + minimumCompletion > searchMaximum || currentTotal + maximumCompletion < searchMinimum ) { return; }The
toleranceis a small margin around the truncation interval. It is measured in total-price units, so it does not grow with the number of items. A smaller value risks rejecting the real set; a larger one makes more branches look plausible. -
Stop condition: The first plausible match is returned because the game usually gives a practically unique combination. The search still has an exponential worst case, so it stops after 14,000,605 visited states and reports a truncated result. It runs in a Web Worker as well, so a difficult search does not freeze the interface.
Why not conventional dynamic programming?
A knapsack-style dynamic program would index states by item count and total price. That is awkward when prices reach into the millions. The average adds another complication because every possible item count has its own target range, and bounded repetitions add more state.
A sparse Map avoids allocating empty totals, but it still has to store every distinct reachable total, plus predecessor data if we want to reconstruct the items. With many differently priced candidates, that can grow very large, even though we only care about a tiny target interval.
The implementation therefore uses dynamic programming only for the cheapest and most expensive completion of a branch. It gets the useful pruning without remembering every exact total.
In hindsight: this is a MILP
Only after finishing the solver did I learn there was a name for this kind of problem: mixed-integer linear programming, or MILP. Each catalog item gets an integer quantity, and every count, price, and slot clue becomes a constraint. A MILP solver could put all the clues into one model, prove that no solution exists, or check whether a solution is unique.
I was not weighing MILP against branch-and-bound at the time. I simply did not know it was an option. I arrived at this search by following the shape of the game and building the pieces I understood.
Even knowing about MILP now, I would not automatically replace the custom solver. This is a small client-side app, so a general optimization engine has to be shipped to every browser. That likely means a comparatively large runtime, often compiled to WebAssembly, plus the loading and integration work. It is hard to justify that download for a bounded search that the current TypeScript implementation already handles well.
MILP would make more sense if I turn this into one global solver for every clue, or need formal uniqueness and minimum/maximum-value proofs. For the version I actually built, the small purpose-built search is a nice fit.
Conclusion
In the end, the solver is not built around an especially exotic algorithm. The satisfying part was making several small observations:
- The catalog turns the clues into a finite search.
- Rarity and shape remove candidates early.
- The displayed decimal precision gives us an interval, not an equality.
- Once we account for the item count, the average clue can reveal the full item set, just like the total-price clue.
- Min and max completion bounds tell us when a branch is hopeless.
- Early exit, a state ceiling, and a worker make the remaining search practical in a browser.
After I finished the solver, though, I was not left with much motivation to keep playing the game. Using it, I banked another 20M tokens on top of the 100M I had already earned, then never touched the game again.
Footnotes
-
The event structure and auction rules are summarized in the official Azur Lane update notes. Famitsu’s overview has a more detailed description of the information and bidding game. ↩