View Javadoc
1   package de.aikiit.game.kaiser;
2   
3   import lombok.Getter;
4   import org.assertj.core.util.VisibleForTesting;
5   
6   import java.math.BigDecimal;
7   import java.math.RoundingMode;
8   import java.security.SecureRandom;
9   import java.util.Random;
10  
11  import static org.apache.commons.lang3.compare.ComparableUtils.is;
12  
13  /**
14   * This class encapsulates the game's logics and operations on its attributes.
15   * As the game is based on rounds {@link #startNewRound()} starts a new round and performs calculations based on the player's actions.
16   * <br />
17   * In each round there is a chance for famine-induced loss,
18   * which is handled in {@link #processFamine()} based on an underlying random probability factor.
19   * <br />
20   * Apart from these automated things the player can:
21   * <ul>
22   *     <li>{@link #buyLand(Long)} buy new land</li>
23   *     <li>{@link #sellLand(Long)} sell existing land</li>
24   *     <li>{@link #cultivate(Long)} cultivate land in order to achieve new yields</li>
25   *     <li>{@link #feedToPopulation(Long)} give food to your population</li>
26   * </ul>
27   * After user interactions the round is finished in {@link #finishRoundAfterActions()}.
28   */
29  @Getter
30  public class KaiserEngine {
31      /**
32       * e - External damage, e.g. loss due to rats.
33       */
34      private BigDecimal externalDamage = BigDecimal.ZERO; // e
35      private BigDecimal deathToll; // d
36      private BigDecimal increase; // i in original-  birthRate?
37      private Integer zYear; // why z in original?
38      private BigDecimal population = BigDecimal.ZERO; // h in original
39      private BigDecimal area = BigDecimal.ZERO;
40      private BigDecimal yield = BigDecimal.ZERO;
41      private BigDecimal supplies = BigDecimal.ZERO;
42      private BigDecimal humans = BigDecimal.ZERO;
43      private BigDecimal deathTollSum; // d1 in original
44      private BigDecimal percentDeathToll; // p1 in original
45      private BigDecimal q = BigDecimal.ONE; // q - disaster/famineQuotient
46      private BigDecimal cost = BigDecimal.ZERO;
47  
48      private static final Random RANDOM = new SecureRandom();
49  
50      /**
51       * Default constructor to start a game with the given default settings.
52       */
53      public KaiserEngine() {
54          this.population = BigDecimal.valueOf(95L);
55          this.zYear = 0;
56          this.yield = BigDecimal.valueOf(3L);
57          this.supplies = BigDecimal.valueOf(2800L);
58          this.humans = BigDecimal.valueOf(3000L);
59          this.area = this.humans.divide(this.yield, RoundingMode.HALF_UP);
60          this.increase = BigDecimal.valueOf(5L);
61          this.deathToll = BigDecimal.ZERO;
62          this.percentDeathToll = BigDecimal.ZERO;
63          this.deathTollSum = BigDecimal.ZERO;
64          this.externalDamage = this.humans.subtract(this.supplies);
65      }
66  
67      /**
68       * Starts a new round in performs initial calculations before user actions are taken into account.
69       */
70      public void startNewRound() {
71          this.area = this.humans.divide(this.yield, 0, RoundingMode.HALF_UP);
72          this.externalDamage = this.humans.subtract(this.supplies);
73          this.zYear++;
74          this.population = this.population.add(this.increase);
75  
76          processFamine();
77          this.cost = getRandomNumberUntil(10);
78          this.yield = cost.add(BigDecimal.valueOf(17L));
79      }
80  
81      /**
82       * Helper method to retrieve a new random number without any comma (scale=0).
83       *
84       * @param threshold number is greater than 0 and at most threshold.
85       * @return a new random number.
86       */
87      BigDecimal getRandomNumberUntil(int threshold) {
88          return BigDecimal.valueOf(RANDOM.nextInt(threshold + 1) + 1).setScale(0, RoundingMode.HALF_EVEN);
89      }
90  
91      /**
92       * Evaluate internally, if a famine is happening in the current round.
93       * If so this method performs all necessary calculations/reductions within the currently running game.
94       */
95      public void processFamine() {
96          if (is(q).lessThan(BigDecimal.ZERO)) {
97              this.population = this.population.divide(BigDecimal.valueOf(2L), 0, RoundingMode.HALF_UP);
98              System.out.println(KaiserEnginePrinter.ORANGE);
99              System.out.println("Eine fürchterliche Seuche hat die halbe Stadt dahingerafft!");
100             System.out.println(KaiserEnginePrinter.ANSI_RESET);
101         }
102         refreshFamineQuotient();
103     }
104 
105     /**
106      * Explicitly trigger the recalculation of the given internal famine calculation factor.
107      */
108     void refreshFamineQuotient() {
109         this.q = getRandomNumberUntil(10).divide(BigDecimal.TEN, 0, RoundingMode.HALF_UP).subtract(new BigDecimal("0.3"));
110 
111     }
112 
113     /**
114      * Allow setting the area value for testing purposes.
115      * @param q q value.
116      */
117     @VisibleForTesting
118     void setQ(BigDecimal q) {
119         this.q = q;
120     }
121 
122     /**
123      * Allow setting the supplies value for testing purposes.
124      * @param supplies current supplies.
125      */
126     @VisibleForTesting
127     void setSupplies(BigDecimal supplies) {
128         this.supplies = supplies;
129     }
130 
131     /**
132      * Allow setting the area value for testing purposes.
133      * @param area current area.
134      */
135     @VisibleForTesting
136     void setArea(BigDecimal area) {
137         this.area = area;
138     }
139 
140     /**
141      * Calculates the available area per person in the current game.
142      *
143      * @return area per capita, called <b>L</b> in original. Land ownership?
144      */
145     public BigDecimal getAreaPerCapita() {
146         return area.divide(population, 0, RoundingMode.HALF_UP);
147     }
148 
149     /**
150      * Performs an act of buying land (new land is acquired by reducing the supplies according to the current land price).
151      * You cannot buy more than you can afford.
152      *
153      * @param buy how many hectares you want to buy. Negative input is ignored.
154      * @return the given number of hectares. {@code 0} means that the player does not want to buy anything, which will trigger the possibility to sell land.
155      */
156     public long buyLand(Long buy) {
157         if (buy < 0) {
158             System.out.println(KaiserEnginePrinter.ANSI_PURPLE + "Ignoriere negative Eingaben - Du willst mich wohl verkackeiern." + KaiserEnginePrinter.ANSI_RESET);
159         }
160 
161         if (buy > 0) {
162             if (is(this.yield.multiply(BigDecimal.valueOf(buy))).lessThanOrEqualTo(this.supplies)) {
163                 this.area = this.area.add(BigDecimal.valueOf(buy));
164                 this.supplies = this.supplies.subtract(this.yield.multiply(BigDecimal.valueOf(buy)));
165                 this.cost = BigDecimal.ZERO; // price is recalculated per round
166             } else {
167                 throw new IllegalArgumentException("Not Enough Supplies");
168             }
169         }
170         return buy;
171     }
172 
173     /**
174      * Performs an act of selling land (resulting in an increase of supplies as the land is sold to the current land price).
175      * You cannot sell more than you have.
176      *
177      * @param sell how many hectares you want to sell. Negative input is ignored.
178      */
179     public void sellLand(Long sell) {
180         if (sell < 0) {
181             System.out.println(KaiserEnginePrinter.ANSI_PURPLE + "Ignoriere negative Eingaben - Du willst mich wohl verkackeiern." + KaiserEnginePrinter.ANSI_RESET);
182             return;
183         }
184 
185         if (is(BigDecimal.valueOf(sell)).lessThan(this.area)) {
186             this.area = this.area.subtract(BigDecimal.valueOf(sell));
187             this.supplies = this.supplies.add(this.yield.multiply(BigDecimal.valueOf(sell)));
188             this.cost = BigDecimal.ZERO; // price is recalculated per round
189         } else {
190             throw new IllegalArgumentException("Not Enough Land");
191         }
192     }
193 
194     /**
195      * Performs an act of using supplies to feed your population.
196      * You cannot give more than you have.
197      *
198      * @param feed how many dzt you want to feed
199      */
200     public void feedToPopulation(Long feed) {
201         if (feed < 0) {
202             System.out.println(KaiserEnginePrinter.ANSI_PURPLE + "Ignoriere negative Eingaben - Du willst mich wohl verkackeiern." + KaiserEnginePrinter.ANSI_RESET);
203             return;
204         }
205 
206         if (feed != 0) {
207             if (is(BigDecimal.valueOf(feed)).lessThanOrEqualTo(this.supplies)) {
208                 this.supplies = this.supplies.subtract(BigDecimal.valueOf(feed));
209                 this.cost = BigDecimal.ONE; // price is recalculated per round
210             } else {
211                 throw new IllegalArgumentException("Not Enough in Stock");
212             }
213         }
214     }
215 
216     /**
217      * Performs an act of using your area and people to cultivate, plant crops for the upcoming season/next round.
218      * You cannot give more than you have.
219      *
220      * @param cultivate how many hectares you want to use for agricultural purposes. Negative input is ignored. An input of {@code 0} will trigger a recalculation of the current land price.
221      */
222     public void cultivate(Long cultivate) {
223         if (cultivate == 0) {
224             calculateNewPrice();
225             return;
226         }
227 
228         if (cultivate < 0) {
229             System.out.println(KaiserEnginePrinter.ANSI_PURPLE + "Ignoriere negative Eingaben - Du willst mich wohl verkackeiern." + KaiserEnginePrinter.ANSI_RESET);
230             return;
231         }
232 
233         if (is(this.area).lessThan(BigDecimal.valueOf(cultivate))) {
234             throw new IllegalArgumentException("You cannot cultivate more area than you have.");
235         }
236 
237         BigDecimal halfCultivate = BigDecimal.valueOf(cultivate).divide(BigDecimal.valueOf(2L), 0, RoundingMode.HALF_UP);
238         if (is(this.supplies).lessThan(halfCultivate)) {
239             throw new IllegalArgumentException("You cannot cultivate more than you have.");
240         }
241 
242         if (is(BigDecimal.valueOf(cultivate)).greaterThan(getPopulation().multiply(BigDecimal.TEN))) {
243             throw new IllegalArgumentException("Not enough workers available.");
244         }
245 
246         // perform seeding
247         this.supplies = this.supplies.subtract(halfCultivate);
248         calculateNewPrice();
249 
250         // yields after cultivation and population increase
251         this.yield = this.cost;
252         this.humans = this.yield.multiply(BigDecimal.valueOf(cultivate));
253 
254         // cultivation kills rats ;)
255         this.externalDamage = BigDecimal.ZERO;
256         calculateNewPrice();
257 
258         // but add some external damage in some cases in a naiive manner
259         // original condition stated: if int(c/2) <> c/2
260         if (this.cost.divide(BigDecimal.valueOf(2L), 0, RoundingMode.DOWN).intValue() == this.cost.divide(BigDecimal.valueOf(2L), 0, RoundingMode.UP).intValue()) {
261             this.externalDamage = this.supplies.divide(this.cost, 0, RoundingMode.HALF_UP);
262         }
263         this.supplies = this.supplies.subtract(this.externalDamage).add(this.humans);
264         calculateNewPrice();
265     }
266 
267     /**
268      * Change price for next round.
269      */
270     @VisibleForTesting
271     void calculateNewPrice() {
272         this.cost = getRandomNumberUntil(5);
273     }
274 
275     /**
276      * Perform "round"-final calculations such as
277      * <ul>
278      * <li>number of people that died</li>
279      * <li>adapt overall (internal) death statistics</li>
280      * <li>refresh internal famine quotient</li>
281      * </ul>
282      */
283     public void finishRoundAfterActions() {
284         BigDecimal factor = BigDecimal.valueOf(20L).multiply(this.area).add(this.supplies);
285         this.increase = cost.multiply(factor).divide(this.population, 0, RoundingMode.HALF_UP).divide(BigDecimal.valueOf(100).add(BigDecimal.ONE), 0, RoundingMode.HALF_UP);
286 
287         this.cost = this.q.divide(BigDecimal.valueOf(20L), 0, RoundingMode.HALF_UP);
288         refreshFamineQuotient();
289 
290         if (is(this.population).lessThan(this.cost)) {
291             this.deathToll = BigDecimal.ZERO;
292             return; // start new round without any deaths
293         }
294 
295         // calculate deaths
296         this.deathToll = this.population.subtract(this.cost);
297         if (is(this.deathToll).greaterThan(this.population.multiply(BigDecimal.valueOf(0.45)))) {
298             System.out.println(KaiserEnginePrinter.ANSI_YELLOW);
299             System.out.println("Sie haben " + this.deathToll + " Menschen in nur einem Jahr verhungern lassen!");
300             System.out.println("Auf Grund dieser extremen Misswirtschaft, werden Sie nicht nur aus Amt und Würden gejagt,");
301             System.out.println("sondern auch zum Versager des Jahres erklärt.");
302             System.out.println(KaiserEnginePrinter.ANSI_RESET);
303             return; // TODO stop the game here!
304         }
305 
306         // calc death statistics
307         // p1 = ((z-1)*p1+D*100/p)/z
308         BigDecimal tempQuotient = this.percentDeathToll.multiply(BigDecimal.valueOf(this.zYear - 1)).add(this.deathToll.multiply(BigDecimal.valueOf(100)).divide(this.population, 0, RoundingMode.HALF_UP));
309         this.percentDeathToll = tempQuotient.divide(BigDecimal.valueOf(this.zYear), 0, RoundingMode.HALF_UP);
310 
311         this.population = this.cost; // TODO why? shouldn't this somehow be added up?
312         this.deathTollSum = this.deathTollSum.add(this.deathToll);
313     }
314 }