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))); } }