Escape Room Booking: Turnover, Leaderboards and Versus Mode
MediumAI-assisted coding · AI-Assisted Debugging
Escape Room Booking: Turnover, Leaderboards and Versus Mode
You've joined the small team that writes the booking back-end for Lockstep Escape, a studio with four themed rooms. The front desk app sends operations to this service. Last Saturday two parties were put into the Lighthouse within 20 minutes of each other, a five-player group was quoted 150, and a customer's gift card was accepted on the minute it expired. Fixing that is Part 1. After that, the studio wants game results with a weekly leaderboard (Part 2), and a "versus" mode with rescheduling (Part 3).
All money is integer cents. All times are whole minutes since Monday 00:00 of week 0 (so week w starts at minute w * 10080).
Codebase
| File | What it does |
|---|---|
escape/api.py | run(operations) entry point and the Dispatcher that routes each operation |
escape/bookings.py | BookingService: quotes, bookings, cancellations, gift-card issue |
escape/slots.py | SlotBook: which room is occupied when |
escape/pricing.py | Party-size bands, private bookings and the minimum charge |
escape/giftcards.py | GiftCardVault: balances, expiry, partial draws and refunds |
escape/results.py | ResultBoard: result and leaderboard routes (stubs, answer "not supported") |
escape/models.py | Room, GiftCard, Booking |
escape/config.py | The rooms, price bands, minimum charge and other constants |
escape/argcheck.py, escape/errors.py | Argument validation helpers; every error code |
tests/ | Existing unit tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh studio and returns one result per operation. Every operation is [name, ts, ...args], where ts is the current time in minutes and never decreases. Errors are returned, never raised, as "error: <code>" (for example "error: slot unavailable").
| Operation | Arguments after ts | Returns |
|---|---|---|
issue_card | card_id, amount, expires_at | true |
card_balance | card_id | the card's balance in cents |
quote | room, players, private | price in cents |
book | room, start, players, private, team, cards | booking id |
cancel | booking_id | true |
booking | booking_id | booking details (below) |
- A non-list operation, a non-string name, a non-integer
tsor the wrong number of arguments returns"error: invalid arguments"; an unknown name returns"error: unknown operation". Argument types are checked by the handlers (ids and names are non-empty strings,startis an integer>= 0,playersan integer>= 1,privatea real boolean,cardsa list of distinct card ids). Any violation isinvalid arguments.true/falseare never integers. - Booking ids are
"bk-1","bk-2", ... in the order bookings succeed; failed calls never use one up. bookingreturns{"id", "status", "room", "team", "players", "private", "start", "ends", "free_at", "price", "paid", "due", "cards"}:ends = start + 60,free_at = ends + the room's reset,paidis the total drawn from gift cards,due = price - paidis paid at the door, andcardslists[card_id, cents]per card actually drawn on, in draw order.statusis"booked"or"cancelled".
Part 1: Stop the double bookings (debugging)
Goal: the booking engine runs, but it gets three rules wrong. Make it follow the rules below. Read the code to learn how everything else already works.
- Turnover. A game lasts 60 minutes, then the crew resets the room. A booking occupies its room from
startup to (not including)start + 60 + reset, whereresetbelongs to the room (vault15,cellar15,observatory20,lighthouse25 minutes). Two bookings in the same room may not overlap, buffers included, on both sides; a booking may start exactly at the previous one'sfree_at. Different rooms never interfere. - Pricing. Price =
n * rate(n), never less than the minimum charge of 12,000 cents, whererateis the per-player rate of the band containingn(2-3, 4-5 and 6-8 players, seeconfig.py; band edges are inclusive). For a normal bookingnis the party size; a party larger than the room's maximum isparty too large, smaller than its minimum isparty too small. A private booking may be smaller than the room's minimum: it is priced as ifmin_playerswere coming. - Gift cards. A card is usable strictly before its
expires_at(atts == expires_atit is expired).booklists cards to draw on; each is drawn in order for as much as it can cover, and the rest isdue. Every listed card must exist and be unexpired, even if an earlier card already covers the price.cancelreturns each drawn amount to its card, even if the card has since expired.
Checks in book, in this order: invalid arguments, unknown room, party too large / party too small, in the past (start < ts), slot unavailable, card not found, card expired. A failed book changes nothing. issue_card checks invalid arguments, invalid amount (an integer > 0), bad expiry (expires_at <= ts), card exists. quote checks invalid arguments, unknown room, then the party-size errors. card_balance fails with card not found. cancel fails with booking not found or booking not active (already cancelled).
Example
[["issue_card", 0, "g1", 5000, 500],
["quote", 0, "observatory", 5, false],
["book", 1, "lighthouse", 600, 5, false, "Ada", ["g1"]],
["booking", 2, "bk-1"],
["book", 3, "lighthouse", 684, 2, false, "Bo", []],
["book", 3, "lighthouse", 685, 2, false, "Bo", []],
["book", 3, "lighthouse", 516, 2, false, "Cy", []]]returns [true, 15000, "bk-1", {...}, "error: slot unavailable", "bk-2", "error: slot unavailable"] where the details are {"id": "bk-1", "status": "booked", "room": "lighthouse", "team": "Ada", "players": 5, "private": false, "start": 600, "ends": 660, "free_at": 685, "price": 15000, "paid": 5000, "due": 10000, "cards": [["g1", 5000]]}. The lighthouse is busy until 685, and a game at 516 would need the room reset until 601.
Part 2: Results and the weekly leaderboard (feature)
Goal: record how each game went and rank the fastest teams per room and week. Make record_result, leaderboard and backfill real (they live in results.py).
["record_result", ts, booking_id, outcome, seconds, hints]returnstrue.outcomeis"escaped"or"failed";secondsis an integer in1..3600andhintsan integer>= 0(both are validated even for a failed game, whosesecondsis otherwise ignored). The booking's status becomes"completed".- Errors in order:
invalid arguments,booking not found,booking not active(cancelled),result exists(already completed),game not finished(ts < ends;ts == endsis fine). Completed bookings cannot be cancelled (booking not active). - Score =
seconds + 120 * hints. Failed games never appear on a leaderboard. ["leaderboard", ts, room, week, k]returns the bestkgames of that room whose start falls in[week * 10080, (week + 1) * 10080)(a game starting at 10079 is in week 0 even though it ends in week 1), as[team, score, hints, booking_id], lowest score first. Ties: fewer hints, then earlierstart. Errors:invalid arguments(week >= 0,k >= 1), thenunknown room.["backfill", ts, room, first_start, count, seed]importscounthistoric solo games that ran back to back in one room and returnscount. Gamei(from 0) starts atfirst_start + i * (60 + reset), has team"bf-<i>",room.min_playersplayers, is not private, uses no gift cards and has status"completed"(so noin the pastorgame not finishedchecks apply). It takes the next booking id like any booking. The result comes from a generator:x = seed; for each game draw three times withx = (x * 1103515245 + 12345) % 2147483648and letd1, d2, d3be the three valuesx >> 8; the game hasseconds = 1200 + d1 % 2400,hints = d2 % 4, and isfailedwhend3 % 7 == 0, otherwiseescaped. If any of those games would collide with an existing booking (buffers included) the answer isslot unavailableand nothing is imported. Arguments: `first_start
>= 0, count >= 1, seed >= 0; unknown room comes after invalid arguments`.
- Hidden tests backfill tens of thousands of games and then book, cancel and query around them, and the whole suite has a few seconds.
Example (all in the cellar, which resets in 15 minutes)
[["book", 0, "cellar", 100, 4, false, "Ada", []],
["book", 0, "cellar", 175, 4, false, "Bo", []],
["book", 0, "cellar", 250, 4, false, "Cy", []],
["record_result", 400, "bk-1", "escaped", 2700, 1],
["record_result", 400, "bk-2", "escaped", 2820, 0],
["record_result", 400, "bk-3", "failed", 3600, 0],
["leaderboard", 400, "cellar", 0, 10]]returns ["bk-1", "bk-2", "bk-3", true, true, true, [["Bo", 2820, 0, "bk-2"], ["Ada", 2820, 1, "bk-1"]]]. Both scores are 2820; Bo used fewer hints.
Part 3: Versus mode and rescheduling (change request)
Goal: the owners want a versus mode, and customers want to move bookings. This changes Part 1's assumption that a booking occupies exactly one room.
The linked rooms are observatory and lighthouse (twins; see LINKS in config.py, add it if missing). A *versus booking* has two teams who race in the twin rooms at the same time.
["book_versus", ts, room, start, players_a, players_b, team_a, team_b, cards]returns a booking id. Team A plays inroom, team B in its twin. Both rooms must be free for[start, start + 60 + own reset)(each with its own reset). The booking is always private:price= the price of A's party inroomplus the price of B's party in the twin, each computed exactly as in Part 1 withprivate = true. Cards pay the total as in Part 1. Checks in order:invalid arguments,unknown room,room not linked,party too large(A, then B),in the past,slot unavailable,card not found,card expired. Failed calls change nothing.bookingfor a versus booking reports team A's details as before (room,team,players,free_atforroom),priceis the total, and one extra key"versus": {"room", "team", "players", "free_at"}for team B. Solo bookings do not get the key.cancelfrees both rooms.["record_race", ts, booking_id, result_a, result_b], each result[outcome, seconds, hints]validated likerecord_result. Errors in order:invalid arguments,booking not found,not a versus booking, thenbooking not active,result exists,game not finishedas in Part 2.record_resulton a versus booking isnot a solo booking.- Race rule (different from the solo rule): a race score is
seconds + 180 * hints. The race's winner is the team that escaped with the lower score (ties: fewer hints, then team A); if only one team escaped it wins; if neither escaped the race has no entry. Only winners are ranked.["race_leaderboard", ts, week, k]returns[team, score, hints, booking_id]rows, best first (ties: fewer hints, then earlier start), for races starting in that week. Races never appear on the sololeaderboard, and solo games never appear on the race board. ["reschedule", ts, booking_id, new_room, new_start]moves a booking and returns the same booking id. For a versus bookingnew_roomis where team A plays and team B moves to its twin. It keeps its teams, party sizes, private flag and gift-card payment: nothing is re-drawn and card expiry is not re-checked. The price is recomputed for the new room(s) with the same party sizes. Then: if the cards paid more than the new price, the excess is returned to the cards, latest draw first, each by at most what it paid (a card's balance increases even if it has expired); if the new price is higher,duesimply grows. The booking's own old slot never blocks its new one (it may move 10 minutes later, overlapping its old self). Errors in order:invalid arguments,booking not found,booking not active(cancelled or completed),unknown room,room not linked(versus only),party too large/party too small,in the past(new_start < ts),slot unavailable. A failed reschedule changes nothing.
Example
[["book_versus", 0, "observatory", 600, 3, 6, "Reds", "Blues", []],
["booking", 1, "bk-1"],
["book", 1, "lighthouse", 684, 2, false, "X", []],
["book", 1, "observatory", 680, 2, false, "Y", []],
["reschedule", 2, "bk-2", "lighthouse", 700],
["record_race", 700, "bk-1", ["escaped", 2500, 1], ["escaped", 2400, 2]],
["race_leaderboard", 700, 0, 5]]returns ["bk-1", {...}, "error: slot unavailable", "bk-2", "bk-2", true, [["Reds", 2680, 1, "bk-1"]]]. The booking details are {"id": "bk-1", "status": "booked", "room": "observatory", "team": "Reds", "players": 3, "private": true, "start": 600, "ends": 660, "free_at": 680, "price": 27600, "paid": 0, "due": 27600, "cards": [], "versus": {"room": "lighthouse", "team": "Blues", "players": 6, "free_at": 685}} (12,000 for 3 players, 15,600 for 6). Lighthouse is busy until 685 but observatory frees up at 680. Reds score 2500 + 180 = 2680, Blues 2400 + 360 = 2760, so Reds win.
Notes
models.py,argcheck.pyanderrors.pyare fine; some bugs live elsewhere.- Earlier Parts' behaviour must keep working; hidden tests re-check them after the later Parts.
- You may use the AI assistant freely; you are expected to verify what it produces.
- Parts 1 and 2 are a solid result; Part 3 is what separates strong candidates.
Example 1
- Input:
- [["issue_card",0,"g1",5000,500],["quote",0,"observatory",5,false],["book",1,"lighthouse",600,5,false,"Ada",["g1"]],["book",3,"lighthouse",684,2,false,"Bo",[]],["book",3,"lighthouse",685,2,false,"Bo",[]],["book",3,"lighthouse",516,2,false,"Cy",[]]]
- Output:
- [true,15000,"bk-1","error: slot unavailable","bk-2","error: slot unavailable"]
- Explanation
- Five players pay the 4-5 band rate (5 x $30). The lighthouse is occupied until 685 (60 minutes plus its 25-minute reset), so 684 is rejected and 685 is fine; a game at 516 would need the room reset until 601.
Example 2
- Input:
- [["book",0,"cellar",100,4,false,"Ada",[]],["book",0,"cellar",175,4,false,"Bo",[]],["book",0,"cellar",250,4,false,"Cy",[]],["record_result",400,"bk-1","escaped",2700,1],["record_result",400,"bk-2","escaped",2820,0],["record_result",400,"bk-3","failed",3600,0],["leaderboard",400,"cellar",0,10]]
- Output:
- ["bk-1","bk-2","bk-3",true,true,true,[["Bo",2820,0,"bk-2"],["Ada",2820,1,"bk-1"]]]
- Explanation
- Ada's score is 2700 + 120 = 2820, the same as Bo's, but Bo used fewer hints so ranks first. Cy failed and is excluded.
Example 3
- Input:
- [["book_versus",0,"observatory",600,3,6,"Reds","Blues",[]],["book",1,"lighthouse",684,2,false,"X",[]],["book",1,"observatory",680,2,false,"Y",[]],["record_race",700,"bk-1",["escaped",2500,1],["escaped",2400,2]],["race_leaderboard",700,0,5]]
- Output:
- ["bk-1","error: slot unavailable","bk-2",true,[["Reds",2680,1,"bk-1"]]]
- Explanation
- The versus booking holds both twin rooms; the lighthouse is busy until 685 but the observatory frees up at 680. Race scores use 180 seconds per hint: Reds 2500 + 180 = 2680 beats Blues 2400 + 360 = 2760.
Guidelines
- Python 3 standard library only
- Money is integer cents and time is integer minutes since Monday 00:00 of week 0; no floating point anywhere
- Timestamps never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Failed operations must not change any state (no half-made booking, held slot or drawn gift card)
- Part 2 inputs backfill thousands of games into one room and then book and query around them