File: OutfitterPanel.cpp

package info (click to toggle)
endless-sky 0.10.16-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 414,608 kB
  • sloc: cpp: 73,435; python: 893; xml: 666; sh: 271; makefile: 28
file content (1040 lines) | stat: -rw-r--r-- 31,429 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
/* OutfitterPanel.cpp
Copyright (c) 2014 by Michael Zahniser

Endless Sky is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.

Endless Sky is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>.
*/

#include "OutfitterPanel.h"

#include "text/Alignment.h"
#include "comparators/BySeriesAndIndex.h"
#include "Color.h"
#include "Dialog.h"
#include "text/DisplayText.h"
#include "text/Font.h"
#include "text/FontSet.h"
#include "text/Format.h"
#include "GameData.h"
#include "Hardpoint.h"
#include "Mission.h"
#include "Outfit.h"
#include "Planet.h"
#include "PlayerInfo.h"
#include "Point.h"
#include "Screen.h"
#include "Ship.h"
#include "image/Sprite.h"
#include "image/SpriteSet.h"
#include "shader/SpriteShader.h"
#include "text/Truncate.h"
#include "UI.h"

#include <algorithm>
#include <limits>
#include <memory>

using namespace std;

namespace {
	// Label for the description field of the detail pane.
	const string DESCRIPTION = "description";

	// Determine the refillable ammunition a particular ship consumes or stores.
	set<const Outfit *> GetRefillableAmmunition(const Ship &ship) noexcept
	{
		auto toRefill = set<const Outfit *>{};
		auto armed = set<const Outfit *>{};
		for(auto &&it : ship.Weapons())
			if(it.GetOutfit())
			{
				const Outfit *weapon = it.GetOutfit();
				armed.emplace(weapon);
				if(weapon->Ammo() && weapon->AmmoUsage() > 0)
					toRefill.emplace(weapon->Ammo());
			}

		// Carriers may be configured to supply ammunition for carried ships found
		// within the fleet. Since a particular ammunition outfit is not bound to
		// any particular weapon (i.e. one weapon may consume it, while another may
		// only require it be installed), we always want to restock these outfits.
		for(auto &&it : ship.Outfits())
		{
			const Outfit *outfit = it.first;
			if(outfit->Ammo() && !outfit->IsWeapon() && !armed.contains(outfit))
				toRefill.emplace(outfit->Ammo());
		}
		return toRefill;
	}
}



OutfitterPanel::OutfitterPanel(PlayerInfo &player, Sale<Outfit> stock)
	: ShopPanel(player, true), outfitter(stock)
{
	for(const pair<const string, Outfit> &it : GameData::Outfits())
		catalog[it.second.Category()].push_back(it.first);

	for(pair<const string, vector<string>> &it : catalog)
		sort(it.second.begin(), it.second.end(), BySeriesAndIndex<Outfit>());

	for(auto &ship : player.Ships())
		if(ship->GetPlanet() == planet)
			++shipsHere;
}



void OutfitterPanel::Step()
{
	CheckRefill();
	ShopPanel::Step();
	ShopPanel::CheckForMissions(Mission::OUTFITTER);
	if(GetUI()->IsTop(this) && !checkedHelp)
		// Use short-circuiting to only display one of them at a time.
		// (The first valid condition encountered will make us skip the others.)
		if(DoHelp("outfitter") || DoHelp("cargo management") || DoHelp("uninstalling and storage")
				|| (shipsHere > 1 && DoHelp("outfitter with multiple ships")) || true)
			// Either a help message was freshly displayed, or all of them have already been seen.
			checkedHelp = true;
}



int OutfitterPanel::TileSize() const
{
	return OUTFIT_SIZE;
}



int OutfitterPanel::VisibilityCheckboxesSize() const
{
	return 30;
}



bool OutfitterPanel::HasItem(const string &name) const
{
	const Outfit *outfit = GameData::Outfits().Get(name);
	if(showForSale && (outfitter.Has(outfit) || player.Stock(outfit) > 0))
		return true;

	if(showCargo && player.Cargo().Get(outfit))
		return true;

	if(showStorage && player.Storage().Get(outfit))
		return true;

	for(const Ship *ship : playerShips)
		if(ship->OutfitCount(outfit))
			return true;

	if(showForSale && HasLicense(name))
		return true;

	return false;
}



void OutfitterPanel::DrawItem(const string &name, const Point &point)
{
	const Outfit *outfit = GameData::Outfits().Get(name);
	zones.emplace_back(point, Point(OUTFIT_SIZE, OUTFIT_SIZE), outfit);
	if(point.Y() + OUTFIT_SIZE / 2 < Screen::Top() || point.Y() - OUTFIT_SIZE / 2 > Screen::Bottom())
		return;

	bool isSelected = (outfit == selectedOutfit);
	bool isOwned = playerShip && playerShip->OutfitCount(outfit);
	DrawOutfit(*outfit, point, isSelected, isOwned);

	// Check if this outfit is a "license".
	bool isLicense = IsLicense(name);
	int mapSize = outfit->Get("map");

	const Font &font = FontSet::Get(14);
	const Color &bright = *GameData::Colors().Get("bright");
	const Color &highlight = *GameData::Colors().Get("outfitter difference highlight");

	bool highlightDifferences = false;
	if(playerShip || isLicense || mapSize)
	{
		int minCount = numeric_limits<int>::max();
		int maxCount = 0;
		if(isLicense)
			minCount = maxCount = player.HasLicense(LicenseRoot(name));
		else if(mapSize)
		{
			bool mapMinables = outfit->Get("map minables");
			minCount = maxCount = player.HasMapped(mapSize, mapMinables);
		}
		else
		{
			highlightDifferences = true;
			string firstModelName;
			for(const Ship *ship : playerShips)
			{
				// Highlight differences in installed outfit counts only when all selected ships are of the same model.
				string modelName = ship->TrueModelName();
				if(firstModelName.empty())
					firstModelName = modelName;
				else
					highlightDifferences &= (modelName == firstModelName);
				int count = ship->OutfitCount(outfit);
				minCount = min(minCount, count);
				maxCount = max(maxCount, count);
			}
		}

		if(maxCount)
		{
			string label = "installed: " + to_string(minCount);
			Color color = bright;
			if(maxCount > minCount)
			{
				label += " - " + to_string(maxCount);
				if(highlightDifferences)
					color = highlight;
			}

			Point labelPos = point + Point(-OUTFIT_SIZE / 2 + 20, OUTFIT_SIZE / 2 - 38);
			font.Draw(label, labelPos, color);
		}
	}

	// Don't show the "in stock" amount if the outfit has an unlimited stock.
	int stock = 0;
	if(!outfitter.Has(outfit))
		stock = max(0, player.Stock(outfit));
	int cargo = player.Cargo().Get(outfit);
	int storage = player.Storage().Get(outfit);

	string message;
	if(cargo && storage && stock)
		message = "cargo+stored: " + to_string(cargo + storage) + ", in stock: " + to_string(stock);
	else if(cargo && storage)
		message = "in cargo: " + to_string(cargo) + ", in storage: " + to_string(storage);
	else if(cargo && stock)
		message = "in cargo: " + to_string(cargo) + ", in stock: " + to_string(stock);
	else if(storage && stock)
		message = "in storage: " + to_string(storage) + ", in stock: " + to_string(stock);
	else if(cargo)
		message = "in cargo: " + to_string(cargo);
	else if(storage)
		message = "in storage: " + to_string(storage);
	else if(stock)
		message = "in stock: " + to_string(stock);
	else if(!outfitter.Has(outfit))
		message = "(not sold here)";
	if(!message.empty())
	{
		Point pos = point + Point(
			OUTFIT_SIZE / 2 - 20 - font.Width(message),
			OUTFIT_SIZE / 2 - 24);
		font.Draw(message, pos, bright);
	}
}



int OutfitterPanel::DividerOffset() const
{
	return 80;
}



int OutfitterPanel::DetailWidth() const
{
	return 3 * ItemInfoDisplay::PanelWidth();
}



double OutfitterPanel::DrawDetails(const Point &center)
{
	string selectedItem = "Nothing Selected";
	const Font &font = FontSet::Get(14);

	double heightOffset = 20.;

	if(selectedOutfit)
	{
		outfitInfo.Update(*selectedOutfit, player, CanSell(), collapsed.contains(DESCRIPTION));
		selectedItem = selectedOutfit->DisplayName();

		const Sprite *thumbnail = selectedOutfit->Thumbnail();
		const float tileSize = thumbnail
			? max(thumbnail->Height(), static_cast<float>(TileSize()))
			: static_cast<float>(TileSize());
		const Point thumbnailCenter(center.X(), center.Y() + 20 + static_cast<int>(tileSize / 2));
		const Point startPoint(center.X() - INFOBAR_WIDTH / 2 + 20, center.Y() + 20 + tileSize);

		const Sprite *background = SpriteSet::Get("ui/outfitter unselected");
		SpriteShader::Draw(background, thumbnailCenter);
		if(thumbnail)
			SpriteShader::Draw(thumbnail, thumbnailCenter);

		const bool hasDescription = outfitInfo.DescriptionHeight();

		double descriptionOffset = hasDescription ? 40. : 0.;

		if(hasDescription)
		{
			if(!collapsed.contains(DESCRIPTION))
			{
				descriptionOffset = outfitInfo.DescriptionHeight();
				outfitInfo.DrawDescription(startPoint);
			}
			else
			{
				const Color &dim = *GameData::Colors().Get("medium");
				font.Draw(DESCRIPTION, startPoint + Point(35., 12.), dim);
				const Sprite *collapsedArrow = SpriteSet::Get("ui/collapsed");
				SpriteShader::Draw(collapsedArrow, startPoint + Point(20., 20.));
			}

			// Calculate the ClickZone for the description and add it.
			const Point descriptionDimensions(INFOBAR_WIDTH, descriptionOffset);
			const Point descriptionCenter(center.X(), startPoint.Y() + descriptionOffset / 2);
			ClickZone<string> collapseDescription = ClickZone<string>(
				descriptionCenter, descriptionDimensions, DESCRIPTION);
			categoryZones.emplace_back(collapseDescription);
		}

		const Point requirementsPoint(startPoint.X(), startPoint.Y() + descriptionOffset);
		const Point attributesPoint(startPoint.X(), requirementsPoint.Y() + outfitInfo.RequirementsHeight());
		outfitInfo.DrawRequirements(requirementsPoint);
		outfitInfo.DrawAttributes(attributesPoint);

		heightOffset = attributesPoint.Y() + outfitInfo.AttributesHeight();
	}

	// Draw this string representing the selected item (if any), centered in the details side panel
	const Color &bright = *GameData::Colors().Get("bright");
	Point selectedPoint(center.X() - INFOBAR_WIDTH / 2, center.Y());
	font.Draw({selectedItem, {INFOBAR_WIDTH, Alignment::CENTER, Truncate::MIDDLE}},
		selectedPoint, bright);

	return heightOffset;
}



ShopPanel::BuyResult OutfitterPanel::CanBuy(bool onlyOwned) const
{
	if(!planet || !selectedOutfit)
		return false;

	// Check special unique outfits, if you already have them.
	int mapSize = selectedOutfit->Get("map");
	bool mapMinables = selectedOutfit->Get("map minables");
	if(mapSize > 0 && player.HasMapped(mapSize, mapMinables))
		return "You have already mapped all the systems shown by this map, "
			"so there is no reason to buy another.";

	if(HasLicense(selectedOutfit->TrueName()))
		return "You already have one of these licenses, "
			"so there is no reason to buy another.";

	// Check that the player has any necessary licenses.
	int64_t licenseCost = LicenseCost(selectedOutfit, onlyOwned);
	if(licenseCost < 0)
		return "You cannot buy this outfit, because it requires a license that you don't have.";

	// Check if the outfit is available to get at all.
	bool isInCargo = player.Cargo().Get(selectedOutfit);
	bool isInStorage = player.Storage().Get(selectedOutfit);
	bool isInStore = outfitter.Has(selectedOutfit) || player.Stock(selectedOutfit) > 0;
	if(isInStorage && (onlyOwned || isInStore || playerShip))
	{
		// In storage, the outfit is certainly available to get,
		// except for this one case: 'b' does not move storage to cargo.
	}
	else if(isInCargo && playerShip)
	{
		// Installing to a ship will work from cargo.
	}
	else if(onlyOwned)
	{
		// Not using the store, there's nowhere to get this outfit.
		if(isInStore)
		{
			// Player hit 'i' or 'c' when they should've hit 'b'.
			if(playerShip)
				return "You'll need to buy this outfit to install it (using \"b\").";
			else
				return "You'll need to buy this outfit to put it in your cargo hold (using \"b\").";
		}
		else
		{
			// Player hit 'i' or 'c' to install, with no outfit to use.
			if(playerShip)
				return "You do not have any of these outfits available to install.";
			else
				return "You do not have any of these outfits in storage to move to your cargo hold.";
		}
	}
	else if(!isInStore)
	{
		// The store doesn't have it.
		return "You cannot buy this outfit here. "
			"It is being shown in the list because you have one, "
			"but this " + planet->Noun() + " does not sell them.";
	}
	// Add system to accumulate reasons why an outfit cannot be bought
	vector<string> errors;
	// Check if you need to pay, and can't afford it.
	if(!onlyOwned)
	{
		// Determine what you will have to pay to buy this outfit.
		int64_t cost = player.StockDepreciation().Value(selectedOutfit, day);
		int64_t credits = player.Accounts().Credits();

		if(cost > credits)
			errors.push_back("You do not have enough money to buy this outfit. You need a further " +
				Format::CreditString(cost - credits) + ".");

		// Add the cost to buy the required license.
		else if(cost + licenseCost > credits)
			errors.push_back("You do not have enough money to buy this outfit because you also need "
				"to buy a license for it. You need a further " +
				Format::CreditString(cost + licenseCost - credits) + ".");
	}

	bool anyShipCanInstall = true;
	// Check if the outfit will fit.
	if(!playerShip)
	{
		// Buying into cargo, so check cargo space vs mass.
		double mass = selectedOutfit->Mass();
		double freeCargo = player.Cargo().FreePrecise();
		if(mass && freeCargo < mass)
			errors.push_back("You cannot " + string(onlyOwned ? "load" : "buy") + " this outfit, because it takes up "
				+ Format::CargoString(mass, "mass") + " and your fleet has "
				+ Format::CargoString(freeCargo, "cargo space") + " free.");
	}
	else
	{
		anyShipCanInstall = false;
		// Find if any ship can install the outfit.
		for(const Ship *ship : playerShips)
			if(ShipCanBuy(ship, selectedOutfit))
			{
				anyShipCanInstall = true;
				break;
			}
	}

	if(!anyShipCanInstall)
	{
		// If no selected ship can install the outfit,
		// report error based on playerShip.
		double outfitNeeded = -selectedOutfit->Get("outfit space");
		double outfitSpace = playerShip->Attributes().Get("outfit space");
		if(outfitNeeded > outfitSpace)
			errors.push_back("You cannot install this outfit, because it takes up "
				+ Format::CargoString(outfitNeeded, "outfit space") + ", and this ship has "
				+ Format::MassString(outfitSpace) + " free.");

		double weaponNeeded = -selectedOutfit->Get("weapon capacity");
		double weaponSpace = playerShip->Attributes().Get("weapon capacity");
		if(weaponNeeded > weaponSpace)
			errors.push_back("Only part of your ship's outfit capacity is usable for weapons. "
				"You cannot install this outfit, because it takes up "
				+ Format::CargoString(weaponNeeded, "weapon space") + ", and this ship has "
				+ Format::MassString(weaponSpace) + " free.");

		double engineNeeded = -selectedOutfit->Get("engine capacity");
		double engineSpace = playerShip->Attributes().Get("engine capacity");
		if(engineNeeded > engineSpace)
			errors.push_back("Only part of your ship's outfit capacity is usable for engines. "
				"You cannot install this outfit, because it takes up "
				+ Format::CargoString(engineNeeded, "engine space") + ", and this ship has "
				+ Format::MassString(engineSpace) + " free.");

		if(selectedOutfit->Category() == "Ammunition")
			errors.push_back(!playerShip->OutfitCount(selectedOutfit) ?
				"This outfit is ammunition for a weapon. "
				"You cannot install it without first installing the appropriate weapon."
				: "You already have the maximum amount of ammunition for this weapon. "
				"If you want to install more ammunition, you must first install another of these weapons.");

		int mountsNeeded = -selectedOutfit->Get("turret mounts");
		int mountsFree = playerShip->Attributes().Get("turret mounts");
		if(mountsNeeded && !mountsFree)
			errors.push_back("This weapon is designed to be installed on a turret mount, "
				"but your ship does not have any unused turret mounts available.");

		int gunsNeeded = -selectedOutfit->Get("gun ports");
		int gunsFree = playerShip->Attributes().Get("gun ports");
		if(gunsNeeded && !gunsFree)
			errors.push_back("This weapon is designed to be installed in a gun port, "
				"but your ship does not have any unused gun ports available.");

		if(selectedOutfit->Get("installable") < 0.)
			errors.push_back("This item is not an outfit that can be installed in a ship.");

		// For unhandled outfit requirements, show a catch-all error message.
		if(errors.empty())
			errors.push_back("You cannot install this outfit in your ship, "
				"because it would reduce one of your ship's attributes to a negative amount. "
				"For example, it may use up more cargo space than you have left.");
	}

	if(errors.empty())
		return true;
	else if(errors.size() == 1)
		return errors[0];
	else
	{
		string errorMessage = "There are several reasons why you cannot " +
			string(onlyOwned ? "install" : "buy") + " this outfit:\n";
		for(size_t i = 0; i < errors.size(); ++i)
			errorMessage += "- " + errors[i] + "\n";
		return errorMessage;
	}
}



void OutfitterPanel::Buy(bool onlyOwned)
{
	int64_t licenseCost = LicenseCost(selectedOutfit, onlyOwned);
	if(licenseCost)
	{
		player.Accounts().AddCredits(-licenseCost);
		for(const string &licenseName : selectedOutfit->Licenses())
			if(!player.HasLicense(licenseName))
				player.AddLicense(licenseName);
	}

	// Special case: maps.
	int mapSize = selectedOutfit->Get("map");
	if(mapSize)
	{
		bool mapMinables = selectedOutfit->Get("map minables");
		player.Map(mapSize, mapMinables);
		player.Accounts().AddCredits(-selectedOutfit->Cost());
		return;
	}

	// Special case: licenses.
	if(IsLicense(selectedOutfit->TrueName()))
	{
		player.AddLicense(LicenseRoot(selectedOutfit->TrueName()));
		player.Accounts().AddCredits(-selectedOutfit->Cost());
		return;
	}

	int modifier = Modifier();
	for(int i = 0; i < modifier && CanBuy(onlyOwned); ++i)
	{
		// Buying into cargo, either from storage or from stock/supply.
		if(!playerShip)
		{
			if(onlyOwned)
			{
				if(!player.Storage().Get(selectedOutfit))
					continue;
				player.Cargo().Add(selectedOutfit);
				player.Storage().Remove(selectedOutfit);
			}
			else
			{
				// Check if the outfit is for sale or in stock so that we can actually buy it.
				if(!outfitter.Has(selectedOutfit) && player.Stock(selectedOutfit) <= 0)
					continue;
				player.Cargo().Add(selectedOutfit);
				int64_t price = player.StockDepreciation().Value(selectedOutfit, day);
				player.Accounts().AddCredits(-price);
				player.AddStock(selectedOutfit, -1);
				continue;
			}
		}

		// Find the ships with the fewest number of these outfits.
		const vector<Ship *> shipsToOutfit = GetShipsToOutfit(true);

		for(Ship *ship : shipsToOutfit)
		{
			if(!CanBuy(onlyOwned))
				return;

			if(player.Cargo().Get(selectedOutfit))
				player.Cargo().Remove(selectedOutfit);
			else if(player.Storage().Get(selectedOutfit))
				player.Storage().Remove(selectedOutfit);
			else if(onlyOwned || !(player.Stock(selectedOutfit) > 0 || outfitter.Has(selectedOutfit)))
				break;
			else
			{
				int64_t price = player.StockDepreciation().Value(selectedOutfit, day);
				player.Accounts().AddCredits(-price);
				player.AddStock(selectedOutfit, -1);
			}
			ship->AddOutfit(selectedOutfit, 1);
			int required = selectedOutfit->Get("required crew");
			if(required && ship->Crew() + required <= static_cast<int>(ship->Attributes().Get("bunks")))
				ship->AddCrew(required);
			ship->Recharge();
		}
	}
}



bool OutfitterPanel::CanSell(bool toStorage) const
{
	if(!planet || !selectedOutfit)
		return false;

	if(player.Cargo().Get(selectedOutfit))
		return true;

	if(!toStorage && player.Storage().Get(selectedOutfit))
		return true;

	for(const Ship *ship : playerShips)
		if(ShipCanSell(ship, selectedOutfit))
			return true;

	return false;
}



void OutfitterPanel::Sell(bool toStorage)
{
	CargoHold &storage = player.Storage();

	if(player.Cargo().Get(selectedOutfit))
	{
		player.Cargo().Remove(selectedOutfit);
		if(toStorage && storage.Add(selectedOutfit))
		{
			// Transfer to planetary storage completed.
			// The storage->Add() function should never fail as long as
			// planetary storage has unlimited size.
		}
		else
		{
			int64_t price = player.FleetDepreciation().Value(selectedOutfit, day);
			player.Accounts().AddCredits(price);
			player.AddStock(selectedOutfit, 1);
		}
		return;
	}

	// Get the ships that have the most of this outfit installed.
	// If there are no ships that have this outfit, then sell from storage.
	const vector<Ship *> shipsToOutfit = GetShipsToOutfit();

	if(!shipsToOutfit.empty())
	{
		for(Ship *ship : shipsToOutfit)
		{
			ship->AddOutfit(selectedOutfit, -1);
			if(selectedOutfit->Get("required crew"))
				ship->AddCrew(-selectedOutfit->Get("required crew"));
			ship->Recharge();

			if(toStorage && storage.Add(selectedOutfit))
			{
				// Transfer to planetary storage completed.
			}
			else if(toStorage)
			{
				// No storage available; transfer to cargo even if it
				// would exceed the cargo capacity.
				int size = player.Cargo().Size();
				player.Cargo().SetSize(-1);
				player.Cargo().Add(selectedOutfit);
				player.Cargo().SetSize(size);
			}
			else
			{
				int64_t price = player.FleetDepreciation().Value(selectedOutfit, day);
				player.Accounts().AddCredits(price);
				player.AddStock(selectedOutfit, 1);
			}

			const Outfit *ammo = selectedOutfit->Ammo();
			if(ammo && ship->OutfitCount(ammo))
			{
				// Determine how many of this ammo I must sell to also sell the launcher.
				int mustSell = 0;
				for(const pair<const char *, double> &it : ship->Attributes().Attributes())
					if(it.second < 0.)
						mustSell = max<int>(mustSell, it.second / ammo->Get(it.first));

				if(mustSell)
				{
					ship->AddOutfit(ammo, -mustSell);
					if(toStorage)
						mustSell -= storage.Add(ammo, mustSell);
					if(mustSell)
					{
						int64_t price = player.FleetDepreciation().Value(ammo, day, mustSell);
						player.Accounts().AddCredits(price);
						player.AddStock(ammo, mustSell);
					}
				}
			}
		}
		return;
	}

	if(!toStorage && storage.Get(selectedOutfit))
	{
		storage.Remove(selectedOutfit);
		int64_t price = player.FleetDepreciation().Value(selectedOutfit, day);
		player.Accounts().AddCredits(price);
		player.AddStock(selectedOutfit, 1);
	}
}



void OutfitterPanel::FailSell(bool toStorage) const
{
	const string &verb = toStorage ? "uninstall" : "sell";
	if(!planet || !selectedOutfit)
		return;
	else if(selectedOutfit->Get("map"))
		GetUI()->Push(new Dialog("You cannot " + verb + " maps. Once you buy one, it is yours permanently."));
	else if(HasLicense(selectedOutfit->TrueName()))
		GetUI()->Push(new Dialog("You cannot " + verb + " licenses. Once you obtain one, it is yours permanently."));
	else
	{
		bool hasOutfit = player.Cargo().Get(selectedOutfit);
		hasOutfit = hasOutfit || (!toStorage && player.Storage().Get(selectedOutfit));
		for(const Ship *ship : playerShips)
			if(ship->OutfitCount(selectedOutfit))
			{
				hasOutfit = true;
				break;
			}
		if(!hasOutfit)
			GetUI()->Push(new Dialog("You do not have any of these outfits to " + verb + "."));
		else
		{
			for(const Ship *ship : playerShips)
				for(const pair<const char *, double> &it : selectedOutfit->Attributes())
					if(ship->Attributes().Get(it.first) < it.second)
					{
						for(const auto &sit : ship->Outfits())
							if(sit.first->Get(it.first) < 0.)
							{
								GetUI()->Push(new Dialog("You cannot " + verb + " this outfit, "
									"because that would cause your ship's \"" + it.first +
									"\" value to be reduced to less than zero. "
									"To " + verb + " this outfit, you must " + verb + " the " +
									sit.first->DisplayName() + " outfit first."));
								return;
							}
						GetUI()->Push(new Dialog("You cannot " + verb + " this outfit, "
							"because that would cause your ship's \"" + it.first +
							"\" value to be reduced to less than zero."));
						return;
					}
			GetUI()->Push(new Dialog("You cannot " + verb + " this outfit, "
				"because something else in your ship depends on it."));
		}
	}
}



bool OutfitterPanel::ShouldHighlight(const Ship *ship)
{
	if(!selectedOutfit)
		return false;

	if(hoverButton == 'b')
		return CanBuy() && ShipCanBuy(ship, selectedOutfit);
	else if(hoverButton == 's')
		return CanSell() && ShipCanSell(ship, selectedOutfit);

	return false;
}



void OutfitterPanel::DrawKey()
{
	const Sprite *back = SpriteSet::Get("ui/outfitter key");
	SpriteShader::Draw(back, Screen::BottomLeft() + .5 * Point(back->Width(), -back->Height()));

	const Font &font = FontSet::Get(14);
	Color color[2] = {*GameData::Colors().Get("medium"), *GameData::Colors().Get("bright")};
	const Sprite *box[2] = {SpriteSet::Get("ui/unchecked"), SpriteSet::Get("ui/checked")};

	Point pos = Screen::BottomLeft() + Point(10., -VisibilityCheckboxesSize() - 20.);
	Point off = Point(10., -.5 * font.Height());
	SpriteShader::Draw(box[showForSale], pos);
	font.Draw("Show outfits for sale", pos + off, color[showForSale]);
	AddZone(Rectangle(pos + Point(80., 0.), Point(180., 20.)), [this](){ ToggleForSale(); });

	pos.Y() += 20.;
	SpriteShader::Draw(box[showCargo], pos);
	font.Draw("Show outfits in cargo", pos + off, color[showCargo]);
	AddZone(Rectangle(pos + Point(80., 0.), Point(180., 20.)), [this](){ ToggleCargo(); });

	pos.Y() += 20.;
	SpriteShader::Draw(box[showStorage], pos);
	font.Draw("Show outfits in storage", pos + off, color[showStorage]);
	AddZone(Rectangle(pos + Point(80., 0.), Point(180., 20.)), [this](){ ToggleStorage(); });
}



void OutfitterPanel::ToggleForSale()
{
	showForSale = !showForSale;
	ShopPanel::ToggleForSale();
}



void OutfitterPanel::ToggleStorage()
{
	showStorage = !showStorage;
	ShopPanel::ToggleStorage();
}



void OutfitterPanel::ToggleCargo()
{
	showCargo = !showCargo;

	if(playerShip)
	{
		playerShip = nullptr;
		playerShips.clear();
	}
	else
	{
		playerShip = player.Flagship();
		if(playerShip)
			playerShips.insert(playerShip);
	}

	ShopPanel::ToggleCargo();
}



bool OutfitterPanel::ShipCanBuy(const Ship *ship, const Outfit *outfit)
{
	return (ship->Attributes().CanAdd(*outfit, 1) > 0);
}



bool OutfitterPanel::ShipCanSell(const Ship *ship, const Outfit *outfit)
{
	if(!ship->OutfitCount(outfit))
		return false;

	// If this outfit requires ammo, check if we could sell it if we sold all
	// the ammo for it first.
	const Outfit *ammo = outfit->Ammo();
	if(ammo && ship->OutfitCount(ammo))
	{
		Outfit attributes = ship->Attributes();
		attributes.Add(*ammo, -ship->OutfitCount(ammo));
		return attributes.CanAdd(*outfit, -1);
	}

	// Now, check whether this ship can sell this outfit.
	return ship->Attributes().CanAdd(*outfit, -1);
}



void OutfitterPanel::DrawOutfit(const Outfit &outfit, const Point &center, bool isSelected, bool isOwned)
{
	const Sprite *thumbnail = outfit.Thumbnail();
	const Sprite *back = SpriteSet::Get(
		isSelected ? "ui/outfitter selected" : "ui/outfitter unselected");
	SpriteShader::Draw(back, center);
	SpriteShader::Draw(thumbnail, center);

	// Draw the outfit name.
	const string &name = outfit.DisplayName();
	const Font &font = FontSet::Get(14);
	Point offset(-.5 * OUTFIT_SIZE, -.5 * OUTFIT_SIZE + 10.);
	font.Draw({name, {OUTFIT_SIZE, Alignment::CENTER, Truncate::MIDDLE}},
		center + offset, Color((isSelected | isOwned) ? .8 : .5, 0.));
}



bool OutfitterPanel::IsLicense(const string &name) const
{
	return name.ends_with(" License");
}



bool OutfitterPanel::HasLicense(const string &name) const
{
	return (IsLicense(name) && player.HasLicense(LicenseRoot(name)));
}



string OutfitterPanel::LicenseRoot(const string &name) const
{
	static const string &LICENSE = " License";
	return name.substr(0, name.length() - LICENSE.length());
}



void OutfitterPanel::CheckRefill()
{
	if(checkedRefill)
		return;
	checkedRefill = true;

	int count = 0;
	map<const Outfit *, int> needed;
	for(const shared_ptr<Ship> &ship : player.Ships())
	{
		// Skip ships in other systems and those that were unable to land in-system.
		if(ship->GetSystem() != player.GetSystem() || ship->IsDisabled())
			continue;

		++count;
		auto toRefill = GetRefillableAmmunition(*ship);
		for(const Outfit *outfit : toRefill)
		{
			int amount = ship->Attributes().CanAdd(*outfit, numeric_limits<int>::max());
			if(amount > 0)
			{
				bool available = outfitter.Has(outfit) || player.Stock(outfit) > 0;
				available = available || player.Cargo().Get(outfit) || player.Storage().Get(outfit);
				if(available)
					needed[outfit] += amount;
			}
		}
	}

	int64_t cost = 0;
	for(auto &it : needed)
	{
		// Don't count cost of anything installed from cargo or storage.
		it.second = max(0, it.second - player.Cargo().Get(it.first) - player.Storage().Get(it.first));
		if(!outfitter.Has(it.first))
			it.second = min(it.second, max(0, player.Stock(it.first)));
		cost += player.StockDepreciation().Value(it.first, day, it.second);
	}
	if(!needed.empty() && cost < player.Accounts().Credits())
	{
		string message = "Do you want to reload all the ammunition for your ship";
		message += (count == 1) ? "?" : "s?";
		if(cost)
			message += " It will cost " + Format::CreditString(cost) + ".";
		GetUI()->Push(new Dialog(this, &OutfitterPanel::Refill, message));
	}
}



void OutfitterPanel::Refill()
{
	for(const shared_ptr<Ship> &ship : player.Ships())
	{
		// Skip ships in other systems and those that were unable to land in-system.
		if(ship->GetSystem() != player.GetSystem() || ship->IsDisabled())
			continue;

		auto toRefill = GetRefillableAmmunition(*ship);
		for(const Outfit *outfit : toRefill)
		{
			int neededAmmo = ship->Attributes().CanAdd(*outfit, numeric_limits<int>::max());
			if(neededAmmo > 0)
			{
				// Fill first from any stockpiles in storage.
				const int fromStorage = player.Storage().Remove(outfit, neededAmmo);
				neededAmmo -= fromStorage;
				// Then from cargo.
				const int fromCargo = player.Cargo().Remove(outfit, neededAmmo);
				neededAmmo -= fromCargo;
				// Then, buy at reduced (or full) price.
				int available = outfitter.Has(outfit) ? neededAmmo : min<int>(neededAmmo, max<int>(0, player.Stock(outfit)));
				if(neededAmmo && available > 0)
				{
					int64_t price = player.StockDepreciation().Value(outfit, day, available);
					player.Accounts().AddCredits(-price);
					player.AddStock(outfit, -available);
				}
				ship->AddOutfit(outfit, available + fromStorage + fromCargo);
			}
		}
	}
}



// Determine which ships of the selected ships should be referenced in this
// iteration of Buy / Sell.
const vector<Ship *> OutfitterPanel::GetShipsToOutfit(bool isBuy) const
{
	vector<Ship *> shipsToOutfit;
	int compareValue = isBuy ? numeric_limits<int>::max() : 0;
	int compareMod = 2 * isBuy - 1;
	for(Ship *ship : playerShips)
	{
		if((isBuy && !ShipCanBuy(ship, selectedOutfit))
				|| (!isBuy && !ShipCanSell(ship, selectedOutfit)))
			continue;

		int count = ship->OutfitCount(selectedOutfit);
		if(compareMod * count < compareMod * compareValue)
		{
			shipsToOutfit.clear();
			compareValue = count;
		}
		if(count == compareValue)
			shipsToOutfit.push_back(ship);
	}

	return shipsToOutfit;
}



int OutfitterPanel::FindItem(const string &text) const
{
	int bestIndex = 9999;
	int bestItem = -1;
	auto it = zones.begin();
	for(unsigned int i = 0; i < zones.size(); ++i, ++it)
	{
		const Outfit *outfit = it->GetOutfit();
		int index = Format::Search(outfit->DisplayName(), text);
		if(index >= 0 && index < bestIndex)
		{
			bestIndex = index;
			bestItem = i;
			if(!index)
				return i;
		}
	}
	return bestItem;
}