Idraluna Archives

One Hundred-thousand Travellers

A follow-up to One Million Worlds. One million characters took too long to generate so I went down an order of magnitude. The median planet population has 100,000 people, after all.

Thank you to MTB and Ms. Quixotic for suggesting questions to ask the dataset.

The Generator

Generating Classic Traveller characters is a lot trickier than planets, and not just because there are more fiddly components. Whereas world generation is fully procedural, character creation involves moments of decision -- players can choose which service to attempt enlistment, can choose whether or not to re-enlist (except when a 12 is rolled), and can choose which of the Acquired Skills Tables to roll on.

I decided on the following:

So whereas the planets dataset is impartial and can be used to make inferences about any set of planets rolled with the CT ruleset, the following characters are generated with simulated player decision-making. We can draw conclusions about how the character creation rules behave, but can't perfectly generate to the set of all CT characters.

In terms of scope, I was mainly interested in how the numbers affect survival and capacity to achieve different ranks. However, I became interested in understanding how rare each skill is, so I ended up tallying those as well.

Also, I did not use Citizens of The Imperium -- the data-entry required for all the tables is pretty tedious and I didn't want to bother with it for this post, but I do hope to do a follow-up to see how they stack up.

Stats

As noted above, the following results are based on a "mindless" approach to character creation where players always seek to re-enlist. Thus, characters in the set have either failed a re-enlistment roll or a survival roll.

General

Of the 100,000 simulated characters, 48% washed out of their service before dying.

Roughly 1/4th of all characters were drafted into a random service. The rest made it into their first choice. The varying number of characters in each service is a result of enlistment difficulty -- characters that "want" to be in the Army or Other services usually make it, while those who try for Navy or Marines often get drafted into a random other service.

Service Count Survival STR DEX END INT EDU SOC AGE Mean_skills
Army 20419 0.59 6.97 7.10 7.13 6.91 7.12 6.99 24.29 3.98
Marines 11787 0.54 7.20 6.92 6.91 7.11 6.98 6.99 26.56 3.70
Merchants 17300 0.42 6.01 5.80 5.40 6.88 6.97 7.01 39.59 8.14
Navy 13431 0.76 6.55 6.86 6.71 7.29 7.44 7.15 29.26 4.59
Other 20185 0.52 6.35 6.60 6.44 6.93 6.96 6.80 31.35 4.18
Scouts 16878 0.05 6.86 6.83 6.71 7.11 7.08 7.00 27.13 2.93

Values in the Attribute columns are mean values for all characters.

Life Expectancy

Character age for living characters ranged from 22 to 82, with a median of 26 and a mean of 30.

Age at death ranged from 18 to 94, with a median of 24 and a mean of 30.

ott1

Interestingly, the Navy had the highest proportion of surviving characters. I think this is because survival odds are good and rely on INT, which also improves odds of getting into the Navy in the first place, and on top of that re-enlistment is 6+. So characters who make it into the Navy are well-suited to survive and tend to wash out before death.

Scouts, of course, have the lowest -- if you are playing a Scout and decide to push your luck every reenlistment chance, you only have a 6% chance of ending up with a viable character.

On the other hand, Merchants had by far the highest average age, thanks to forgiving survival and re-enlistment rolls. Army has the lowest, as the 7+ reenlistment roll makes one unlikely to last more than a few terms (though shockingly, there's one Army guy who washed out alive at age 66!).

ott3

The above plot depicts the percentage of characters that are still in play a given age. For example, around 60% of Merchants are still in their career at age 30. We can also see death due to aging kick in starting at around age 64 -- notice that the merchant survival rate falls off of its smooth curve and dips toward zero. (Anagathic drugs were not included in the simulation).

ott4

The effect of attributes on survival can be seen here. (Army: EDU, Marines: END, Merchants: INT, Navy: INT, Other: INT, Scouts: END). The size of the gap (I think) has to do with the math around how likely one is to qualify for the +2 DM and the math around the 2d6 survival roll. The gap is small for Scouts because the the roll is still hard even with good END, whereas high INT makes a big difference in the Navy.

The other funny thing to note is that the effects of aging are visible among the Merchants, hence the fact that the distributions for STR, DEX, and END are skewed lower. Truly the chopped uncs of the Traveller universe.

I don't know why mean STR for the Other service is so low, except to hazard a guess that because STR affects 3 other services, low STR characters are likelier to bounce out of other enlistments whereas they can easily make it into Other.

Advancement

ott2

This plot depicts the number of characters that reach each rank. A rank of 0 denotes characters that were drafted and never earned a Commission.

Because the Army has a low promotion target, characters that earn a Commission to rank 1 (Lieutenant) usually jump right on to rank 2 (Captain). The low Commission target for Merchants has lots of characters ending up at rank 1 (4th Officer) but the 10+ needed for a promotion balances out the longer careers.1

The Navy is by far the worst option for gaining ranks, with an overwhelming number of characters never gaining a Commission at all. The Marines are also quite bad, especially given their lower survival rate -- making it to ranks 5 or 6 (Colonel or Brigadier) in the Marines is vanishingly unlikely.

Skills

ott5

Characters that don't die get an average of 5.1 rolls on the skills table.

Merchants, by virtue of their longer careers, enter play with the most skills on average (8.2 rolls). There's at least one Merchant in the dataset who racked up 25 skill rolls before dying!

What about specific skills? (Here, I should reiterate that my simulated characters "choose" skills randomly. Real players would likely strategize table rolls to go after desired skills or attribute increases.)

The following table gives the mean value of each skill across all characters.

Skill Mean_Army Mean_Marines Mean_Merchants Mean_Navy Mean_Other Mean_Scouts
ATV 0.32 0.29 0.00 0.00 0.00 0.00
Admin 0.15 0.15 0.33 0.19 0.00 0.00
Air/Raft 0.16 0.00 0.00 0.00 0.00 0.22
Blade Cbt 0.31 0.59 0.33 0.18 0.34 0.00
Brawling 0.16 0.15 0.00 0.00 0.50 0.00
Bribery 0.00 0.00 0.34 0.00 0.17 0.00
Computer 0.16 0.14 0.33 0.18 0.17 0.10
Electronic 0.16 0.14 0.68 0.19 0.17 0.21
Electronics 0.00 0.00 0.00 0.00 0.17 0.00
Engineering 0.00 0.00 0.34 0.38 0.00 0.11
Forgery 0.00 0.00 0.00 0.00 0.50 0.00
Fwd Obsv 0.15 0.00 0.00 0.19 0.00 0.00
Gambling 0.15 0.15 0.00 0.00 0.33 0.00
Gun Cbt 0.47 0.44 0.33 0.19 0.17 0.11
Gunnery 0.00 0.00 0.34 0.38 0.00 0.11
Jack-o-T 0.00 0.00 0.34 0.19 0.00 0.32
Jack-o-t 0.00 0.00 0.00 0.00 0.17 0.00
Leader 0.16 0.15 0.00 0.00 0.00 0.00
Mechanical 0.16 0.15 0.34 0.19 0.17 0.21
Medical 0.15 0.14 0.67 0.18 0.17 0.22
Navigation 0.00 0.00 0.67 0.19 0.00 0.21
Pilot 0.00 0.00 0.34 0.18 0.00 0.11
Ship's Boat 0.00 0.00 0.00 0.19 0.00 0.00
Steward 0.00 0.00 0.34 0.00 0.00 0.00
Streetwise 0.00 0.00 0.34 0.00 0.34 0.00
Tactics 0.47 0.43 0.00 0.00 0.00 0.00
Vacc Suit 0.00 0.14 0.33 0.38 0.00 0.11

Observations:

Misc

8,200 characters (8.2%) have a SOC of at least 11, entitling them to the use of a hereditary title with their name.

Conclusions

On the whole, careers were shorter than I expected, but consistent with my experiences rolling them by hand.

Merchants come off pretty well -- you can get lots of skills, a good chance of useful skills, and even a shot at a Free Trader. Merchants are definitely the best class if you like to play Traveller character creation as a minigame, since you have a good chance of simulating a long career.

Marines seem a bit under-powered. The skill breakdown and average number of terms are similar to that of the Army, but it's a lot harder to enlist and gain ranks. I guess you could say that this is offset by Marines getting a shot at Traveller's Aid Society and slightly larger cash payouts, but in general Army seems better.

I will probably run some more analysis (I want to test other simulated stopping strategies), but this is getting a bit long and I am running out of steam, so I will save it for a future post.

Code

library(tidyverse)
library(knitr)

r2d6 <- function(dm = 0, max = NA, min = NA){
  d1 = sample(1:6, 1)
  d2 = sample(1:6, 1)
  
  res <- d1+d2+dm
  
  if (!is.na(max)){res <- min(res, max)}
  if (!is.na(min)){res <- max(res, min)}
  
  return(res)
}

prior_service.df <- data.frame(
  Service = c("Navy", "Marines", "Army", "Scouts", "Merchants", "Other", "Castalian", "Church"),
  Enlistment = c(8, 9, 5, 7, 7, 3, 10, 6),
  DM_1 = c("INT 8", "INT 8", "DEX 6", "INT 6", "STR 7", NA, "INT 9", "INT 6"),
  DM_2 = c("EDU 9", "STR 8", "END 5", "STR 8", "INT 6", NA, "EDU 9", "SOC 8"),
  Survival = c(5, 6, 7, 7, 5, 5, 3, 6),
  SDM_2 = c("INT 7", "END 8", "EDU 6", "END 9", "INT 7", "INT 9", "INT 99", "SOC 8"),
  Commission = c(10, 9, 5, NA, 4, NA, 8, 10),
  CDM_1 = c("SOC 9", "EDU 7", "END 7", NA, "INT 6", NA, "INT 10", "SOC 10"),
  Promotion = c(8, 9, 6, NA, 10, NA, 8, 8),
  PDM_1 = c("EDU 8", "SOC 8", "EDU 7", NA, "INT 9", NA, "EDU 10", "EDU 10"),
  Reenlist = c(6, 6, 7, 3, 4, 5, 5, 3)
)

acq_skills.df <- data.frame(
  Roll = 1:24,
  Navy = c("STR", "DEX", "END", "SOC", "INT", "EDU", "Ship's Boat", "Vacc Suit", "Fwd Obsv", "Blade Cbt", "Gun Cbt", "Gunnery", "Vacc Suit", "Mechanical", "Electronic", "Engineering", "Gunnery", "Jack-o-T", "Medical", "Navigation", "Engineering", "Computer", "Pilot", "Admin"),
  Marines = c("STR", "DEX", "END", "Gambling", "Brawling", "Blade Cbt", "ATV", "Vacc Suit", "Blade Cbt", "Blade Cbt", "Gun Cbt", "Gun Cbt", "ATV", "Mechanical", "Electronic", "Tactics", "Blade Cbt", "Gun Cbt", "Medical", "Tactics", "Tactics", "Computer", "Leader", "Admin"),
  Army = c("STR", "DEX", "END", "Gambling", "Brawling", "EDU", "ATV", "Air/Raft", "Fwd Obsv", "Blade Cbt", "Gun Cbt", "Gun Cbt", "ATV", "Mechanical", "Electronic", "Tactics", "Blade Cbt", "Gun Cbt", "Medical", "Tactics", "Tactics", "Computer", "Leader", "Admin"),
  Scouts = c("STR", "DEX", "END", "Gun Cbt", "INT", "EDU", "Air/Raft", "Vacc Suit", "Navigation", "Mechanical", "Electronic", "Jack-o-T", "Air/Raft", "Mechanical", "Electronic", "Jack-o-T", "Gunnery", "Medical", "Medical", "Navigation", "Engineering", "Computer", "Pilot", "Jack-o-T"),
  Merchants = c("STR", "DEX", "END", "STR", "Blade Cbt", "Bribery", "Steward", "Vacc Suit", "STR", "Gun Cbt", "Electronic", "Jack-o-T", "Streetwise", "Mechanical", "Electronic", "Navigation", "Gunnery", "Medical", "Medical", "Navigation", "Engineering", "Computer", "Pilot", "Admin"),
  Other = c("STR", "DEX", "END", "Blade Cbt", "Brawling", "-SOC", "Forgery", "Gambling", "Brawling", "Blade Cbt", "Gun Cbt", "Bribery", "Streetwise", "Mechanical", "Electronic", "Gambling", "Brawling", "Forgery", "Medical", "Forgery", "Electronics", "Computer", "Streetwise", "Jack-o-t"),
  Castalian = c("INT", "INT", "EDU", "EDU", "SOC", "Forgery", "Lute", "Harpsichord", "Calligraphy", "Bookbinding", "Gardening", "Teaching", "Computer", "Mathematics", "Music Theory", "Philosophy", "EDU", "Pedagogy", "Mathematics", "Music Theory", "Astronomy", "History", "Philosophy", "Philology"),
  Church = c("EDU", "INT", "SOC", "STR", "Gambling", "Bribery", "Theology", "Medical", "Streetwise", "EDU", "Jack-o-T", "Bribery", "Theology", "Admin", "Leader", "SOC", "Oration", "EDU", "Admin", "Leader", "Theology", "Casuistry", "Medical", "Computer")
)

parse_dm <- function(charsheet, service, col){
  potential_dm <- str_split_1(col, "_")[2] |> as.numeric()
  
  dm_str <- prior_service.df[prior_service.df$Service == service, col]
  if (is.na(dm_str)){return(0)}
  
  attr <- str_split_1(dm_str, " ")[1]
  target <- str_split_1(dm_str, " ")[2] |> as.numeric()
  
  char_attr <- charsheet[1, attr]
  
  if (char_attr >= target){
    return(potential_dm)
  } else {
    return(0)
  }
}

generate_character <- function(x, simulated_decisions = F, enlistment_services = NULL){
  set.seed(x)
  
  # initialize character sheet
  charsheet = data.frame(
    AGE = 18,
    Alive = T,
    Terms = 0,
    STR = r2d6(),
    DEX = r2d6(),
    END = r2d6(),
    INT = r2d6(),
    EDU = r2d6(),
    SOC = r2d6(),
    Service = NA,
    Draft = F,
    Rank = NA,
    Skill_rolls = 1  # freebie extra for first term
  )
  # print(paste("Starting UPP:", paste0(charsheet$STR, charsheet$DEX, charsheet$END, charsheet$INT, charsheet$EDU, charsheet$SOC)))
  
  # attempt to enlist in a service, take draft roll on failure
  if (!is.null(enlistment_services)){
    chosen_service <- sample(enlistment_services, 1)
  } else {
    chosen_service <- sample(prior_service.df$Service, 1)
  }
  
  # print(paste("Attempting to join", chosen_service))
  enlistment_dm1 <- parse_dm(charsheet, chosen_service, "DM_1")
  enlistment_dm2 <- parse_dm(charsheet, chosen_service, "DM_2")
  enlistment_roll <- r2d6(dm = enlistment_dm1 + enlistment_dm2)
  enlistment_target <- prior_service.df |> filter(Service == chosen_service) |> select(Enlistment) |> pull()
  if (enlistment_roll >= enlistment_target) {
    charsheet$Service <- chosen_service
    charsheet$Rank <- 0
    # print(paste("Enlisted successfully with", charsheet$Service))
  } else {  # draft
    charsheet$Service <- sample(c("Navy", "Marines", "Army", "Scouts", "Merchants", "Other"), 1)
    charsheet$Draft <- T
    # print(paste("Drafted into", charsheet$Service))
  }
  
  mustered_out <- F
  prior_skills <- 0
  
  while (!mustered_out & charsheet$Alive){
    # increment terms
    charsheet$Terms <- charsheet$Terms + 1
    if (is.na(charsheet$Rank) & charsheet$Terms > 1){charsheet$Rank <- 0}  # if second term after being drafted, become eligible for commission.

    # check for survival and increment number of terms
    survival_target <- prior_service.df |> filter(Service == charsheet$Service) |> select(Survival) |> pull()
    survival_dm <- parse_dm(charsheet, charsheet$Service, "SDM_2")
    survival_roll <- r2d6(survival_dm)
    if (survival_roll < survival_target){
      # add random age, change status, and end loop
      charsheet$AGE <- charsheet$AGE + sample(0:3, 1)
      charsheet$Alive <- F
      # print(paste("Died at", charsheet$AGE, "during term", charsheet$Terms))
      break
    }

    charsheet$Skill_rolls <- charsheet$Skill_rolls + 1

    # check for commission
    commission_target <- prior_service.df |> filter(Service == charsheet$Service) |> select(Commission) |> pull()
    if (!is.na(commission_target) & !is.na(charsheet$Rank) & (charsheet$Rank == 0)){  # can only be commissioned if not drafted this term.
      commission_dm <- parse_dm(charsheet, charsheet$Service, "CDM_1")
      commission_roll <- r2d6(commission_dm)
      if (commission_roll >= commission_target){
        charsheet$Rank <- 1
        charsheet$Skill_rolls <- charsheet$Skill_rolls + 1
        # print("Commission earned.")
      }
    }


    # check for promotion if commission is earned
    promotion_target <- prior_service.df |> filter(Service == charsheet$Service) |> select(Promotion) |> pull()
    if (!is.na(promotion_target) & (charsheet$Rank %in% 1:5)){
      promotion_dm <- parse_dm(charsheet, charsheet$Service, "PDM_1")
      promotion_roll <- r2d6(promotion_dm)
      if (promotion_roll >= promotion_target) {
        charsheet$Rank <- charsheet$Rank + 1
        charsheet$Skill_rolls <- charsheet$Skill_rolls + 1
        # print(paste("Promoted to rank", charsheet$Rank))
      }
    }

    # apply skills and attribute increases
    new_rolls <- charsheet$Skill_rolls - prior_skills
    # print(paste("Rolling", new_rolls, "new skill(s)."))

    skill_die <- ifelse(charsheet$EDU >= 8, 1:24, 1:18)

    new_skills <- sample(acq_skills.df[, charsheet$Service], new_rolls, replace = T)

    # print(paste("New skills:", paste(new_skills, collapse = ", ")))

    for (skill in new_skills){
      if (skill %in% colnames(charsheet)){
        charsheet[1, skill] <- charsheet[1, skill] + 1
      } else if (skill == "-SOC"){
        charsheet[1, "SOC"] <- charsheet[1, "SOC"] - 1
      } else {  # otherwise add skill at level 1
        charsheet[1, skill] <- 1
      }
    }

    prior_skills <- charsheet$Skill_rolls

    # update age and resolve age
    charsheet$AGE <- charsheet$AGE + 4

    if (charsheet$AGE %in% 34:49){
      if (r2d6() < 8){charsheet$STR <- charsheet$STR -1}
      if (r2d6() < 7){charsheet$DEX <- charsheet$DEX -1}
      if (r2d6() < 8){charsheet$END <- charsheet$END -1}

    } else if (charsheet$AGE %in% 50:65){
      if (r2d6() < 9){charsheet$STR <- charsheet$STR -1}
      if (r2d6() < 8){charsheet$DEX <- charsheet$DEX -1}
      if (r2d6() < 9){charsheet$END <- charsheet$END -1}

    } else if (charsheet$AGE >= 66){
      if (r2d6() < 9){charsheet$STR <- charsheet$STR -2}
      if (r2d6() < 9){charsheet$DEX <- charsheet$DEX -2}
      if (r2d6() < 9){charsheet$END <- charsheet$END -2}
      if (r2d6() < 9){charsheet$INT <- charsheet$INT -1}
    }

    # and check for death from aging
    if (charsheet$STR <= 0){
      if(r2d6() >= 8){
        charsheet$STR <- 1
      } else {
        # print("Died of age-related illness.")
        charsheet$Alive <- F
        break
      }
    }

    if (charsheet$DEX <= 0){
      if(r2d6() >= 8){
        charsheet$DEX <- 1
      } else {
        # print("Died of age-related illness.")
        charsheet$Alive <- F
        break
      }
    }

    if (charsheet$END <= 0){
      if(r2d6() >= 8){
        charsheet$END <- 1
      } else {
        # print("Died of age-related illness.")
        charsheet$Alive <- F
        break
      }
    }

    if (charsheet$INT <= 0){
      if(r2d6() >= 8){
        charsheet$INT <- 1
      } else {
        # print("Died of age-related illness.")
        charsheet$Alive <- F
        break
      }
    }

    # check for re-enlistment
    re_enlist_roll <- r2d6()
    reenlist_target <- prior_service.df |> filter(Service == charsheet$Service) |> select(Reenlist) |> pull()
    reenlist_desire_target <- survival_target + (charsheet$AGE-34)/16  # every four terms, the desire to stay in drops
    reenlist_desire_roll <- ifelse(simulated_decisions, r2d6(), 12)
    if ((re_enlist_roll != 12) & ((re_enlist_roll < reenlist_target) | (reenlist_desire_roll < reenlist_desire_target))){
      mustered_out <- T
    }

  }
  
  return(charsheet)
  
}

generate_character(1)

character.df <- lapply(1:100000, generate_character, enlistment_services = c("Navy", "Marines", "Army", "Scouts", "Merchants", "Other")) |> bind_rows()
  1. Mea culpa: when writing the code, I didn't realize that the Merchant Service only goes up to rank 5. The rank 6 bar should thus be included in the rank 5 total.

#traveller