Tiny Tower

From the Tiny Tower wiki:

Tiny Tower is a simulation game developed by NimbleBit and released in June 2011. It is available to download on Apple iOS devices (3.0 or later) and Android devices. The object of the game is to build and manage a large skyscraper. Each new floor the player builds is either a Commercial floor that hosts businesses and venues, or a Residential floor that Bitizens live in. Bitizens live and work in the player's Tower, paying rent every day and stocking the Commercial floors with items to sell. The player's goal is to turn a profit, build more floors, and manage their ever-growing tower.

Like you and me, bitizens have job satisfaction. This is decided by two things, their skill in the job, and if it is their dream job. To be specific:

Category / Color

Each business has a color that represents a category:

  • Green for food
  • Blue for service
  • Purple for retail
  • Yellow for recreation
  • Orange for creative

Each bitizen has one skill level, ranging from 0 - 9, in each color. The higher the skill level, the greater the discount will be for stocking items.

Dream Job

They also have one dream job that corresponds to a name of a business. When a bitizen is employed at her dream job she will be able to stock twice as many items as normal.

Sorting by Job Satisfaction

Higher job satisfaction means higher revenue, and using the above information we can create a tool that takes a list of bitizens and a list of businesses as input, and outputs a list of optimal employments (each business can employ three bitizens).

Let's use the following formula for scoring the employment value of a bitizen:

1: 
Skill * 10 + 10 - Avg(Rest of Skills)

What this means is that the skill we are looking for is the one highest valued, and that specialists (high skill in question, low for others) are more valued than generalists (high in all skills). Here are some examples:

1: 
2: 
3: 
4: 
5: 
6: 
7: 
8: 
  |
  V
0,9,0 => 9*10 + 10-0 = 100
1,9,1 => 9*10 + 10-1 = 99
8,9,1 => 9*10 + 10-4.5 = 95,5
8,9,8 => 9*10 + 10-8 = 92
9,9,9 => 9*10 + 10-9 = 91
0,8,0 => 8*10 + 10-0 = 90

The arrow denotes the skill column we are looking for in this specific example (the actual data will have of course have 5 skill values for each bitizen). As you can see, a bitizen with 9 in all skills is valued lower than one with 9 in only the desired skill. This is because if there are, say, 4 bitizens with a skill value of 9 for a specific job, then it's probably better to use up the specialist for this job and save the generalist for another job further down the list. The calculated value is then used to sort the list of bitizens based on which business that are looking for employees.

Types

Let's start with the F# types:

 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: 
type Color =
    | Green
    | Blue
    | Yellow
    | Purple
    | Orange
    with
        static member Parse color =
            match color with
            | "green" -> Color.Green
            | "blue" -> Color.Blue
            | "yellow" -> Color.Yellow
            | "purple" -> Color.Purple
            | "orange" -> Color.Orange
            | _ -> raise(ArgumentException("Invalid color"))
        override this.ToString() =
            match this with
            | Green -> "Green"
            | Blue -> "Blue"
            | Yellow -> "Yellow"
            | Purple -> "Purple"
            | Orange -> "Orange"

type Skill(color: Color, value: int) =
    do if value < 0 || value > 9 then raise (ArgumentException("Invalid skill value: " + sprintf "%i" value))
    member this.Color = color
    member this.Value = value

type Bitizen(name: string, dreamJob: string, skills: Skill[]) =
    do if skills.Length <> 5 then raise(ArgumentException("Invalid number of skills"))
    let skillSum = skills |> Array.sumBy (fun s -> s.Value) |> decimal
    member this.Name = name
    member this.DreamJob = dreamJob
    member this.SortValueFor (color: Color) =
        let matchingSkill = decimal (skills |> (Array.find (fun s -> s.Color = color))).Value
        let avgOtherskills = (skillSum - matchingSkill) / 4m
        0m - (matchingSkill * 10m + 10m - avgOtherskills)
    override this.ToString() =
        name + ", " + dreamJob + ", [| " + String.Join("; ", skills |> Array.map (fun s -> string s.Color + ": " + string s.Value)) + " |]"

type Job = 
    { Name: string; Color: Color; }
    with
        override this.ToString() =
            this.Name + ", " + string this.Color

type Position = 
    { Job: Job; Employee: Bitizen; }
    with
        override this.ToString() =
            let jobStr = string this.Job
            jobStr + String.replicate (25 - jobStr.Length) " " + string this.Employee

From this we can create a list of bitizens that each have a dream job and a helper method for getting her skill value based on a color. We can also create a list of jobs where each job is of a specific color (i.e. category).

Filling vacant positions

From one list of jobs and one of bitizens, assign jobs according to our earlier defined formula using the following function:

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
let fillPositions filter (jobs: Job list) (bitizens: Bitizen list) =
    let fillOne (job: Job) (bitizens: Bitizen list) filter =
        let matches, rest = List.partition (fun b -> filter job b) bitizens
        let sortedMatches = matches |> List.sortBy (fun b -> b.SortValueFor job.Color)
        match sortedMatches with
        | [] -> None, bitizens
        | head::tail  -> Some { Job = job; Employee = head }, tail@rest
    let rec fillPositionsInner (jobs: Job list) (bitizens: Bitizen list) (positions: Position list) =
        match jobs with
        | [] -> positions, bitizens
        | head::tail -> match fillOne head bitizens filter with
                        | None, b -> fillPositionsInner tail b (positions)
                        | Some p, b -> fillPositionsInner tail b (p::positions)
    fillPositionsInner jobs bitizens []

This function contains two other functions, fillOne and fillPositionsInner. The latter takes the first job and then uses fillOne to find the most suitable bitizen. Apart from the jobs- and bitizen lists a filter argument is also required, more on this soon. fillOne sorts the bitizen list according to "best for the requested skill", if the bitizen list is empty then a tuple None * Bitizen list is returned, otherwise Some(Position) * Bitizen list, where the second position holds the remaining unassigned bitizens, is returned.

Program Start

The program is started using this function:

 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: 
[<EntryPoint>]
let main argv =
    let jobs = 
        (new CsvProvider<"shops.csv">()).Data
        |> Seq.map (fun r -> seq { for i in 1..3 -> { Name = r.Name; Color = Color.Parse r.Color }})
        |> Seq.collect (fun o -> seq { yield! o })
        |> List.ofSeq

    let bitizens = 
        (new CsvProvider<"bitizens.csv">()).Data
        |> Seq.map
            (fun r ->
                Bitizen(r.Name, r.DreamJob, 
                    [| 
                        Skill(Color.Blue, r.Blue);
                        Skill(Color.Green, r.Green);
                        Skill(Color.Orange, r.Orange);
                        Skill(Color.Purple, r.Purple);
                        Skill(Color.Yellow, r.Yellow)
                    |]))
        |> List.ofSeq

    let dreamPositions, bitizens = fillPositions (fun j b -> j.Name.ciCompare(b.DreamJob)) jobs bitizens
    let normalPositions, bitizens = fillPositions (fun j b -> true) jobs bitizens

    File.WriteAllLines("positions.txt", dreamPositions@normalPositions |> List.map (fun p -> string p))

    0

It reads two csv files using the csv type provider and from this creates the types we need (oh, and type providers are the greatest thing ever). The available jobs list is created by making three jobs for each business. Next we need to fill positions by calling the fillPositions function. This is done twice, first by using a filter function that matches dream jobs, making sure that these are assigned first, then once more for the remaining bitizens. One last note on this function, as you can see there is a string method ciCompare in the dream positions filter function. This is simply an extension function on the string type which is defined as:

1: 
2: 
3: 
type String with
    member this.ciCompare other =
        System.String.Equals(this, other, StringComparison.InvariantCultureIgnoreCase)

Source

You can find the full source as a Visual Studio solution on my github account: https://github.com/andagr/SortTinyTowerWorkers

Final Note

I wrote this tool as an F# learning experience and I welcome any input on improvements on everything from the syntax to how this could be better modeled.

val raise : exn:System.Exception -> 'T

Full name: Microsoft.FSharp.Core.Operators.raise
Multiple items
val int : value:'T -> int (requires member op_Explicit)

Full name: Microsoft.FSharp.Core.Operators.int

--------------------
type int = int32

Full name: Microsoft.FSharp.Core.int

--------------------
type int<'Measure> = int

Full name: Microsoft.FSharp.Core.int<_>
val sprintf : format:Printf.StringFormat<'T> -> 'T

Full name: Microsoft.FSharp.Core.ExtraTopLevelOperators.sprintf
Multiple items
val string : value:'T -> string

Full name: Microsoft.FSharp.Core.Operators.string

--------------------
type string = System.String

Full name: Microsoft.FSharp.Core.string
module Array

from Microsoft.FSharp.Collections
val sumBy : projection:('T -> 'U) -> array:'T [] -> 'U (requires member ( + ) and member get_Zero)

Full name: Microsoft.FSharp.Collections.Array.sumBy
Multiple items
val decimal : value:'T -> decimal (requires member op_Explicit)

Full name: Microsoft.FSharp.Core.Operators.decimal

--------------------
type decimal = System.Decimal

Full name: Microsoft.FSharp.Core.decimal

--------------------
type decimal<'Measure> = decimal

Full name: Microsoft.FSharp.Core.decimal<_>
val find : predicate:('T -> bool) -> array:'T [] -> 'T

Full name: Microsoft.FSharp.Collections.Array.find
module String

from Microsoft.FSharp.Core
val map : mapping:('T -> 'U) -> array:'T [] -> 'U []

Full name: Microsoft.FSharp.Collections.Array.map
val replicate : count:int -> str:string -> string

Full name: Microsoft.FSharp.Core.String.replicate
type 'T list = List<'T>

Full name: Microsoft.FSharp.Collections.list<_>
Multiple items
module List

from Microsoft.FSharp.Collections

--------------------
type List<'T> =
  | ( [] )
  | ( :: ) of Head: 'T * Tail: 'T list
  interface IEnumerable
  interface IEnumerable<'T>
  member Head : 'T
  member IsEmpty : bool
  member Item : index:int -> 'T with get
  member Length : int
  member Tail : 'T list
  static member Cons : head:'T * tail:'T list -> 'T list
  static member Empty : 'T list

Full name: Microsoft.FSharp.Collections.List<_>
val partition : predicate:('T -> bool) -> list:'T list -> 'T list * 'T list

Full name: Microsoft.FSharp.Collections.List.partition
val sortBy : projection:('T -> 'Key) -> list:'T list -> 'T list (requires comparison)

Full name: Microsoft.FSharp.Collections.List.sortBy
union case Option.None: Option<'T>
union case Option.Some: Value: 'T -> Option<'T>
Multiple items
type EntryPointAttribute =
  inherit Attribute
  new : unit -> EntryPointAttribute

Full name: Microsoft.FSharp.Core.EntryPointAttribute

--------------------
new : unit -> EntryPointAttribute
namespace Microsoft.FSharp.Data
module Seq

from Microsoft.FSharp.Collections
val map : mapping:('T -> 'U) -> source:seq<'T> -> seq<'U>

Full name: Microsoft.FSharp.Collections.Seq.map
Multiple items
val seq : sequence:seq<'T> -> seq<'T>

Full name: Microsoft.FSharp.Core.Operators.seq

--------------------
type seq<'T> = System.Collections.Generic.IEnumerable<'T>

Full name: Microsoft.FSharp.Collections.seq<_>
val collect : mapping:('T -> #seq<'U>) -> source:seq<'T> -> seq<'U>

Full name: Microsoft.FSharp.Collections.Seq.collect
val ofSeq : source:seq<'T> -> 'T list

Full name: Microsoft.FSharp.Collections.List.ofSeq
val map : mapping:('T -> 'U) -> list:'T list -> 'U list

Full name: Microsoft.FSharp.Collections.List.map
namespace System
Multiple items
type String =
  new : value:char -> string + 7 overloads
  member Chars : int -> char
  member Clone : unit -> obj
  member CompareTo : value:obj -> int + 1 overload
  member Contains : value:string -> bool
  member CopyTo : sourceIndex:int * destination:char[] * destinationIndex:int * count:int -> unit
  member EndsWith : value:string -> bool + 2 overloads
  member Equals : obj:obj -> bool + 2 overloads
  member GetEnumerator : unit -> CharEnumerator
  member GetHashCode : unit -> int
  ...

Full name: System.String

--------------------
System.String(value: nativeptr<char>) : unit
System.String(value: nativeptr<sbyte>) : unit
System.String(value: char []) : unit
System.String(c: char, count: int) : unit
System.String(value: nativeptr<char>, startIndex: int, length: int) : unit
System.String(value: nativeptr<sbyte>, startIndex: int, length: int) : unit
System.String(value: char [], startIndex: int, length: int) : unit
System.String(value: nativeptr<sbyte>, startIndex: int, length: int, enc: System.Text.Encoding) : unit
System.String.Equals(a: string, b: string) : bool
System.String.Equals(a: string, b: string, comparisonType: System.StringComparison) : bool