Skip to content

Instantly share code, notes, and snippets.

@gaesa
Created May 18, 2025 13:56
Show Gist options
  • Save gaesa/7430a9d6bb051dbebfb27e177681e06e to your computer and use it in GitHub Desktop.
Save gaesa/7430a9d6bb051dbebfb27e177681e06e to your computer and use it in GitHub Desktop.
Find the missing weight to balance a buff scale in ASTLIBRA.

This script assists players of ASTLIBRA ~生きた証~ Revision in solving a specific challenge related to the buff scale balancing system in the game.

🎮 Context

In ASTLIBRA, players can place various items on the left and right sides of a scale. Each item has a specific weight and provides different buff effects (such as block durability, attack power, max stamina, etc.). Players must balance the total weight on both sides of the scale to maximize buff effectiveness. An unbalanced scale weakens the buffs.

Unlike tools that attempt to balance a full set of items by trying all possible placements, this script takes a more realistic approach: it assumes the player first places key buff items (regardless of weight), then wants to fill in the missing item to restore balance. It then systematically explores all distinct ways to assign the known items to both sides of the scale, and for each configuration, it calculates the missing weight needed to achieve perfect balance.

🧠 What this script does

Given:

  • Number of slots on each side of the scale,
  • Known weights already placed (one weight missing),
  • Allowed range for the unknown weight,

The script will compute the missing weight that would result in a perfectly balanced scale, along with the side it should go on.

This is done with intelligent deduplication: order within each side is ignored, and if both sides have the same number of slots, symmetric configurations are treated as equivalent.

✅ Example Usage

python3 astra_balancer.py --left=3 --right=3 70,58,43,64,85

This means:

  • 3 items on the left side,
  • 3 items on the right side,
  • You already placed 5 known weights,
  • The 6th (unknown) weight is missing,
  • Script will suggest the correct missing weight and which side to place it on.

Optional arguments:

--min=2 --max=99   # Specify allowed weight range (default: 2–99)
#!/usr/bin/env python3
import enum
from collections.abc import Iterable, Sized
from dataclasses import dataclass
from typing import Self, final
def is_empty(x: Sized):
return len(x) == 0
@final
@dataclass(frozen=True, kw_only=True, slots=True)
class Cli:
left: int
right: int
weights: list[int]
allowed_range: range
@classmethod
def parse(cls) -> Self:
from argparse import ArgumentParser, ArgumentTypeError
def parse_int_list(arg: str) -> list[int]:
"""Convert comma-separated numbers into a list of integers."""
try:
return [int(x.strip()) for x in arg.removesuffix(",").split(",")]
except ValueError:
raise ArgumentTypeError(f"Invalid number list: {arg}")
parser = ArgumentParser(
description="Balance a scale by adding one missing weight.",
epilog="Example: astra-balancer --left=5 --right=4 70,58,43,64,85,55,60,59",
)
parser.add_argument(
"--left", type=int, required=True, help="Number of slots on the left side"
)
parser.add_argument(
"--right", type=int, required=True, help="Number of slots on the right side"
)
parser.add_argument(
"weights",
type=parse_int_list,
help="List of known weights excluding the missing one. (comma-separated)",
)
parser.add_argument(
"--min",
type=int,
default=2,
help="Minimum possible missing weight. (default: 2)",
)
parser.add_argument(
"--max",
type=int,
default=99,
help="Maximum possible missing weight. (default: 99)",
)
args = parser.parse_args()
if not (1 <= args.left <= 5):
raise ValueError(
f"Left side count must be between 1 and 5, got {args.left}."
)
if not (1 <= args.right <= 5):
raise ValueError(
f"Right side count must be between 1 and 5, got {args.right}."
)
expected_weights_len = args.left + args.right - 1
# this ensures `weights` has at least one item
if len(args.weights) != expected_weights_len:
raise ValueError(
f"Expected {expected_weights_len} weights, got {len(args.weights)}."
)
if args.min > args.max:
raise ValueError(
f"Invalid range: min ({args.min}) cannot be greater than max ({args.max})."
)
allowed_range = range(args.min, args.max + 1)
for w in args.weights:
if w not in allowed_range:
raise ValueError(
f"Weight {w} is outside of the allowed range ({args.min} ~ {args.max})."
)
return cls(
left=args.left,
right=args.right,
weights=args.weights,
allowed_range=allowed_range,
)
@final
class Position(enum.Enum):
LEFT = enum.auto()
RIGHT = enum.auto()
def __str__(self) -> str:
return self.name.capitalize()
@final
@dataclass(frozen=True, kw_only=True, slots=True)
class MissingItem:
weight: int
position: Position
def __str__(self) -> str:
return f"{self.weight} ({self.position})"
@final
@dataclass(frozen=True, kw_only=True, slots=True)
class Output:
left: list[int]
right: list[int]
missing: MissingItem
def __str__(self) -> str:
return f"Left: {self.left}, Right: {self.right}, Missing: {self.missing}"
def find_balanced_combinations(
left_capacity: int, right_capacity: int, weights: list[int], weight_range: range
) -> Iterable[Output]:
results: list[Output] = []
cache = set() # for deduplication
generate_state = (
(lambda left, right: (frozenset(left), frozenset(right)))
if left_capacity != right_capacity
else (
lambda left, right: (
( # to work around the restriction of python's lambdas
lambda l, r: (
(l, r),
(r, l),
)
)(frozenset(left), frozenset(right))
)
)
)
add_state = (
(lambda state: cache.add(state))
if left_capacity != right_capacity
else (lambda state: cache.add(state[0]) is None and cache.add(state[1]))
)
in_cache = (
(lambda state: state in cache)
if left_capacity != right_capacity
else (lambda state: state[0] in cache or state[1] in cache)
)
def iter_fn(left: list[int], right: list[int], remaining: list[int]):
def base_case(left: list[int], right: list[int]):
"""
Check the possibility of balance.
"""
# `len(weights) == left_capacity + right_capacity - 1`
# According to the above rule and `is_empty(remaning)` (`remaining` is chosen from `weights`),
# if one side is full, another side must be the one missing a weight.
is_full_side_left = len(left) == left_capacity
full_side, not_full_side = (
(left, right) if is_full_side_left else (right, left)
)
missing_weight = sum(full_side) - sum(not_full_side)
if missing_weight in weight_range:
pos = Position.RIGHT if is_full_side_left else Position.LEFT
results.append(
Output(
left=left,
right=right,
missing=MissingItem(weight=missing_weight, position=pos),
)
)
def recursive_case(
new_remaining, *, mutated_side: list[int], immutated_side: list[int]
):
"""
Try placing one of the items from the `remaining` list onto one side.
`len(remaining) == len(weights) - len(left) - len(right)` ensures that
the remaining list represents the items that still need to be placed on either side.
In each recursive step, we have one of two options for each item in `remaining`:
either place the item on the left side or on the right side (mutated side),
the other side staying unchanged is the immutated side,
as an item can only be placed on one side.
This recursive process explores all possible ways the remaining items can be
distributed between the two sides, gradually growing `left` and `right` while shrinking `remaining`.
The total number of combinations results from the sum of the possible configurations
for each of the two sides.
"""
new = mutated_side.copy()
new.append(item)
iter_fn(new, immutated_side, new_remaining)
state = generate_state(left, right)
if in_cache(state):
return
else:
add_state(state)
if is_empty(remaining):
base_case(left, right)
else:
item = remaining[0] # safety: the caller ensures it has at least one item
new_remaining = remaining[1:]
if len(left) < left_capacity:
recursive_case(new_remaining, mutated_side=left, immutated_side=right)
if len(right) < right_capacity:
recursive_case(new_remaining, mutated_side=right, immutated_side=left)
iter_fn([], [], weights)
results.sort(key=lambda x: x.missing.weight)
return results
def main():
cli = Cli.parse()
for output in find_balanced_combinations(
cli.left, cli.right, cli.weights, cli.allowed_range
):
print(output)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment