Skip to content
Pokered Save Editor 2
Pokemon Red & Blue save file editor - Qt 6 C++/QML
Loading...
Searching...
No Matches
itemexchangemodel.cpp
Go to the documentation of this file.
1/*
2 * Copyright 2026 Twilight
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15*/
16
32
33#include "./itemexchangemodel.h"
34
35#include <QCollator>
36#include <QSet>
37#include <algorithm>
38
41#include <pse-db/itemsdb.h>
43
44namespace {
45// Curated healing / status / drink item set for the Healing sub-tab (internal names
46// from items.json). UI filter only -- not save data.
47const QSet<QString>& healingNames()
48{
49 static const QSet<QString> s = {
50 "POTION", "SUPER POTION", "HYPER POTION", "MAX POTION", "FULL RESTORE",
51 "FULL HEAL", "REVIVE", "MAX REVIVE", "ANTIDOTE", "BURN HEAL", "ICE HEAL",
52 "AWAKENING", "PARLYZ HEAL", "ETHER", "MAX ETHER", "ELIXER", "MAX ELIXER",
53 "FRESH WATER", "SODA POP", "LEMONADE",
54 };
55 return s;
56}
57
58// Healing sub-tab default SOURCE preference (what you give). Potion family first -- a
59// player reaching for the Healing tab means potions long before Antidote.
60const QStringList& healingSourcePref()
61{
62 static const QStringList s = {
63 "POTION", "SUPER POTION", "HYPER POTION", "MAX POTION", "FULL RESTORE",
64 "FULL HEAL", "REVIVE", "MAX REVIVE",
65 "ELIXER", "MAX ELIXER", "ETHER", "MAX ETHER",
66 };
67 return s;
68}
69
70// Healing sub-tab default TARGET preference (what you get) -- the drinks, Fresh Water first.
71const QStringList& healingTargetPref()
72{
73 static const QStringList s = { "FRESH WATER", "SODA POP", "LEMONADE" };
74 return s;
75}
76
77int ceilDiv(int a, int b) { return (b <= 0) ? 0 : ((a + b - 1) / b); }
78}
79
81 ItemStorageBox* storage,
82 PlayerBasics* basics)
83 : bag(bag), storage(storage), basics(basics)
84{
85 // Cache each item's static info once (readable name, money buy price, healing flag).
86 for(auto entry : ItemsDB::inst()->getStore()) {
87 const int ind = entry->getInd();
88 if(ind < 0)
89 continue;
90 Info in;
91 in.name = entry->getName();
92 in.readable = entry->getReadable();
93 in.buy = entry->buyPriceMoney();
94 in.healing = healingNames().contains(in.name);
95 m_info.insert(ind, in);
96 }
97
98 // Keep the preview live as the save changes under us (checkout, or edits elsewhere).
99 if(bag) QObject::connect(bag, &ItemStorageBox::itemsChanged, this, &ItemExchangeModel::refresh);
100 if(storage) QObject::connect(storage, &ItemStorageBox::itemsChanged, this, &ItemExchangeModel::refresh);
101 if(basics) {
102 QObject::connect(basics, &PlayerBasics::moneyChanged, this, &ItemExchangeModel::refresh);
103 QObject::connect(basics, &PlayerBasics::coinsChanged, this, &ItemExchangeModel::refresh);
104 }
105}
106
107// ---- Static per-item lookups (from the cache) ------------------------------
108
109int ItemExchangeModel::buyOf(int ind) const
110{
111 auto it = m_info.constFind(ind);
112 return (it == m_info.constEnd()) ? 0 : it->buy;
113}
114
115QString ItemExchangeModel::readableOf(int ind) const
116{
117 auto it = m_info.constFind(ind);
118 return (it == m_info.constEnd()) ? QString() : it->readable;
119}
120
121bool ItemExchangeModel::isHealing(int ind) const
122{
123 auto it = m_info.constFind(ind);
124 return (it != m_info.constEnd()) && it->healing;
125}
126
127int ItemExchangeModel::indOfName(const QString& name) const
128{
129 for(auto it = m_info.constBegin(); it != m_info.constEnd(); ++it) {
130 if(it->name == name)
131 return it.key();
132 }
133 return -1;
134}
135
136void ItemExchangeModel::sortByName(QVariantList& list)
137{
138 QCollator col;
139 col.setNumericMode(true);
140 col.setCaseSensitivity(Qt::CaseInsensitive);
141 std::sort(list.begin(), list.end(), [&col](const QVariant& a, const QVariant& b) {
142 return col.compare(a.toMap().value("name").toString(),
143 b.toMap().value("name").toString()) < 0;
144 });
145}
146
147// ---- Live save reads -------------------------------------------------------
148
149int ItemExchangeModel::combinedAmount(int ind) const
150{
151 if(ind < 0)
152 return 0;
153 int total = 0;
154 if(bag) total += bag->amountOfInd(ind);
155 if(storage) total += storage->amountOfInd(ind);
156 return total;
157}
158
159int ItemExchangeModel::combinedCapacity(int ind) const
160{
161 if(ind < 0)
162 return 0;
163 int room = 0;
164 if(bag) room += bag->capacityForInd(ind);
165 if(storage) room += storage->capacityForInd(ind);
166 return room;
167}
168
170{
171 return basics ? static_cast<int>(basics->money) : 0;
172}
173
174// ---- Exchange math ---------------------------------------------------------
175
177{
178 return m_itemAInd >= 0 && m_itemBInd >= 0
179 && m_itemAInd != m_itemBInd
180 && aBuy() > 0 && bBuy() > 0;
181}
182
183// The whole trade is priced ONCE: round the TOTAL value up to a whole number of the given
184// item, not each step separately. Steps that divide evenly therefore cost nothing extra and
185// refund nothing -- 3 Fresh Water (3 x ₽200 = ₽600) is exactly 2 Potions (2 x ₽300 = ₽600).
186int ItemExchangeModel::giveFor(int dir, int steps) const
187{
188 if(!valid() || steps <= 0 || dir == 0)
189 return 0;
190 return (dir > 0) ? ceilDiv(steps * aBuy(), bBuy()) // gaining A, paying in B
191 : ceilDiv(steps * bBuy(), aBuy()); // gaining B, paying in A
192}
193
194int ItemExchangeModel::refundFor(int dir, int steps) const
195{
196 if(!valid() || steps <= 0 || dir == 0)
197 return 0;
198 return (dir > 0) ? (giveFor(1, steps) * bBuy() - steps * aBuy())
199 : (giveFor(-1, steps) * aBuy() - steps * bBuy());
200}
201
202int ItemExchangeModel::aAfterFor(int m) const
203{
204 return aStart() + nA(m) - giveFor(-1, nB(m));
205}
206
207int ItemExchangeModel::bAfterFor(int m) const
208{
209 return bStart() + nB(m) - giveFor(1, nA(m));
210}
211
212int ItemExchangeModel::moneyAfterFor(int m) const
213{
214 return moneyStart() + refundFor(1, nA(m)) + refundFor(-1, nB(m));
215}
216
217// A net value is legal when nothing goes negative, the gained item fits, and money
218// stays under the cap.
219bool ItemExchangeModel::stateValid(int m) const
220{
221 if(!valid())
222 return m == 0;
223
224 const int aA = aAfterFor(m);
225 const int bA = bAfterFor(m);
226 const int moneyA = moneyAfterFor(m);
227
228 if(aA < 0 || bA < 0)
229 return false;
230 if(aA > aStart() + combinedCapacity(m_itemAInd))
231 return false;
232 if(bA > bStart() + combinedCapacity(m_itemBInd))
233 return false;
234 if(moneyA > MoneyCap)
235 return false;
236
237 return true;
238}
239
240void ItemExchangeModel::clampNet()
241{
242 while(m_net > 0 && !stateValid(m_net)) --m_net;
243 while(m_net < 0 && !stateValid(m_net)) ++m_net;
244}
245
246// ---- Mutations -------------------------------------------------------------
247
248// Everything that can move the state goes through here so `revision` (which the dropdown
249// model bindings depend on) is always bumped BEFORE the binding re-reads it.
250void ItemExchangeModel::emitChanged()
251{
252 ++m_revision;
253 emit changed();
254}
255
257{
258 if(v == m_itemAInd)
259 return;
260 m_itemAInd = v;
261 m_net = 0;
262 emit selectionChanged();
263 emitChanged();
264}
265
267{
268 if(v == m_itemBInd)
269 return;
270 m_itemBInd = v;
271 m_net = 0;
272 emit selectionChanged();
273 emitChanged();
274}
275
277{
278 if(dir > 0 && canAddA())
279 ++m_net;
280 else if(dir < 0 && canAddB())
281 --m_net;
282 else
283 return;
284 emitChanged();
285}
286
288{
289 if(m_net == 0)
290 return;
291 m_net = 0;
292 emitChanged();
293}
294
296{
297 clampNet();
298 emitChanged();
299}
300
301// ---- Dropdown lists --------------------------------------------------------
302
303// LEFT: what you can give -- the items you own. If you own nothing in this category the
304// list would be empty (a dead dropdown), so it falls back to listing them all; no trade
305// is possible either way, but the UI still reads as a normal (inert) pair.
306QVariantList ItemExchangeModel::sourceItems(bool healingOnly, int excludeInd) const
307{
308 QVariantList out;
309 for(int pass = 0; pass < 2 && out.isEmpty(); ++pass) {
310 const bool ownedOnly = (pass == 0);
311 for(auto it = m_info.constBegin(); it != m_info.constEnd(); ++it) {
312 const int ind = it.key();
313 if(ind == excludeInd)
314 continue;
315 if(it->buy <= 0) // must be exchangeable (has a buy value)
316 continue;
317 if(healingOnly && !it->healing)
318 continue;
319 if(ownedOnly && combinedAmount(ind) <= 0) // owned, non-zero
320 continue;
321 QVariantMap m;
322 m.insert("name", it->readable);
323 m.insert("ind", ind);
324 out.append(m);
325 }
326 }
327 sortByName(out);
328 return out;
329}
330
331// RIGHT: what you can get -- every exchangeable item, owned or not. `affordable` says
332// whether your current source stock can actually cover one of it; the dropdown greys the
333// ones that can't, so whatever IS selectable always leaves the "+" for it live.
334QVariantList ItemExchangeModel::targetItems(bool healingOnly, int excludeInd) const
335{
336 QVariantList out;
337 for(auto it = m_info.constBegin(); it != m_info.constEnd(); ++it) {
338 const int ind = it.key();
339 if(ind == excludeInd)
340 continue;
341 if(it->buy <= 0)
342 continue;
343 if(healingOnly && !it->healing)
344 continue;
345 QVariantMap m;
346 m.insert("name", it->readable);
347 m.insert("ind", ind);
348 m.insert("affordable", canGainTarget(ind));
349 out.append(m);
350 }
351 sortByName(out);
352 return out;
353}
354
355// Evaluated against the START state (nothing is written until checkout), i.e. exactly the
356// first "+<target>" step: enough of the source to cover it, room for the gained item, and
357// the refund doesn't push money past the cap.
359{
360 if(m_itemAInd < 0 || bInd < 0 || bInd == m_itemAInd)
361 return false;
362
363 const int aB = buyOf(m_itemAInd);
364 const int bB = buyOf(bInd);
365 if(aB <= 0 || bB <= 0)
366 return false;
367
368 const int give = ceilDiv(bB, aB); // source items consumed for one target
369 const int refund = give * aB - bB; // leftover value, refunded as money
370
371 if(combinedAmount(m_itemAInd) < give) return false;
372 if(combinedCapacity(bInd) < 1) return false;
373 if(moneyStart() + refund > MoneyCap) return false;
374 return true;
375}
376
378{
379 m_net = 0;
380 m_itemAInd = -1;
381 m_itemBInd = -1;
382
383 // Source: on Healing, the best potion-family item the player actually HAS (Potion, then
384 // Super/Hyper/Max, Full Restore, ... -- never Antidote while a potion is on hand).
385 int a = -1;
386 if(healingOnly) {
387 for(const QString& n : healingSourcePref()) {
388 const int ind = indOfName(n);
389 if(ind >= 0 && buyOf(ind) > 0 && combinedAmount(ind) > 0) {
390 a = ind;
391 break;
392 }
393 }
394 }
395 if(a < 0) {
396 const QVariantList src = sourceItems(healingOnly, -1);
397 if(!src.isEmpty())
398 a = src.first().toMap().value("ind").toInt();
399 }
400 m_itemAInd = a; // canGainTarget() below reads this
401
402 // Target: on Healing, Fresh Water (so the tab opens on Potion <=> Fresh Water). Fall
403 // back to the first AFFORDABLE item so the pair we land on always has a live "+".
404 int b = -1;
405 if(healingOnly) {
406 for(const QString& n : healingTargetPref()) {
407 const int ind = indOfName(n);
408 if(ind >= 0 && ind != a && canGainTarget(ind)) {
409 b = ind;
410 break;
411 }
412 }
413 }
414 if(b < 0) {
415 const QVariantList tgt = targetItems(healingOnly, a);
416 for(const QVariant& v : tgt) {
417 const QVariantMap m = v.toMap();
418 if(m.value("affordable").toBool()) {
419 b = m.value("ind").toInt();
420 break;
421 }
422 }
423 // Nothing affordable at all (player owns nothing to trade) -- still show a sane pair.
424 if(b < 0 && !tgt.isEmpty())
425 b = tgt.first().toMap().value("ind").toInt();
426 }
427 m_itemBInd = b;
428
429 emit selectionChanged();
430 emitChanged();
431}
432
434{
435 if(!valid() || m_net == 0)
436 return;
437
438 const int steps = (m_net > 0) ? m_net : -m_net;
439 const int dir = (m_net > 0) ? 1 : -1;
440
441 // Priced as ONE trade (giveFor/refundFor), so this writes exactly what was previewed.
442 const int give = giveFor(dir, steps);
443 const int refund = refundFor(dir, steps);
444
445 const int gainInd = (dir > 0) ? m_itemAInd : m_itemBInd;
446 const int giveInd = (dir > 0) ? m_itemBInd : m_itemAInd;
447
448 // Consume the given item (bag first, then PC storage), add the gained one, refund the
449 // leftover value as money.
450 int rem = bag ? bag->removeAmount(giveInd, give) : 0;
451 if(rem < give && storage)
452 storage->removeAmount(giveInd, give - rem);
453
454 int added = bag ? bag->addAmount(gainInd, steps) : 0;
455 if(added < steps && storage)
456 storage->addAmount(gainInd, steps - added);
457
458 if(basics) {
459 const int m = static_cast<int>(basics->money) + refund;
460 basics->money = static_cast<unsigned int>(m > MoneyCap ? MoneyCap : m);
461 }
462
463 if(basics)
464 basics->moneyChanged();
465
466 m_net = 0;
467 // The box itemsChanged/moneyChanged signals already trigger refresh(); emit anyway
468 // so the preview settles even if a box somehow emitted nothing.
469 emitChanged();
470}
QVariantList targetItems(bool healingOnly, int excludeInd) const
RIGHT list – what you can GET: EVERY exchangeable item (owned or not), optionally healing-only,...
bool canGainTarget(int bInd) const
Can we gain at least one of item bInd right now, paying with the selected source?
void changed()
Any derived value changed.
ItemExchangeModel(ItemStorageBox *bag, ItemStorageBox *storage, PlayerBasics *basics)
int giveFor(int dir, int steps) const
How many of the OTHER item steps of this direction consume, priced as ONE whole trade rather than ste...
void refresh()
Re-read counts/money + re-emit (tab open / external edit).
void pickDefaults(bool healingOnly)
Choose a sensible starting pair.
void adjust(int dir)
+1 = one gained-A step, -1 = one gained-B step (gated).
QVariantList sourceItems(bool healingOnly, int excludeInd) const
LEFT list – what you can GIVE: items you actually own (amount > 0) and that are exchangeable (buy pri...
static constexpr int MoneyCap
< Both picked, distinct, both have a buy price.
void reset()
Clear the net axis.
protected::void selectionChanged()
A or B selection changed.
void checkout()
Apply the previewed trade to the save.
int refundFor(int dir, int steps) const
The leftover value of steps in direction dir, refunded as money – i.e.
A container of Items – either the trainer's bag or a PC item box.
void itemsChanged()
Box contents changed.
static ItemsDB * inst()
< Number of items.
Definition itemsdb.cpp:37
The trainer's headline values: name, ID, money, coins, badges, starter.
void moneyChanged()
void coinsChanged()