Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ public void onTaxCycle(@NotNull TaxCycleEvent event) {
}

Set<UUID> exempt = new HashSet<>(settings.exemptUuids());
int threshold = settings.exemptPlotThreshold();
Instant periodStart = event.getPeriodStart();

// Resolve the configured destination account once for the whole batch.
Expand All @@ -93,20 +92,19 @@ public void onTaxCycle(@NotNull TaxCycleEvent event) {
for (Map.Entry<UUID, Map<String, Set<String>>> ownerEntry : regionsByOwner.entrySet()) {
UUID owner = ownerEntry.getKey();
Map<String, Set<String>> regions = ownerEntry.getValue();
int plots = regions.size();

if (exempt.contains(owner)) {
continue;
}

// The Act's property tax is a single function of plot count, charged once
// per owner. The policy applies the exemption threshold and any optional
// local per-property overrides; with no rules configured it is exactly
// floor(default-formula(plots)).
BigDecimal taxAmount = policy.taxForOwner(new ArrayList<>(regions.values()), threshold);
// Each tax rule is charged once per owner, on the number of that owner's
// plots which fell to it. Plots matching no rule are untaxed.
List<Set<String>> plotTagSets = new ArrayList<>(regions.values());
BigDecimal taxAmount = policy.taxForOwner(plotTagSets);
if (taxAmount.signum() <= 0) {
continue;
}
int plots = policy.taxablePlotCount(plotTagSets);

int accountId = resolvePersonalAccountId(owner);
if (accountId == -1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,26 @@
import org.spongepowered.configurate.objectmapping.meta.Setting;

/**
* A single property-tax rule: a {@link TagMatch} predicate plus the formula
* (a {@link io.github.md5sha256.realty.tax.TaxFormula} expression over
* {@code <plots>}) applied to regions it matches. Rules are evaluated top-to-
* bottom and the first match wins.
* One property-tax bracket: a {@link TagMatch} predicate paired with the formula
* charged to the plots it matches.
*
* <p>A rule is an <em>aggregate</em> charge, not a per-plot one. Each plot is
* assigned to the first rule that matches it; the rule's formula is then evaluated
* <em>once</em> per owner with {@code <plots>} bound to how many of that owner's
* plots landed in this rule. Owners with {@code exemptThreshold} plots or fewer in
* the rule pay nothing for it. A plot matching no rule is untaxed.
*
* <p>Per-plot rates are expressible as aggregates: a flat $10 per matched plot is
* {@code "10 * <plots>"}.
*
* <p>An omitted {@code match} matches every plot, which is how a server-wide tax
* is written — as a catch-all rule, placed last.
*/
@ConfigSerializable
public record TaxRule(
@Setting("match") @Nullable TagMatch match,
@Setting("formula") @Nullable String formula
@Setting("formula") @Nullable String formula,
@Setting("exempt-threshold") int exemptThreshold
) {

public TaxRule {
Expand All @@ -24,6 +35,9 @@ public record TaxRule(
if (formula == null || formula.isBlank()) {
formula = "0";
}
if (exemptThreshold < 0) {
exemptThreshold = 0;
}
}

public @NotNull TagMatch match() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
package io.github.md5sha256.realty.settings;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.spongepowered.configurate.objectmapping.ConfigSerializable;
import org.spongepowered.configurate.objectmapping.meta.Setting;

import java.util.List;
import java.util.UUID;

/**
* Property-tax configuration. The whole tax is the {@code rules} list: each rule
* pairs a tag predicate with the formula charged to the plots it matches, so there
* is no tax at all beyond what the rules define. A server-wide tax is a rule with
* no {@code match}.
*/
@ConfigSerializable
public record TaxSettings(
@Setting("enabled") boolean enabled,
@Setting("government-account") @NotNull String governmentAccount,
@Setting("exempt-uuids") @NotNull List<UUID> exemptUuids,
@Setting("exempt-plot-threshold") int exemptPlotThreshold,
@Setting("rules") @NotNull List<TaxRule> rules,
@Setting("default-formula") @NotNull String defaultFormula
@Setting("rules") @Nullable List<TaxRule> rules
) {

/** Built-in default — the Taxation Act's federal property-tax formula. Gives an
* owner's total daily tax as a function of their plot count {@code <plots>};
* evaluated once per owner (not per plot). Owners of 7 or fewer plots are exempt
* via {@code exempt-plot-threshold}, and the result is rounded down to the cent. */
public static final String DEFAULT_FORMULA = "0.25 * 1.16^<plots> + 0.3 * <plots>^2 + 2.5 * <plots> - 25";

public TaxSettings {
if (governmentAccount == null || governmentAccount.isBlank()) {
governmentAccount = "DCGovernment";
Expand All @@ -33,8 +32,10 @@ public record TaxSettings(
if (rules == null) {
rules = List.of();
}
if (defaultFormula == null || defaultFormula.isBlank()) {
defaultFormula = DEFAULT_FORMULA;
}
}

/** The tax brackets, in precedence order. Empty means no property tax. */
public @NotNull List<TaxRule> rules() {
return rules;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import io.github.md5sha256.realty.settings.TaxRule;
import io.github.md5sha256.realty.settings.TaxSettings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.math.BigDecimal;
import java.math.RoundingMode;
Expand All @@ -18,117 +17,115 @@
/**
* Compiled property-tax ruleset. Built once per tax cycle from {@link TaxSettings}.
*
* <p><b>Federal model (the Taxation Act).</b> The {@code default-formula} gives the
* owner's <em>total</em> daily tax as a single function of their plot count — it is
* evaluated <em>once</em> on the federally-taxed plot count and rounded <em>down</em>
* to the cent, and owners with {@code exemptThreshold} federal plots or fewer pay
* nothing. With no rules configured (the default) this is exactly the Act:
* {@code floor(default-formula(totalPlots))}, exempt at/below the threshold.
* <p>The tax is entirely the {@code rules} list — there is no tax outside it. Each
* rule pairs a {@link TagMatch} with a formula over {@code <plots>}:
*
* <p><b>Local-government overrides (optional, off by default).</b> A plot whose tags
* match a rule (first match wins) is taxed by that rule per-property — with
* {@code <plots>} bound to the owner's total plots — and is excluded from the federal
* count. This models the Act's "unless otherwise provided by Local Governments"
* clause; the shipped config defines no rules, so every plot is federal.
* <ol>
* <li>Every plot the owner title-holds is assigned to the <em>first</em> rule whose
* tags it matches. A plot matching no rule is untaxed and counted nowhere.</li>
* <li>Each rule's formula is evaluated <em>once</em> per owner, with {@code <plots>}
* bound to how many of that owner's plots landed in <em>that</em> rule — not
* their total holdings.</li>
* <li>A rule charges nothing to an owner at or below its {@code exempt-threshold}.</li>
* <li>The rules' charges are summed and rounded <em>down</em> to the cent.</li>
* </ol>
*
* <p>A server-wide tax is therefore a single rule with no {@code match}; a per-city
* tax is a rule matching that city's tags. Per-plot rates need no separate mode —
* a flat $10 per matched plot is the aggregate {@code "10 * <plots>"}.
*/
public final class PropertyTaxPolicy {

private record CompiledRule(TagMatch match, TaxFormula formula) {}
private record CompiledRule(TagMatch match, TaxFormula formula, int exemptThreshold) {}

private final List<CompiledRule> rules;
private final TaxFormula defaultFormula;

private PropertyTaxPolicy(List<CompiledRule> rules, TaxFormula defaultFormula) {
private PropertyTaxPolicy(List<CompiledRule> rules) {
this.rules = rules;
this.defaultFormula = defaultFormula;
}

/**
* Compiles the configured rules. A rule whose formula does not parse is dropped
* with a warning — its plots fall through to the next matching rule, or go
* untaxed — so a typo under-charges rather than charging something unintended.
*/
public static @NotNull PropertyTaxPolicy compile(@NotNull TaxSettings settings, @NotNull Logger logger) {
List<CompiledRule> compiled = new ArrayList<>();
int index = 0;
for (TaxRule rule : settings.rules()) {
try {
compiled.add(new CompiledRule(rule.match(), TaxFormula.compile(rule.formula())));
compiled.add(new CompiledRule(
rule.match(), TaxFormula.compile(rule.formula()), rule.exemptThreshold()));
} catch (TaxFormulaException e) {
logger.warning("Ignoring property-tax rule #" + index + " — invalid formula: " + e.getMessage());
logger.warning("Ignoring property-tax rule #" + index + " — invalid formula: " + e.getMessage()
+ " (plots it would have matched are untaxed until this is fixed)");
}
index++;
}

TaxFormula fallback;
try {
fallback = TaxFormula.compile(settings.defaultFormula());
} catch (TaxFormulaException e) {
logger.warning("Invalid default-formula '" + settings.defaultFormula()
+ "' — using built-in default. " + e.getMessage());
fallback = TaxFormula.compile(TaxSettings.DEFAULT_FORMULA);
if (compiled.isEmpty()) {
logger.warning("No usable property-tax rules configured — no property tax will be charged");
}
return new PropertyTaxPolicy(compiled, fallback);
return new PropertyTaxPolicy(compiled);
}

/**
* Total daily property tax for one owner.
*
* <p>Federal plots (no matching rule) are taxed by the default formula evaluated
* <em>once</em> on their count; an owner at or below {@code exemptThreshold}
* federal plots pays no federal tax. Plots matching a rule are instead taxed by
* that rule per-property, with {@code <plots>} bound to the owner's total plots.
* The two parts are summed and rounded down to the cent (the Act rounds down).
*
* <p>With no rules configured this reduces to {@code floor(default-formula(N))}
* for N total plots above the threshold — i.e. exactly the Taxation Act.
* Total daily property tax for one owner: the sum of each rule's formula
* evaluated once on the number of the owner's plots that fell to that rule,
* rounded down to the cent.
*
* @param plotTagSets one tag-set per plot the owner title-holds (tags any case)
* @param exemptThreshold federal plots at/below which no federal tax is charged
*/
public @NotNull BigDecimal taxForOwner(@NotNull List<Set<String>> plotTagSets, int exemptThreshold) {
int totalPlots = plotTagSets.size();
int federalPlots = 0;
double overrideRaw = 0.0;
public @NotNull BigDecimal taxForOwner(@NotNull List<Set<String>> plotTagSets) {
int[] counts = countPlotsPerRule(plotTagSets);

for (Set<String> plotTags : plotTagSets) {
TaxFormula override = matchRule(plotTags);
if (override == null) {
federalPlots++;
} else {
double v = override.evaluate(totalPlots);
if (Double.isFinite(v) && v > 0.0) {
overrideRaw += v;
}
double raw = 0.0;
for (int i = 0; i < rules.size(); i++) {
CompiledRule rule = rules.get(i);
if (counts[i] <= rule.exemptThreshold()) {
continue;
}
}

// Federal tax: a single evaluation on the federal plot count (the Act's
// formula), charged only above the exemption threshold.
double federalRaw = 0.0;
if (federalPlots > exemptThreshold) {
double v = defaultFormula.evaluate(federalPlots);
double v = rule.formula().evaluate(counts[i]);
if (Double.isFinite(v) && v > 0.0) {
federalRaw = v;
raw += v;
}
}

double raw = federalRaw + overrideRaw;
if (!Double.isFinite(raw) || raw <= 0.0) {
return BigDecimal.ZERO;
}
// The Taxation Act rounds tax down to the nearest cent.
// Tax is rounded down to the nearest cent.
return BigDecimal.valueOf(raw).setScale(2, RoundingMode.FLOOR);
}

/** The first rule whose tags match, or {@code null} when the plot is federal. */
private @Nullable TaxFormula matchRule(@NotNull Set<String> rawTags) {
/**
* How many of the owner's plots fall under any rule — the plots the tax can see.
* Plots matching no rule are excluded.
*/
public int taxablePlotCount(@NotNull List<Set<String>> plotTagSets) {
int total = 0;
for (int count : countPlotsPerRule(plotTagSets)) {
total += count;
}
return total;
}

/** Assigns each plot to the first rule that matches it and tallies the buckets. */
private int[] countPlotsPerRule(@NotNull List<Set<String>> plotTagSets) {
int[] counts = new int[rules.size()];
if (rules.isEmpty()) {
return null;
return counts;
}
Set<String> tags = rawTags.stream()
.map(t -> t.toLowerCase(Locale.ROOT))
.collect(Collectors.toSet());
for (CompiledRule rule : rules) {
if (rule.match().matches(tags)) {
return rule.formula();
for (Set<String> plotTags : plotTagSets) {
Set<String> tags = plotTags.stream()
.map(t -> t.toLowerCase(Locale.ROOT))
.collect(Collectors.toSet());
for (int i = 0; i < rules.size(); i++) {
if (rules.get(i).match().matches(tags)) {
counts[i]++;
break;
}
}
}
return null;
return counts;
}
}
Loading
Loading