There was a string — `current_gamemode`, always "Deathmatch" — an OptionButton
with one entry in it, and a five-minute timer that counted to zero, set
`match_active = false`, and did nothing else. No winner was declared, no summary
appeared, the clock froze at 00:00, and players carried on shooting each other
in a match that had stopped counting. Nothing was tracked beyond a running kill
count. A match did not end so much as stop mattering, and everything a player
does in the last minute only matters if there is a last minute.
globals/game_mode.gd answers the three questions a mode has to answer — how you
score, when it ends, who won — for three modes:
Deathmatch 25 frags or 10 minutes.
Team Deathmatch two squads to 50, teams balanced by COUNT on join (a 4v4
that loses three from one side must refill the short side;
round-robin on join order leaves it 4v1 forever), friendly
fire off and enforced on the server before damage is even
broadcast — a mode where the damage lands but the kill does
not count is worse than either.
Gun Game every kill promotes you a rung and swaps your weapon. The
score IS the rung and the limit IS the ladder's length, so
finishing the ladder and reaching the score limit are the
same event and only one win condition exists.
`check_win` is a pure function of the stats and the clock, deliberately, because
a win condition that can only be exercised by playing a whole match is one
nobody tests — and the previous one never was. debug/game_mode_check.gd runs 30
cases over it: a tie is a DRAW rather than a win for whoever came first out of
the dictionary; a team match is decided on the TEAM's total, which a per-player
check never reaches; every ladder rung names a weapon that exists, or that rung
softlocks the mode; and promoting past the top clamps, because the winning kill
promotes before the match-end RPC lands.
Tracking now covers score, team, current streak, best streak and ladder rung.
Score and kills are separate numbers because in Gun Game they coincide and in
anything with an objective they would not. The scoreboard ranks by score, shows
the mode's own noun for it, and puts the limit on the top line — a win condition
players cannot see is one they cannot play toward.
ui/match_summary.gd gives the ending somewhere to happen: who won, WHY (time and
frag limit are different stories about the same scoreline), team totals, full
standings, and a way out that is not alt-F4. Play Again is host-only on a server.
Three bugs found on the way, all of which only became bugs once matches could
actually end: loading a level reset the clock but not the scores, so the second
match on a server would have ended on its first kill; `.rpc()` on an offline
peer does not call locally, so singleplayer had no killfeed and no stat sync at
all; and a rocket already in the air at the whistle could change the result
after the summary was on screen.
spawn smoke 0, game modes 30/30, movement 11/11.
Co-Authored-By: Claude Opus 5 <[email protected]>
158 lines
5.9 KiB
GDScript
158 lines
5.9 KiB
GDScript
extends SceneTree
|
|
|
|
## Do the game modes end, and end on the RIGHT thing?
|
|
##
|
|
## godot --headless --path . -s res://debug/game_mode_check.gd
|
|
##
|
|
## `GameMode.check_win` is a pure function of the stats and the clock, which is
|
|
## the point of writing it that way: a win condition that can only be exercised
|
|
## by playing a whole match is a win condition nobody tests, and this project's
|
|
## previous one — a five-minute timer that set `match_active = false` and did
|
|
## nothing else — was never exercised at all.
|
|
##
|
|
## The cases below are the ones that actually go wrong: a draw reported as a win
|
|
## for whoever happens to be first in a dictionary, a team mode decided on one
|
|
## player's score instead of the team's, and a team kill counting toward the
|
|
## killer.
|
|
|
|
var _fails := 0
|
|
|
|
|
|
func _init() -> void:
|
|
# Autoloads are not up during `_init`; LoadoutManager is needed below.
|
|
await process_frame
|
|
_check_table()
|
|
_check_deathmatch()
|
|
_check_team()
|
|
_check_gun_game()
|
|
_check_standings()
|
|
_done()
|
|
|
|
|
|
## Every mode must be able to end. A mode with no score limit and no time limit
|
|
## runs forever, and that is the failure that produced this whole task.
|
|
func _check_table() -> void:
|
|
for id in GameMode.all_ids():
|
|
_expect(GameMode.is_valid(id),
|
|
"'%s' can end (score %d, time %.0fs)"
|
|
% [id, GameMode.score_limit(id), GameMode.time_limit(id)])
|
|
_expect(GameMode.display_name(id) != "",
|
|
"'%s' has a display name" % id)
|
|
_expect(GameMode.score_limit(GameMode.GUN_GAME) == GameMode.LADDER.size(),
|
|
"Gun Game's limit IS its ladder length (%d), so adding a rung cannot make the match unwinnable"
|
|
% GameMode.LADDER.size())
|
|
|
|
|
|
func _check_deathmatch() -> void:
|
|
var id := GameMode.DEATHMATCH
|
|
var limit := GameMode.score_limit(id)
|
|
|
|
var mid := {1: _p("A", limit - 1), 2: _p("B", 3)}
|
|
_expect(GameMode.check_win(id, mid, 120.0).is_empty(),
|
|
"deathmatch keeps running one short of the limit")
|
|
|
|
var done := {1: _p("A", limit), 2: _p("B", 3)}
|
|
var r := GameMode.check_win(id, done, 120.0)
|
|
_expect(r.get("reason", "") == "score" and r.get("name", "") == "A",
|
|
"deathmatch ends when someone reaches %d, and A won" % limit)
|
|
|
|
# On time, the leader takes it.
|
|
var timed := GameMode.check_win(id, {1: _p("A", 7), 2: _p("B", 4)}, 0.0)
|
|
_expect(timed.get("reason", "") == "time" and timed.get("name", "") == "A",
|
|
"deathmatch on time goes to the leader")
|
|
|
|
# A DRAW is a draw. Reporting a winner here means reporting whichever key
|
|
# the dictionary happened to hand over first, which is not a rule.
|
|
var drawn := GameMode.check_win(id, {1: _p("A", 5), 2: _p("B", 5)}, 0.0)
|
|
_expect(drawn.get("name", "") == "DRAW",
|
|
"a tie on time is a DRAW, not a win for whoever is first in the dictionary")
|
|
|
|
|
|
func _check_team() -> void:
|
|
var id := GameMode.TEAM_DEATHMATCH
|
|
var limit := GameMode.score_limit(id)
|
|
|
|
# Two on team 1 with half the limit each: the TEAM has won, though neither
|
|
# player is close on their own. Deciding this on individual score — which is
|
|
# what a per-player check would do — never ends a team match.
|
|
var half := int(limit / 2.0)
|
|
var stats := {
|
|
1: _p("A", half, 1), 2: _p("B", half, 1),
|
|
3: _p("C", 4, 2), 4: _p("D", 4, 2),
|
|
}
|
|
var r := GameMode.check_win(id, stats, 300.0)
|
|
_expect(r.get("reason", "") == "score" and r.get("team", 0) == 1,
|
|
"team deathmatch is decided by the TEAM's total, not one player's")
|
|
|
|
var totals := GameMode.team_scores(id, stats)
|
|
_expect(totals.get(1, 0) == half * 2 and totals.get(2, 0) == 8,
|
|
"team totals add up (%d / %d)" % [totals.get(1, 0), totals.get(2, 0)])
|
|
|
|
# Balance on join, by count and not by turn — a 4v4 that loses three from one
|
|
# side must refill the short side, which round-robin on join order does not.
|
|
var lopsided := {1: _p("A", 0, 1), 2: _p("B", 0, 1), 3: _p("C", 0, 1)}
|
|
_expect(GameMode.assign_team(id, lopsided) == 2,
|
|
"a joining player goes to the SHORT team")
|
|
|
|
var tied := GameMode.check_win(id, {1: _p("A", 5, 1), 2: _p("B", 5, 2)}, 0.0)
|
|
_expect(tied.get("name", "") == "DRAW", "level teams on time is a DRAW")
|
|
|
|
|
|
func _check_gun_game() -> void:
|
|
var id := GameMode.GUN_GAME
|
|
var top := GameMode.LADDER.size()
|
|
|
|
_expect(GameMode.check_win(id, {1: _p("A", top - 1)}, 300.0).is_empty(),
|
|
"gun game keeps running on the last rung")
|
|
var r := GameMode.check_win(id, {1: _p("A", top)}, 300.0)
|
|
_expect(r.get("reason", "") == "score",
|
|
"gun game ends when someone finishes the ladder")
|
|
|
|
# Every rung must name a weapon that exists, or a promotion hands the player
|
|
# nothing and the mode softlocks on that rung.
|
|
#
|
|
# Reached through the tree rather than by the autoload's global identifier: a
|
|
# `-s` tool script compiles its dependencies BEFORE autoloads register, so
|
|
# naming `LoadoutManager` directly stops this file from loading at all.
|
|
var loadouts = root.get_node_or_null("LoadoutManager")
|
|
for i in GameMode.LADDER.size():
|
|
var w := GameMode.ladder_weapon(i)
|
|
_expect(loadouts != null and loadouts.weapon_db.has(w),
|
|
"ladder rung %d ('%s') is a real weapon" % [i, w])
|
|
|
|
# Past the end clamps rather than going out of bounds — the winning kill
|
|
# promotes past the top rung before the match-end RPC lands.
|
|
_expect(GameMode.ladder_weapon(999) == GameMode.LADDER[-1],
|
|
"promoting past the top rung clamps instead of erroring")
|
|
|
|
|
|
func _check_standings() -> void:
|
|
var stats := {
|
|
1: _p("A", 5), 2: _p("B", 9), 3: _p("C", 5),
|
|
}
|
|
stats[1]["deaths"] = 2
|
|
stats[3]["deaths"] = 7
|
|
var order := GameMode.standings(stats)
|
|
_expect(order[0] == 2, "standings put the highest score first")
|
|
_expect(order[1] == 1 and order[2] == 3,
|
|
"equal scores break on FEWEST DEATHS (kills are equal by definition there)")
|
|
|
|
|
|
func _p(who: String, score: int, team: int = 0) -> Dictionary:
|
|
return {"username": who, "score": score, "kills": score, "deaths": 0,
|
|
"assists": 0, "team": team, "best_streak": 0, "rung": score}
|
|
|
|
|
|
func _expect(ok: bool, what: String) -> void:
|
|
if ok:
|
|
print(" OK: ", what)
|
|
else:
|
|
print(" FAIL: ", what)
|
|
_fails += 1
|
|
|
|
|
|
func _done() -> void:
|
|
print("\n=== GAME MODE SUMMARY ===")
|
|
print("Failures: %d" % _fails)
|
|
quit(1 if _fails > 0 else 0)
|