|
#!/usr/bin/env python3 |
|
import collections, data, itertools, numpy, pandas |
|
from dataclasses import dataclass |
|
from ortools.algorithms import pywrapknapsack_solver |
|
from typing import Annotated, NewType, TypeAlias |
|
|
|
workshopRank = 3 |
|
landmarkCount = 4 |
|
|
|
# Max number of a single produce/leaving type that can be consumed in a week. |
|
# This is further capped by the script to the physical limitations of the |
|
# cropland/pasture (see weeklyBudget()). |
|
# Want a more diverse cropland/pasture? Reduce the numbers to taste |
|
maxSingleProduce = 999 # 35 for perfectly balanced |
|
maxSingleLeaving = 27 # 28 for perfectly balanced |
|
|
|
################################################################################ |
|
|
|
# A sequence of items a single workshop crafts in a single day |
|
Agenda = NewType("Agenda", list[data.ItemName]) |
|
|
|
# A combination of agendas to be split between multiple workshops and multiple |
|
# days, but not allocated to individual workshops and days yet, so order doesn't |
|
# matter |
|
Combination = NewType("Combination", list[Agenda]) |
|
|
|
# Agendas allocated to specific workshops on specific days of the week |
|
DaySchedule = NewType("DaySchedule", tuple[Agenda, Agenda, Agenda]) |
|
WeekSchedule = NewType("WeekSchedule", tuple[DaySchedule, DaySchedule, DaySchedule, DaySchedule, DaySchedule]) |
|
|
|
Budget: TypeAlias = pandas.Series # pandas.Series[ItemName, float] |
|
Cost: TypeAlias = pandas.Series # pandas.Series[ItemName, float] |
|
|
|
def agendaCost(agenda: Agenda) -> Cost: |
|
return scheduleCost([[agenda]]) # pytype: disable=wrong-arg-types |
|
|
|
def scheduleCost(schedule: WeekSchedule) -> Cost: |
|
cost = [] |
|
for day in schedule: |
|
for agenda in day: |
|
for craft in agenda: |
|
craft = data.workshopCrafts[craft] |
|
cost.append(craft.materials) |
|
return pandas.Series( |
|
numpy.sum(cost, axis=0), |
|
index=data.workshopCraftsMaterials.index, |
|
name="Cost", |
|
dtype=float, |
|
) |
|
|
|
def agendaRevenue(agenda: Agenda) -> float: |
|
# Quick calculation that only cares about craft base value and double bonus. |
|
# Ignores: popularity, supply, groove, workshop rank |
|
assert(len(agenda) > 0) |
|
prevCraft = data.workshopCrafts[agenda[0]] |
|
revenue = prevCraft.value |
|
for craft in agenda[1:]: |
|
craft = data.workshopCrafts[craft] |
|
revenue += craft.value |
|
if craft.item != prevCraft.item and craft.themes & prevCraft.themes: |
|
revenue += craft.value |
|
prevCraft = craft |
|
return revenue |
|
|
|
def scheduleCombination(combination: Combination) -> WeekSchedule: |
|
# Sorts combination by decreasing revenue, and: |
|
# - If len(combination) <= 3: Repeats the agendas for every single day |
|
# - If 3 < len(combination) <= 6: Alternates between combination[:3] and |
|
# combination[3:6] for each day |
|
assert(len(combination) <= 6) |
|
|
|
combination.sort(key=agendaRevenue, reverse=True) |
|
schedule = [] |
|
for day in range(5): |
|
if len(combination) > 3 and day % 2 == 1: |
|
schedule.append(DaySchedule(tuple(combination[3:6]))) |
|
else: |
|
schedule.append(DaySchedule(tuple(combination[:3]))) |
|
return WeekSchedule(tuple(schedule)) |
|
|
|
@dataclass |
|
class RevenueBreakdown: |
|
total: float |
|
@dataclass |
|
class Item: |
|
name: data.ItemName |
|
revenue: float |
|
by: str |
|
hour: int |
|
items: list[Item] |
|
def scheduleRevenue(schedule: WeekSchedule, weeklyBudget: Budget, includeDetails: bool = False) -> RevenueBreakdown: |
|
# Does a more detailed calculation taking into account: |
|
# - Multipliers: double bonus, supply, groove, workshop rank |
|
# - Direct selling of leftover budget |
|
# Ignores the effects of popularity and random supply shift (assumes they |
|
# average out to 1) |
|
|
|
result = RevenueBreakdown(0, []) |
|
groove = 0 |
|
itemCount = collections.Counter() |
|
|
|
def sellCraft(craft: data.WorkshopCraft, by: str, hour: int) -> None: |
|
revenue = (craft.value |
|
* data.supplyValueMod(itemCount[craft.item]) |
|
* data.workshopRankValueMod[workshopRank] |
|
* (1 + groove / 100)) |
|
result.total += revenue |
|
if includeDetails: |
|
result.items.append(RevenueBreakdown.Item( |
|
name = craft.item, |
|
revenue = revenue, |
|
by = by, |
|
hour = hour, |
|
)) |
|
itemCount[craft.item] += 1 |
|
|
|
def workshopDay(agenda: Agenda, workshop: int, day: int) -> collections.abc.Iterator[None]: |
|
nonlocal groove |
|
prev = None |
|
hour = 0 |
|
for craft in agenda: |
|
craft = data.workshopCrafts[craft] |
|
|
|
isBonus = prev and craft.item != prev.item and craft.themes & prev.themes |
|
if isBonus and groove < data.grooveCap[landmarkCount]: |
|
groove += 1 |
|
|
|
for _ in range(craft.craftingTime): |
|
yield |
|
hour += 1 |
|
|
|
sellCraft(craft, f"Workshop {workshop}", day * 24 + hour) |
|
if isBonus: |
|
sellCraft(craft, f"Workshop {workshop}", day * 24 + hour) |
|
|
|
prev = craft |
|
|
|
for d, day in enumerate(schedule): |
|
workshops = [workshopDay(agenda, a + 1, d) for a, agenda in enumerate(day)] |
|
for _ in itertools.zip_longest(*workshops): |
|
pass |
|
|
|
def sellOther(item: data.ItemName, units: float, unitPrice: float) -> None: |
|
result.total += units * unitPrice |
|
if includeDetails: |
|
result.items.append(RevenueBreakdown.Item( |
|
name = data.ItemName(f"{item} ×{units:.1f}"), |
|
revenue = units * unitPrice, |
|
by = "Exporter", |
|
hour = len(schedule) * 24, |
|
)) |
|
|
|
cost = scheduleCost(schedule) |
|
assert(weeklyBudget.index.equals(cost.index)) |
|
leftover = weeklyBudget.to_numpy() - cost.to_numpy() |
|
assert((leftover >= 0).all()) |
|
leftover = pandas.Series(leftover, index=cost.index, dtype=float) |
|
|
|
if leftover["Produce"]: |
|
sellOther(data.ItemName("Produce"), leftover["Produce"], 6) |
|
if leftover["Leavings"]: |
|
sellOther(data.ItemName("Leavings"), leftover["Leavings"], 12) |
|
for item in data.rareMaterial: |
|
if leftover[item] != 0: |
|
sellOther(item, leftover[item], 25) |
|
|
|
return result |
|
|
|
def allAgendas(crafts: collections.abc.Collection[data.WorkshopCraft], maxHours: int = 24) -> collections.abc.Iterator[Agenda]: |
|
for craft in crafts: |
|
if craft.craftingTime > maxHours: |
|
continue |
|
yield Agenda([craft.item]) |
|
for agenda in allAgendas(crafts, maxHours - craft.craftingTime): |
|
yield Agenda([craft.item] + agenda) |
|
|
|
def allCombinations(agendaSpace: collections.abc.Collection[tuple[Agenda, Cost]], dailyBudget: Budget, maxDepth: int = 3) -> collections.abc.Iterator[Combination]: |
|
# pandas doesn't vectorise binomial operations, so use numpy instead |
|
numpyAgendaSpace = [] |
|
for agenda, cost in agendaSpace: |
|
assert(cost.index.equals(dailyBudget.index)) |
|
numpyAgendaSpace.append((agenda, cost.to_numpy())) |
|
return _allCombinations(numpyAgendaSpace, dailyBudget.to_numpy(), maxDepth) |
|
|
|
def _allCombinations(agendaSpace: collections.abc.Collection[tuple[Agenda, numpy.ndarray]], dailyBudget: numpy.ndarray, maxDepth: int) -> collections.abc.Iterator[Combination]: |
|
if maxDepth <= 0: |
|
return |
|
|
|
viableAgendas = [] |
|
newBudgets = [] |
|
for agenda, cost in agendaSpace: |
|
newBudget = dailyBudget - cost |
|
if (newBudget >= 0).all(): |
|
viableAgendas.append((agenda, cost)) |
|
newBudgets.append(newBudget) |
|
|
|
for (agenda, cost), newBudget in zip(viableAgendas, newBudgets): |
|
yield Combination([agenda]) |
|
for combination in _allCombinations(viableAgendas, newBudget, maxDepth - 1): |
|
yield Combination([agenda] + combination) |
|
|
|
def combinationKnapsackSolver(agendaSpace: collections.abc.Sequence[tuple[Agenda, Cost]], dailyBudget: Budget) -> Combination: |
|
# Treat each possible agenda as items to fit into the combination knapsack. |
|
# This won't necessarily get us the optimal solution since the knapsack |
|
# solver won't be able to take into account dependencies between agendas |
|
# like supply, groove, and selling of leftover goods. But it should get us |
|
# a reasonably good solution |
|
|
|
# Solve for 2 days worth at once + a fudge factor due to 2 not evenly |
|
# dividing 5 crafting days |
|
budgetAndCosts = [(2 * 5 / 6) * dailyBudget] |
|
for agenda, cost in agendaSpace: |
|
budgetAndCosts.append(cost) |
|
budgetAndCosts = numpy.ceil(1000 * pandas.DataFrame(budgetAndCosts).fillna(0)).astype(int) |
|
budgetAndCosts.loc[:, "Agenda"] = [6] + [1] * len(agendaSpace) |
|
|
|
profits = [round(agendaRevenue(a[0])) for a in agendaSpace] |
|
capacities = budgetAndCosts.iloc[0].to_numpy().tolist() |
|
weights = budgetAndCosts.iloc[1:].T.to_numpy().tolist() |
|
|
|
solver = pywrapknapsack_solver.KnapsackSolver( |
|
pywrapknapsack_solver.KnapsackSolver.KNAPSACK_MULTIDIMENSION_SCIP_MIP_SOLVER, |
|
"Knapsack Solver" |
|
) |
|
solver.Init(profits, weights, capacities) |
|
solver.set_time_limit(60) |
|
solver.Solve() |
|
|
|
combination = [] |
|
for a in range(len(agendaSpace)): |
|
if solver.BestSolutionContains(a): |
|
combination.append(agendaSpace[a][0]) |
|
return Combination(combination) |
|
|
|
def weeklyBudget(granaries: collections.abc.Collection[data.Source]) -> Budget: |
|
budget = collections.Counter() |
|
for granary in granaries: |
|
for item in data.sourceToItem[granary]: |
|
budget[item] += 4 # Average number gathered |
|
budget["Produce"] = (5 * 20 / 2) - (20 / 3 * 4) # (grown) - (fed to pasture) |
|
budget["Leavings"] = (20) + (0.8 * 20) # (common) + (rare) |
|
|
|
for item in data.sourceToItem[data.Source("Cropland")]: |
|
budget[item] = maxSingleProduce / 7 |
|
for item in data.sourceToItem[data.Source("Pasture")]: |
|
budget[item] = maxSingleLeaving / 7 |
|
|
|
budget = pandas.Series(budget, name="Budget", dtype=float) * 7 |
|
_, aligned = data.workshopCraftsMaterials.align(budget, join="left", axis=0, fill_value=0) |
|
assert(aligned.gt(0).sum() == budget.gt(0).sum()) |
|
assert(aligned.index.equals(data.workshopCraftsMaterials.index)) |
|
|
|
return aligned |
|
|
|
def findOptimalCombinations() -> list[tuple[Annotated[float, "revenue"], Annotated[collections.abc.Collection[data.Source], "granaries"], Combination]]: |
|
shortlist = {} |
|
for granaries in itertools.combinations_with_replacement(sorted(data.granary), 2): |
|
# We could run all granary combinations in parallel, but the total serial |
|
# runtime is only ~2m so not worth to implement |
|
budget = weeklyBudget(granaries) |
|
print("Calculating:", " + ".join(granaries)) |
|
|
|
viableCrafts = [] |
|
for craft in data.workshopCrafts.values(): |
|
if (budget / 5).sub(craft.materials, fill_value=0).ge(0).all(): |
|
viableCrafts.append(craft) |
|
|
|
agendaSpace = {} |
|
for agenda in allAgendas(viableCrafts): |
|
revenue = agendaRevenue(agenda) |
|
key = tuple(sorted(agenda)) |
|
if revenue > agendaSpace.setdefault(key, (0,))[0]: |
|
agendaSpace[key] = (revenue, agenda) |
|
agendaSpace = [(e[1], agendaCost(e[1])) for e in sorted(agendaSpace.values(), key=lambda e: e[0], reverse=True)] |
|
|
|
# Try both a greedy (brute force over top 200 agendas) and a knapsack |
|
# solver. |
|
# The knapsack solver tends to produce a better solution, but the greedy |
|
# solver produces multiple solutions that can be used as alternatives |
|
combinations = [] |
|
for combination in itertools.chain( |
|
allCombinations(agendaSpace[:200], budget / 5), |
|
[combinationKnapsackSolver(agendaSpace, budget / 5)], |
|
): |
|
schedule = scheduleCombination(combination) |
|
combinations.append((scheduleRevenue(schedule, budget).total, combination)) |
|
combinations.sort(key=lambda e: e[0], reverse=True) |
|
|
|
# Shortlist the top 5 combinations with unique material costs |
|
n = 0 |
|
for revenue, combination in combinations: |
|
schedule = scheduleCombination(combination) |
|
key = tuple(scheduleCost(schedule).items()) |
|
if revenue > shortlist.setdefault(key, (0,))[0]: |
|
shortlist[key] = (revenue, granaries, combination) |
|
n += 1 |
|
if n >= 5: |
|
break |
|
print() |
|
return list(sorted(shortlist.values(), key=lambda e: e[0], reverse=True)) # pytype: disable=bad-return-type |
|
|
|
def printOptimalCombinations() -> None: |
|
shortlist = findOptimalCombinations() |
|
|
|
print("#####################") |
|
print("# Shortlist Summary #") |
|
print("#####################") |
|
for _, granaries, combination in shortlist: |
|
budget = weeklyBudget(granaries) |
|
combination.sort(key=agendaRevenue, reverse=True) |
|
schedule = scheduleCombination(combination) |
|
|
|
print("Profit:", scheduleRevenue(schedule, budget).total |
|
- 7 * 2 * 50 # Granary |
|
- 7 * 20 * (5 + 2 / 2) # Cropland |
|
- 7 * 20 * 10 # Pasture |
|
) |
|
print(" Granaries:", " + ".join(granaries)) |
|
for s, setting in enumerate(combination): |
|
print(f" Preset {s + 1}:", " → ".join(setting)) |
|
|
|
cost = scheduleCost(schedule) |
|
print(" Cost:", list(cost.loc[cost != 0].sort_index().items())) |
|
|
|
print() |
|
print("########################") |
|
print("# Details of Top Entry #") |
|
print("########################") |
|
_, granaries, combination = shortlist[0] |
|
budget = weeklyBudget(granaries) |
|
schedule = scheduleCombination(combination) |
|
print(budget.loc[budget != 0].sort_index()) |
|
print() |
|
cost = scheduleCost(schedule) |
|
print(cost.loc[cost != 0].sort_index()) |
|
print() |
|
revenue = scheduleRevenue(schedule, budget, includeDetails=True) |
|
print(f"Revenue: {revenue.total:.0f}") |
|
print(f" {'Price':>6s} {'Name':35s} {'Hour':4s} {'By':10s}") |
|
for item in revenue.items: |
|
print(f" {item.revenue:6.1f} {item.name:35s} {item.hour:4d} {item.by:10s}") |
|
|
|
printOptimalCombinations() |