From c8e463b7a305f9b4520319676ec4077af1f42663 Mon Sep 17 00:00:00 2001 From: Technofied <40795318+Technofied@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:52:27 +0800 Subject: [PATCH] feat!: make property tax a list of match/formula rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tax was a global `default-formula` plus `exempt-plot-threshold`, with `rules` as an optional per-property override. Scoping the tax to a subset of plots was not expressible: rule predicates have no negation, and a rule match removes a plot from the aggregate count, so the plots you want counted cannot be shielded from a catch-all. The tax is now the `rules` list and nothing else. Each rule pairs a tag predicate with the formula charged to the plots it claims: - every plot falls to the FIRST rule matching its tags; a plot matching no rule is untaxed and counted nowhere - each rule's formula is evaluated ONCE per owner, with bound to that owner's plot count in THAT rule, not their total holdings - `exempt-threshold` moves onto the rule, so each bracket carries its own - charges are summed and floored to the cent A server-wide tax is a rule with no `match`. Rules are aggregate rather than per-property, which loses nothing: a flat per-plot rate is `10 * `. A rule whose formula fails to parse is dropped and its plots fall through to the next match, so a typo under-charges rather than charging something unintended. BREAKING CHANGE: `default-formula`, `exempt-plot-threshold` and TaxSettings.DEFAULT_FORMULA are removed. An un-migrated taxes.yml has its `rules` key filled from the shipped defaults on startup, switching that server to the packaged rule. `rules: []` does not disable collection — Configurate treats an empty list as absent, so the default returns on the next restart; `enabled: false` is the off-switch. Tests: 18 policy tests cover scoping, per-rule counting and exemption, rule ordering and drop-on-parse-failure. A new TaxesConfigTest loads the packaged taxes.yml as the plugin does and pins that it deserializes, that its formulas compile, that it charges the figures its own comments quote, that every tag it names exists in region-tags.yml, and both startup-merge behaviours above. Co-Authored-By: Claude Opus 5 (1M context) --- .../realty/listener/PropertyTaxListener.java | 12 +- .../md5sha256/realty/settings/TaxRule.java | 24 +- .../realty/settings/TaxSettings.java | 25 +- .../realty/tax/PropertyTaxPolicy.java | 139 +++++---- realty-paper/src/main/resources/taxes.yml | 96 ++++-- .../realty/settings/TaxesConfigTest.java | 212 ++++++++++++++ .../realty/tax/PropertyTaxPolicyTest.java | 273 +++++++++++++----- 7 files changed, 586 insertions(+), 195 deletions(-) create mode 100644 realty-paper/src/test/java/io/github/md5sha256/realty/settings/TaxesConfigTest.java diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/listener/PropertyTaxListener.java b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/PropertyTaxListener.java index 34472af..248648c 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/listener/PropertyTaxListener.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/listener/PropertyTaxListener.java @@ -83,7 +83,6 @@ public void onTaxCycle(@NotNull TaxCycleEvent event) { } Set exempt = new HashSet<>(settings.exemptUuids()); - int threshold = settings.exemptPlotThreshold(); Instant periodStart = event.getPeriodStart(); // Resolve the configured destination account once for the whole batch. @@ -93,20 +92,19 @@ public void onTaxCycle(@NotNull TaxCycleEvent event) { for (Map.Entry>> ownerEntry : regionsByOwner.entrySet()) { UUID owner = ownerEntry.getKey(); Map> 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> 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) { diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxRule.java b/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxRule.java index 47640c9..7b27ad0 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxRule.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxRule.java @@ -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 }) 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. + * + *

A rule is an aggregate charge, not a per-plot one. Each plot is + * assigned to the first rule that matches it; the rule's formula is then evaluated + * once per owner with {@code } 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. + * + *

Per-plot rates are expressible as aggregates: a flat $10 per matched plot is + * {@code "10 * "}. + * + *

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 { @@ -24,6 +35,9 @@ public record TaxRule( if (formula == null || formula.isBlank()) { formula = "0"; } + if (exemptThreshold < 0) { + exemptThreshold = 0; + } } public @NotNull TagMatch match() { diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxSettings.java b/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxSettings.java index d3ff66f..aa10848 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxSettings.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/settings/TaxSettings.java @@ -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 exemptUuids, - @Setting("exempt-plot-threshold") int exemptPlotThreshold, - @Setting("rules") @NotNull List rules, - @Setting("default-formula") @NotNull String defaultFormula + @Setting("rules") @Nullable List 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 }; - * 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^ + 0.3 * ^2 + 2.5 * - 25"; - public TaxSettings { if (governmentAccount == null || governmentAccount.isBlank()) { governmentAccount = "DCGovernment"; @@ -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 rules() { + return rules; } } diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/tax/PropertyTaxPolicy.java b/realty-paper/src/main/java/io/github/md5sha256/realty/tax/PropertyTaxPolicy.java index 4953c1b..a58756b 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/tax/PropertyTaxPolicy.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/tax/PropertyTaxPolicy.java @@ -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; @@ -18,117 +17,115 @@ /** * Compiled property-tax ruleset. Built once per tax cycle from {@link TaxSettings}. * - *

Federal model (the Taxation Act). The {@code default-formula} gives the - * owner's total daily tax as a single function of their plot count — it is - * evaluated once on the federally-taxed plot count and rounded down - * 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. + *

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 }: * - *

Local-government overrides (optional, off by default). A plot whose tags - * match a rule (first match wins) is taxed by that rule per-property — with - * {@code } 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. + *

    + *
  1. Every plot the owner title-holds is assigned to the first rule whose + * tags it matches. A plot matching no rule is untaxed and counted nowhere.
  2. + *
  3. Each rule's formula is evaluated once per owner, with {@code } + * bound to how many of that owner's plots landed in that rule — not + * their total holdings.
  4. + *
  5. A rule charges nothing to an owner at or below its {@code exempt-threshold}.
  6. + *
  7. The rules' charges are summed and rounded down to the cent.
  8. + *
+ * + *

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 * "}. */ public final class PropertyTaxPolicy { - private record CompiledRule(TagMatch match, TaxFormula formula) {} + private record CompiledRule(TagMatch match, TaxFormula formula, int exemptThreshold) {} private final List rules; - private final TaxFormula defaultFormula; - private PropertyTaxPolicy(List rules, TaxFormula defaultFormula) { + private PropertyTaxPolicy(List 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 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. - * - *

Federal plots (no matching rule) are taxed by the default formula evaluated - * once 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 } bound to the owner's total plots. - * The two parts are summed and rounded down to the cent (the Act rounds down). - * - *

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> plotTagSets, int exemptThreshold) { - int totalPlots = plotTagSets.size(); - int federalPlots = 0; - double overrideRaw = 0.0; + public @NotNull BigDecimal taxForOwner(@NotNull List> plotTagSets) { + int[] counts = countPlotsPerRule(plotTagSets); - for (Set 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 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> 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> plotTagSets) { + int[] counts = new int[rules.size()]; if (rules.isEmpty()) { - return null; + return counts; } - Set 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 plotTags : plotTagSets) { + Set 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; } } diff --git a/realty-paper/src/main/resources/taxes.yml b/realty-paper/src/main/resources/taxes.yml index 763464a..b8d23a4 100644 --- a/realty-paper/src/main/resources/taxes.yml +++ b/realty-paper/src/main/resources/taxes.yml @@ -1,5 +1,9 @@ # Enable or disable daily property tax collection. # Requires the Treasury plugin to be installed and its DAILY cycle to be enabled. +# +# This is the off-switch. Emptying `rules` below does NOT disable the tax: missing +# and empty keys are refilled from the plugin's shipped defaults on startup, so the +# default rule would come back on the next restart. enabled: true # Name of the Treasury GOVERNMENT account that receives property tax proceeds. @@ -11,30 +15,72 @@ government-account: "DCGovernment" # Useful for authority, government, or server-owned accounts. exempt-uuids: [] -# Owners holding this many freehold plots or fewer pay no property tax at all. -# The Taxation Act exempts owners of 7 or fewer plots. Set to 0 to tax everyone. -exempt-plot-threshold: 7 - # --------------------------------------------------------------------------- -# Property tax follows the Taxation Act: `default-formula` gives an owner's TOTAL -# daily tax as a function of (their freehold plot count). It is evaluated -# ONCE per owner — NOT per property — and the result is rounded DOWN to the cent. -# Owners at or below `exempt-plot-threshold` plots pay nothing. -# -# = the owner's freehold plot count -# operators + - * /, exponentiation ^ (e.g. 1.16^, ^2), -# parentheses, and decimals are supported. -# -# `rules` below are OPTIONAL local-government overrides ("unless otherwise provided -# by Local Governments where the plot is located"). A plot whose tags match a rule -# is taxed per-property by that rule instead of counting toward the federal formula. -# They are OFF by default — leave `rules` empty to apply the Act uniformly. -# -# Rule match syntax (first match wins, tags case-insensitive from region-tags.yml): -# all: [a, b] -> region must carry every listed tag -# any: [a, b] -> region must carry at least one listed tag +# THE PROPERTY TAX IS THIS LIST. There is no tax outside it: a plot matching no +# rule is untaxed and counted nowhere. To stop collection entirely set +# `enabled: false` above — an emptied list is refilled from the defaults on +# startup, whereas edited rules are kept as written. +# +# Each plot is assigned to the FIRST rule whose tags it matches. That rule's +# `formula` is then evaluated ONCE per owner, with bound to how many of +# that owner's plots landed in THAT rule — not their total holdings. Every +# rule's charge is summed and the total rounded DOWN to the cent. +# +# Keys per rule: +# +# match Which plots the rule covers. Omit it to match every plot. +# all: [a, b] plot must carry EVERY listed tag +# any: [a, b] plot must carry AT LEAST ONE listed tag +# both together all(...) AND any(...) +# Tag ids come from region-tags.yml and are matched case-insensitively. +# A tag id that no region actually carries simply matches nothing. +# +# formula Total daily tax for an owner with plots in this +# rule. Supports + - * /, exponentiation ^ (so ^2 and +# 1.16^), parentheses and decimals. Multiplication +# must be explicit: `2.7 * ( - 2)`, never +# `2.7( - 2)`. Omitted or unparseable means no charge. +# +# exempt-threshold Owners with this many plots OR FEWER in this rule pay +# nothing for it. Counted per rule, not across holdings. +# Defaults to 0 (no exemption). +# +# ORDER MATTERS. A plot goes to the first matching rule only, so put narrow +# rules above broad ones — a rule with no `match` placed first would swallow +# every plot and leave the rest of the list dead. +# +# A rule whose formula fails to parse is dropped with a warning and its plots +# fall through to the next matching rule, so a typo under-charges rather than +# charging something unintended. +# +# Worked examples, using the tags shipped in region-tags.yml: +# +# Flat $12/day per commercial or industrial plot, first 2 free: +# - match: +# any: [commercial, industrial] +# formula: "12 * " +# exempt-threshold: 2 +# +# Land that is BOTH commercial and industrial, on a steeper curve, taxed +# ahead of the plain commercial rule below it: +# - match: +# all: [commercial, industrial] +# formula: "0.5 * ^2 + 20 * " +# +# Residential land, exempt entirely (an explicit rule beats relying on the +# ordering of a later catch-all): +# - match: +# any: [residential] +# formula: "0" +# +# If your region-tags.yml defines district or city tags as well as zoning tags, +# combine them: `all: []` with `any: [commercial, industrial]` taxes +# that district's commercial and industrial land and nothing else. # --------------------------------------------------------------------------- -rules: [] - -# The Taxation Act's federal property-tax formula (total daily tax for plots). -default-formula: "0.25 * 1.16^ + 0.3 * ^2 + 2.5 * - 25" +rules: + # Server-wide: no `match`, so every plot falls here and is the owner's + # whole holding. Owners of 2 plots or fewer pay nothing. + # y = 2.7(x-2) + 2.87(x-2)^2 + 0.0462(x-2)^3 + # Roughly: 3 plots -> $5.61/day, 5 -> $35.17, 10 -> $228.93, 20 -> $1247.91. + - formula: "2.7 * ( - 2) + 2.87 * ( - 2)^2 + 0.0462 * ( - 2)^3" + exempt-threshold: 2 diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/settings/TaxesConfigTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/settings/TaxesConfigTest.java new file mode 100644 index 0000000..0303d80 --- /dev/null +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/settings/TaxesConfigTest.java @@ -0,0 +1,212 @@ +package io.github.md5sha256.realty.settings; + +import io.github.md5sha256.realty.tax.PropertyTaxPolicy; +import io.github.md5sha256.realty.tax.TaxFormula; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.spongepowered.configurate.ConfigurateException; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Logger; + +/** + * Guards the shipped taxes.yml. The tax is now entirely the {@code rules} list, so a + * key renamed in {@link TaxSettings} or a typo in the resource stops collection + * silently — the plugin just logs a warning and charges nobody. These tests load the + * packaged resource exactly as the plugin does and assert it still means what its + * comments say. + */ +class TaxesConfigTest { + + private static final Logger LOG = Logger.getLogger("test"); + + private static ConfigurationNode load(String resource) throws IOException { + try (InputStream in = TaxesConfigTest.class.getResourceAsStream(resource)) { + Assertions.assertNotNull(in, resource + " is missing from the plugin resources"); + return YamlConfigurationLoader.builder() + .source(() -> new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) + .build() + .load(); + } + } + + private static TaxSettings shippedSettings() throws IOException { + TaxSettings settings = load("/taxes.yml").get(TaxSettings.class); + Assertions.assertNotNull(settings, "taxes.yml did not deserialize into TaxSettings"); + return settings; + } + + /** N plots carrying no tags — enough to exercise a catch-all rule. */ + private static List> plots(int n) { + List> plots = new ArrayList<>(); + for (int i = 0; i < n; i++) { + plots.add(Set.of()); + } + return plots; + } + + @Test + @DisplayName("the shipped taxes.yml deserializes with collection enabled") + void shippedConfigLoads() throws IOException { + TaxSettings settings = shippedSettings(); + Assertions.assertTrue(settings.enabled(), "property tax collection should ship enabled"); + Assertions.assertEquals("DCGovernment", settings.governmentAccount()); + Assertions.assertTrue(settings.exemptUuids().isEmpty()); + Assertions.assertFalse(settings.rules().isEmpty(), + "an empty rules list means no property tax is charged at all"); + } + + @Test + @DisplayName("every shipped rule's formula compiles") + void shippedFormulasCompile() throws IOException { + List rules = shippedSettings().rules(); + for (int i = 0; i < rules.size(); i++) { + int index = i; + Assertions.assertDoesNotThrow(() -> TaxFormula.compile(rules.get(index).formula()), + "rule #" + i + " would be dropped at runtime, leaving its plots untaxed"); + } + } + + @Test + @DisplayName("the shipped rule charges the documented amounts") + void shippedRuleMatchesItsDocumentedFigures() throws IOException { + PropertyTaxPolicy policy = PropertyTaxPolicy.compile(shippedSettings(), LOG); + // The figures quoted in the resource's own comments. + Assertions.assertEquals(BigDecimal.ZERO, policy.taxForOwner(plots(2))); + Assertions.assertEquals(new BigDecimal("5.61"), policy.taxForOwner(plots(3))); + Assertions.assertEquals(new BigDecimal("35.17"), policy.taxForOwner(plots(5))); + Assertions.assertEquals(new BigDecimal("228.93"), policy.taxForOwner(plots(10))); + Assertions.assertEquals(new BigDecimal("1247.91"), policy.taxForOwner(plots(20))); + } + + @Test + @DisplayName("the shipped exemption is read from exempt-threshold, not defaulted to 0") + void exemptThresholdKeyIsRead() throws IOException { + List rules = shippedSettings().rules(); + Assertions.assertEquals(2, rules.get(0).exemptThreshold(), + "a renamed key would silently default the exemption to 0 and tax everyone"); + } + + @Test + @DisplayName("the shipped rule is a catch-all, so it applies server-wide") + void shippedRuleIsCatchAll() throws IOException { + TagMatch match = shippedSettings().rules().get(0).match(); + Assertions.assertTrue(match.all().isEmpty() && match.any().isEmpty()); + Assertions.assertTrue(match.matches(Set.of()), "an untagged plot should still be taxed"); + Assertions.assertTrue(match.matches(Set.of("residential"))); + } + + @Test + @DisplayName("every tag a shipped rule names is defined in region-tags.yml") + void shippedRulesOnlyReferenceKnownTags() throws IOException { + Set known = new HashSet<>(); + for (ConfigurationNode tag : load("/region-tags.yml").node("tags").childrenList()) { + String id = tag.node("tag-id").getString(); + if (id != null) { + known.add(id.toLowerCase(Locale.ROOT)); + } + } + Assertions.assertFalse(known.isEmpty(), "region-tags.yml defines no tags"); + + for (TaxRule rule : shippedSettings().rules()) { + List referenced = new ArrayList<>(rule.match().all()); + referenced.addAll(rule.match().any()); + for (String tag : referenced) { + Assertions.assertTrue(known.contains(tag), + "taxes.yml matches tag '" + tag + "', which region-tags.yml does not define — " + + "the rule would match nothing"); + } + } + } + + @Test + @DisplayName("a config whose rules list is empty charges nothing") + void emptyRulesChargeNothing() throws ConfigurateException { + ConfigurationNode node = YamlConfigurationLoader.builder() + .buildAndLoadString(""" + enabled: true + government-account: "DCGovernment" + exempt-uuids: [] + rules: [] + """); + TaxSettings settings = node.get(TaxSettings.class); + Assertions.assertNotNull(settings); + Assertions.assertEquals(BigDecimal.ZERO, + PropertyTaxPolicy.compile(settings, LOG).taxForOwner(plots(100))); + } + + @Test + @DisplayName("an emptied rules list is refilled by the startup merge — disable with enabled: false") + void emptyRulesListIsRefilledByTheStartupMerge() throws IOException { + // Mirrors Realty#copyDefaultsYaml: the deployed file is merged with the packaged + // defaults and saved back. Configurate treats an empty list as absent, so + // `rules: []` does NOT survive a restart — the shipped rule comes back and the + // server starts charging again. taxes.yml documents `enabled: false` as the + // off-switch for exactly this reason; this test pins the behaviour that makes + // that instruction necessary. + ConfigurationNode deployed = YamlConfigurationLoader.builder() + .buildAndLoadString(""" + enabled: true + government-account: "TownHall" + exempt-uuids: [] + rules: [] + """); + deployed.mergeFrom(load("/taxes.yml")); + + TaxSettings merged = deployed.get(TaxSettings.class); + Assertions.assertNotNull(merged); + Assertions.assertEquals("TownHall", merged.governmentAccount(), "the deployed value must win"); + Assertions.assertFalse(merged.rules().isEmpty(), + "an empty list is indistinguishable from an absent key, so the default returns"); + } + + @Test + @DisplayName("startup merge fills in the shipped rules when the key is absent entirely") + void mergeSuppliesRulesWhenKeyIsMissing() throws IOException { + ConfigurationNode deployed = YamlConfigurationLoader.builder() + .buildAndLoadString(""" + enabled: true + government-account: "TownHall" + """); + deployed.mergeFrom(load("/taxes.yml")); + + TaxSettings merged = deployed.get(TaxSettings.class); + Assertions.assertNotNull(merged); + Assertions.assertFalse(merged.rules().isEmpty(), + "a config predating `rules` should pick up the shipped default on upgrade"); + } + + @Test + @DisplayName("a pre-rules config (default-formula / exempt-plot-threshold) no longer taxes") + void legacyKeysAreInert() throws ConfigurateException { + // Documents the migration hazard: the old top-level keys are simply ignored, + // so an un-migrated file collects nothing rather than collecting the old tax. + ConfigurationNode node = YamlConfigurationLoader.builder() + .buildAndLoadString(""" + enabled: true + government-account: "DCGovernment" + exempt-uuids: [] + exempt-plot-threshold: 7 + rules: [] + default-formula: "0.3 * ^2 + 2.5 * - 25" + """); + TaxSettings settings = node.get(TaxSettings.class); + Assertions.assertNotNull(settings, "an un-migrated config must still load, not crash the plugin"); + Assertions.assertTrue(settings.rules().isEmpty()); + Assertions.assertEquals(BigDecimal.ZERO, + PropertyTaxPolicy.compile(settings, LOG).taxForOwner(plots(100))); + } +} diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/tax/PropertyTaxPolicyTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/tax/PropertyTaxPolicyTest.java index 1cbbc5f..edf6ad3 100644 --- a/realty-paper/src/test/java/io/github/md5sha256/realty/tax/PropertyTaxPolicyTest.java +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/tax/PropertyTaxPolicyTest.java @@ -15,125 +15,248 @@ import java.util.logging.Logger; /** - * The Taxation Act's property tax: a single formula on the owner's plot count, - * evaluated once (not per plot), rounded down to the cent, exempt at 7 plots or - * fewer. Tag rules are optional local-government overrides and off by default. + * The property tax is the rules list and nothing else. Each plot falls to the first + * matching rule; that rule's formula is charged once per owner on the number of the + * owner's plots in it, above the rule's exemption, floored to the cent. Plots + * matching no rule are untaxed. + * + *

Fixtures use the zoning tags shipped in region-tags.yml plus a couple of + * hypothetical district tags, since combining a district with a zoning list is the + * shape servers configure most often. */ class PropertyTaxPolicyTest { private static final Logger LOG = Logger.getLogger("test"); - private static final int ACT_THRESHOLD = 7; - private static TaxRule rule(TagMatch match, String formula) { - return new TaxRule(match, formula); - } + private static final List ZONING = List.of("residential", "commercial", "industrial"); + + /** A district's zoned land: downtown AND (residential OR commercial OR industrial). */ + private static final TagMatch DOWNTOWN_ZONED = new TagMatch(List.of("downtown"), ZONING); + + /** The formula shipped in taxes.yml: y = 2.7d + 2.87d^2 + 0.0462d^3, d = plots - 2. */ + private static final String CUBIC = + "2.7 * ( - 2) + 2.87 * ( - 2)^2 + 0.0462 * ( - 2)^3"; - /** A policy with the built-in (Act) default formula and no override rules. */ - private static PropertyTaxPolicy actPolicy() { + private static PropertyTaxPolicy policy(TaxRule... rules) { return PropertyTaxPolicy.compile( - new TaxSettings(true, "DCGovernment", List.of(), ACT_THRESHOLD, List.of(), TaxSettings.DEFAULT_FORMULA), - LOG); + new TaxSettings(true, "DCGovernment", List.of(), List.of(rules)), LOG); } - /** N untagged (federal) plots. */ - private static List> untagged(int n) { + /** One rule: the district's zoned land on the cubic, exempt at 2. */ + private static PropertyTaxPolicy districtPolicy() { + return policy(new TaxRule(DOWNTOWN_ZONED, CUBIC, 2)); + } + + /** N plots all carrying the same tags. */ + private static List> repeat(Set tags, int n) { List> plots = new ArrayList<>(); for (int i = 0; i < n; i++) { - plots.add(Set.of()); + plots.add(tags); } return plots; } - /** The Act's formula computed directly, once, floored — the expected total. */ - private static BigDecimal actTax(int plots) { - double y = 0.25 * Math.pow(1.16, plots) + 0.3 * plots * plots + 2.5 * plots - 25; + private static BigDecimal cubic(int plots) { + double d = plots - 2; + double y = 2.7 * d + 2.87 * d * d + 0.0462 * d * d * d; return y <= 0 ? BigDecimal.ZERO : BigDecimal.valueOf(y).setScale(2, RoundingMode.FLOOR); } + // ------------------------------------------------------------------ + // Scoping: a rule combining all + any + // ------------------------------------------------------------------ + + @Test + @DisplayName("only plots matching the rule are taxed or counted") + void taxesOnlyMatchingPlots() { + List> plots = new ArrayList<>(repeat(Set.of("downtown", "commercial"), 3)); + plots.addAll(repeat(Set.of("riverside", "residential"), 10)); // another district — no rule + plots.addAll(repeat(Set.of("downtown", "farmland"), 5)); // right district, unlisted zoning + + // = 3, not 18: floor(f(3)) = 5.61. + Assertions.assertEquals(new BigDecimal("5.61"), districtPolicy().taxForOwner(plots)); + Assertions.assertEquals(3, districtPolicy().taxablePlotCount(plots)); + } + + @Test + @DisplayName("all + any means AND: district alone or zoning alone does not qualify") + void requiresDistrictAndZoning() { + PropertyTaxPolicy policy = districtPolicy(); + Assertions.assertEquals(BigDecimal.ZERO, + policy.taxForOwner(repeat(Set.of("downtown", "farmland"), 10))); + Assertions.assertEquals(BigDecimal.ZERO, + policy.taxForOwner(repeat(Set.of("riverside", "commercial"), 10))); + } + @Test - @DisplayName("owners of 7 or fewer plots are exempt") - void exemptsSevenOrFewer() { - PropertyTaxPolicy policy = actPolicy(); - for (int n = 0; n <= 7; n++) { - Assertions.assertEquals(BigDecimal.ZERO, policy.taxForOwner(untagged(n), ACT_THRESHOLD), - "owner of " + n + " plots should be exempt"); + @DisplayName("every tag in the any list satisfies the OR arm") + void anyListedTagQualifies() { + PropertyTaxPolicy policy = districtPolicy(); + for (String zoning : ZONING) { + Assertions.assertEquals(cubic(5), policy.taxForOwner(repeat(Set.of("downtown", zoning), 5)), + zoning + " should be taxable"); } } @Test - @DisplayName("matches the Act's published daily figures") - void matchesPublishedFigures() { - PropertyTaxPolicy policy = actPolicy(); - // From the Act: ~$15.01 @ 8 plots, ~$149.86 @ 20 plots. - Assertions.assertEquals(new BigDecimal("15.01"), policy.taxForOwner(untagged(8), ACT_THRESHOLD)); - Assertions.assertEquals(new BigDecimal("149.86"), policy.taxForOwner(untagged(20), ACT_THRESHOLD)); + @DisplayName("tag matching is case-insensitive") + void matchingIsCaseInsensitive() { + Assertions.assertEquals(cubic(4), districtPolicy().taxForOwner(repeat(Set.of("Downtown", "COMMERCIAL"), 4))); } + // ------------------------------------------------------------------ + // Charging: once per owner, on the rule's own count + // ------------------------------------------------------------------ + @Test - @DisplayName("formula is evaluated ONCE on total plots (not summed per plot)") - void evaluatedOncePerOwner() { - PropertyTaxPolicy policy = actPolicy(); - for (int n : new int[]{8, 12, 20, 50}) { - Assertions.assertEquals(actTax(n), policy.taxForOwner(untagged(n), ACT_THRESHOLD), - "tax for " + n + " plots should be floor(formula(" + n + ")), charged once"); - // Guard against the old per-property summation (which would be n× larger). - Assertions.assertNotEquals(actTax(n).multiply(BigDecimal.valueOf(n)), - policy.taxForOwner(untagged(n), ACT_THRESHOLD)); + @DisplayName("owners of 2 or fewer plots in the rule are exempt, however much else they own") + void exemptsTwoOrFewerMatchingPlots() { + PropertyTaxPolicy policy = districtPolicy(); + for (int n = 0; n <= 2; n++) { + List> plots = new ArrayList<>(repeat(Set.of("downtown", "commercial"), n)); + plots.addAll(repeat(Set.of("riverside", "commercial"), 50)); + Assertions.assertEquals(BigDecimal.ZERO, policy.taxForOwner(plots), + "owner of " + n + " taxable plots should be exempt"); + } + } + + @Test + @DisplayName("the formula is evaluated ONCE on the rule's plot count, floored") + void chargedOncePerOwner() { + PropertyTaxPolicy policy = districtPolicy(); + Assertions.assertEquals(new BigDecimal("5.61"), policy.taxForOwner(repeat(Set.of("downtown", "commercial"), 3))); + Assertions.assertEquals(new BigDecimal("35.17"), policy.taxForOwner(repeat(Set.of("downtown", "commercial"), 5))); + Assertions.assertEquals(new BigDecimal("228.93"), policy.taxForOwner(repeat(Set.of("downtown", "commercial"), 10))); + for (int n : new int[]{3, 8, 20}) { + List> plots = repeat(Set.of("downtown", "industrial"), n); + Assertions.assertEquals(cubic(n), policy.taxForOwner(plots)); + // Guard against per-property summation, which would be n× larger. + Assertions.assertNotEquals(cubic(n).multiply(BigDecimal.valueOf(n)), policy.taxForOwner(plots)); } } @Test @DisplayName("tax is rounded DOWN to the nearest cent") void roundsDown() { - // floor: 15.0196… -> 15.01 (not 15.02). - Assertions.assertEquals(new BigDecimal("15.01"), actPolicy().taxForOwner(untagged(8), ACT_THRESHOLD)); + // f(3) = 5.6162… -> 5.61, not 5.62. + Assertions.assertEquals(new BigDecimal("5.61"), + districtPolicy().taxForOwner(repeat(Set.of("downtown", "commercial"), 3))); } @Test - @DisplayName("local override: tagged plots use the rule per-property; the rest go federal once") - void localOverridePlusFederal() { - // default "7 * ", threshold 0; one commercial plot (flat 10) + two federal. - TaxSettings settings = new TaxSettings( - true, "DCGovernment", List.of(), 0, - List.of(rule(new TagMatch(null, List.of("commercial")), "10")), - "7 * "); - PropertyTaxPolicy policy = PropertyTaxPolicy.compile(settings, LOG); + @DisplayName("a negative formula result never becomes a credit") + void negativeResultIsZero() { + Assertions.assertEquals(BigDecimal.ZERO, + policy(new TaxRule(null, " - 100", 0)).taxForOwner(repeat(Set.of(), 5))); + } + + // ------------------------------------------------------------------ + // Rule mechanics + // ------------------------------------------------------------------ - List> plots = List.of(Set.of("commercial"), Set.of(), Set.of()); - // override 10 (one commercial) + federal 7 * 2 (two federal plots) = 24. - Assertions.assertEquals(new BigDecimal("24.00"), policy.taxForOwner(plots, 0)); + @Test + @DisplayName("a rule with no match is a server-wide tax") + void catchAllRuleTaxesEveryPlot() { + // A progressive server-wide curve, exempt at 7 plots. + PropertyTaxPolicy policy = policy(new TaxRule( + null, "0.25 * 1.16^ + 0.3 * ^2 + 2.5 * - 25", 7)); + Assertions.assertEquals(BigDecimal.ZERO, policy.taxForOwner(repeat(Set.of("residential"), 7))); + Assertions.assertEquals(new BigDecimal("15.01"), policy.taxForOwner(repeat(Set.of("residential"), 8))); + Assertions.assertEquals(new BigDecimal("149.86"), policy.taxForOwner(repeat(Set.of(), 20))); } @Test - @DisplayName("override tag match is case-insensitive") - void overrideCaseInsensitive() { - TaxSettings settings = new TaxSettings( - true, "DCGovernment", List.of(), 0, - List.of(rule(new TagMatch(List.of("commercial", "industrial"), null), "25")), - "0"); - PropertyTaxPolicy policy = PropertyTaxPolicy.compile(settings, LOG); - Assertions.assertEquals(new BigDecimal("25.00"), - policy.taxForOwner(List.of(Set.of("COMMERCIAL", "Industrial")), 0)); + @DisplayName("each rule is counted and charged independently, then summed") + void rulesAreIndependentBuckets() { + PropertyTaxPolicy policy = policy( + new TaxRule(new TagMatch(List.of("commercial"), null), "100 * ", 0), + new TaxRule(new TagMatch(List.of("industrial"), null), "7 * ", 2)); + + List> plots = new ArrayList<>(repeat(Set.of("commercial"), 2)); + plots.addAll(repeat(Set.of("industrial"), 4)); + plots.addAll(repeat(Set.of("residential"), 9)); // no rule — invisible + + // 100*2 (commercial bucket) + 7*4 (industrial bucket, over its threshold of 2) = 228. + Assertions.assertEquals(new BigDecimal("228.00"), policy.taxForOwner(plots)); + Assertions.assertEquals(6, policy.taxablePlotCount(plots)); + } + + @Test + @DisplayName("a rule's exemption applies to its own bucket only") + void exemptionIsPerRule() { + PropertyTaxPolicy policy = policy( + new TaxRule(new TagMatch(List.of("commercial"), null), "100 * ", 5), + new TaxRule(new TagMatch(List.of("industrial"), null), "7 * ", 0)); + + List> plots = new ArrayList<>(repeat(Set.of("commercial"), 4)); // under its threshold + plots.addAll(repeat(Set.of("industrial"), 3)); // no exemption + Assertions.assertEquals(new BigDecimal("21.00"), policy.taxForOwner(plots)); + } + + @Test + @DisplayName("a plot matching several rules falls to the first") + void firstMatchWins() { + PropertyTaxPolicy policy = policy( + new TaxRule(new TagMatch(List.of("downtown", "commercial"), null), "1000", 0), + new TaxRule(new TagMatch(List.of("downtown"), null), "5", 0)); + // Both rules match, but the first claims the plot — charged once, not twice. + Assertions.assertEquals(new BigDecimal("1000.00"), + policy.taxForOwner(List.of(Set.of("downtown", "commercial")))); + } + + @Test + @DisplayName("a catch-all placed first swallows every plot, leaving later rules dead") + void catchAllFirstShadowsLaterRules() { + PropertyTaxPolicy policy = policy( + new TaxRule(null, "1 * ", 0), + new TaxRule(new TagMatch(List.of("commercial"), null), "1000 * ", 0)); + // The documented ordering hazard: the commercial rule never sees a plot. + Assertions.assertEquals(new BigDecimal("3.00"), policy.taxForOwner(repeat(Set.of("commercial"), 3))); + } + + @Test + @DisplayName("a flat per-plot rate is expressible as an aggregate") + void flatPerPlotRate() { + PropertyTaxPolicy policy = policy(new TaxRule(new TagMatch(null, List.of("commercial")), "10 * ", 0)); + Assertions.assertEquals(new BigDecimal("30.00"), policy.taxForOwner(repeat(Set.of("commercial"), 3))); + } + + @Test + @DisplayName("an explicit zero-rate rule exempts its plots from later rules") + void zeroRateRuleExempts() { + PropertyTaxPolicy policy = policy( + new TaxRule(new TagMatch(null, List.of("residential")), "0", 0), + new TaxRule(null, "50 * ", 0)); + List> plots = new ArrayList<>(repeat(Set.of("residential"), 10)); + plots.addAll(repeat(Set.of("commercial"), 2)); + Assertions.assertEquals(new BigDecimal("100.00"), policy.taxForOwner(plots)); + } + + // ------------------------------------------------------------------ + // Failure handling + // ------------------------------------------------------------------ + + @Test + @DisplayName("a rule with an invalid formula is dropped; its plots fall to the next match") + void badFormulaDropsRule() { + PropertyTaxPolicy policy = policy( + new TaxRule(new TagMatch(List.of("commercial"), null), "2 * acres", 0), + new TaxRule(null, "5 * ", 0)); + // The commercial rule never compiles, so its plots fall through to the catch-all. + Assertions.assertEquals(new BigDecimal("15.00"), policy.taxForOwner(repeat(Set.of("commercial"), 3))); } @Test - @DisplayName("a bad rule formula is dropped — that plot falls back to federal") - void badRuleFormulaDropped() { - TaxSettings settings = new TaxSettings( - true, "DCGovernment", List.of(), 0, - List.of(rule(new TagMatch(List.of("residential"), null), "2 * acres")), - "7 * "); - PropertyTaxPolicy policy = PropertyTaxPolicy.compile(settings, LOG); - // The residential rule is invalid and dropped -> the plot is federal -> 7 * 1. - Assertions.assertEquals(new BigDecimal("7.00"), policy.taxForOwner(List.of(Set.of("residential")), 0)); + @DisplayName("an invalid formula with no fallback rule leaves those plots untaxed") + void badFormulaWithNoFallbackChargesNothing() { + PropertyTaxPolicy policy = policy(new TaxRule(new TagMatch(List.of("commercial"), null), "bogus((", 0)); + Assertions.assertEquals(BigDecimal.ZERO, policy.taxForOwner(repeat(Set.of("commercial"), 30))); + Assertions.assertEquals(0, policy.taxablePlotCount(repeat(Set.of("commercial"), 30))); } @Test - @DisplayName("an invalid default-formula falls back to the built-in Act formula") - void invalidDefaultFallsBack() { - TaxSettings settings = new TaxSettings( - true, "DCGovernment", List.of(), ACT_THRESHOLD, List.of(), "bogus(("); - PropertyTaxPolicy policy = PropertyTaxPolicy.compile(settings, LOG); - Assertions.assertEquals(new BigDecimal("15.01"), policy.taxForOwner(untagged(8), ACT_THRESHOLD)); + @DisplayName("no rules means no property tax") + void noRulesNoTax() { + Assertions.assertEquals(BigDecimal.ZERO, policy().taxForOwner(repeat(Set.of("commercial"), 100))); } }