merge: add fenced turn runner and differential harness

This commit is contained in:
2026-07-25 12:14:38 +00:00
26 changed files with 1764 additions and 101 deletions
+10
View File
@@ -77,6 +77,16 @@ model InputEvent {
@@map("input_event")
}
model TurnDaemonLease {
profile String @id
ownerId String @map("owner_id")
leaseUntil DateTime @map("lease_until")
fencingEpoch BigInt @default(1) @map("fencing_epoch")
heartbeatAt DateTime @default(now()) @map("heartbeat_at")
@@map("turn_daemon_lease")
}
model WorldState {
id Int @id @default(autoincrement())
scenarioCode String @map("scenario_code")
@@ -0,0 +1,9 @@
CREATE TABLE "turn_daemon_lease" (
"profile" TEXT NOT NULL,
"owner_id" TEXT NOT NULL,
"lease_until" TIMESTAMP(3) NOT NULL,
"fencing_epoch" BIGINT NOT NULL DEFAULT 1,
"heartbeat_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "turn_daemon_lease_pkey" PRIMARY KEY ("profile")
);
+1
View File
@@ -31,4 +31,5 @@ export interface DatabaseClient {
inheritanceResult: GamePrisma.InheritanceResultDelegate;
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
inputEvent: GamePrisma.InputEventDelegate;
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
}
+9 -3
View File
@@ -105,16 +105,22 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
nation.meta.tech = round(tech);
};
const resolveConquerNation = (city: City, attackerNationId: number): number => {
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
const rawConflict = city.meta[META_CONFLICT];
if (!rawConflict) {
return attackerNationId;
}
try {
const parsed = JSON.parse(String(rawConflict)) as Record<string, number>;
const activeNationIds = new Set(nations.map((nation) => nation.id));
const entries = Object.entries(parsed)
.map(([key, value]) => [Number(key), value] as const)
.filter(([key, value]) => Number.isFinite(key) && typeof value === 'number')
.filter(
([key, value]) =>
Number.isFinite(key) &&
typeof value === 'number' &&
(key === attackerNationId || activeNationIds.has(key))
)
.sort(([, lhs], [, rhs]) => rhs - lhs);
if (!entries.length) {
return attackerNationId;
@@ -183,7 +189,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
const affectedGenerals = new Set<General<TriggerState>>();
const affectedNations = new Set<Nation>();
const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id);
const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id, input.nations);
const attackerLogger = new ActionLogger({
generalId: attacker.id,
nationId: attackerNation.id,
+3 -1
View File
@@ -183,7 +183,7 @@ describe('war aftermath', () => {
defenderNation.rice = 6000;
const attackerCity = buildCity(1, 1);
const defenderCity = buildCity(2, 2);
defenderCity.meta.conflict = JSON.stringify({ 1: 100 });
defenderCity.meta.conflict = JSON.stringify({ 99: 200, 1: 100 });
const attacker = buildGeneral(1, 1, 1);
const defender = buildGeneral(2, 2, 2);
@@ -235,6 +235,8 @@ describe('war aftermath', () => {
expect(attackerNation.rice).toBe(4600);
expect(defender.experience).toBe(90);
expect(defender.dedication).toBe(50);
// Removed nations can remain in old persisted conflict metadata. They
// must never receive ownership during a later conquest.
expect(defenderCity.nationId).toBe(attackerNation.id);
expect(defenderCity.meta.conflict).toBe('{}');
});