Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions packages/shared/src/ClanApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,137 @@ export const ClanDonationLeaderboardResponseSchema = z.object({
export type ClanDonationLeaderboardResponse = z.infer<
typeof ClanDonationLeaderboardResponseSchema
>;

// ---- Clan map activity (GET /clans/:tag/map-activity and /members): what
// the clan's members did on the clan territory map, and what it lost. Members
// only. The map is served by the API; this is the clan page's log of it.

// A player on the map: public ID and display username, both null when the
// account has since been deleted.
export const MapActivityPlayerSchema = z.object({
id: z.string().nullable(),
username: z.string().nullable(),
});
export type MapActivityPlayer = z.infer<typeof MapActivityPlayerSchema>;

// Another clan on the map; tag null when it has since been deleted. color is
// 0xRRGGBB, null when unknown.
export const MapActivityClanSchema = z.object({
tag: z.string().nullable(),
color: z.number().nullable(),
});
export type MapActivityClan = z.infer<typeof MapActivityClanSchema>;

const ShipSailingSchema = z.object({
kind: z.literal("ship_sailing"),
at: z.iso.datetime(),
player: MapActivityPlayerSchema,
troops: z.number(),
toX: z.number(),
toY: z.number(),
arrivesAt: z.iso.datetime(),
});

export const MapActivityEventSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("claimed"),
at: z.iso.datetime(),
player: MapActivityPlayerSchema,
pixels: z.number(),
neutral: z.number(),
takenFrom: z
.object({ clan: MapActivityClanSchema, pixels: z.number() })
.array(),
x: z.number(),
y: z.number(),
}),
z.object({
kind: z.literal("unclaimed"),
at: z.iso.datetime(),
player: MapActivityPlayerSchema,
pixels: z.number(),
refundTroops: z.number(),
x: z.number(),
y: z.number(),
}),
z.object({
kind: z.literal("bought_troops"),
at: z.iso.datetime(),
player: MapActivityPlayerSchema,
troops: z.number(),
// Caps spent, a bigint as a decimal string.
caps: z.string(),
}),
ShipSailingSchema,
z.object({
kind: z.literal("ship_landed"),
at: z.iso.datetime(),
player: MapActivityPlayerSchema,
troops: z.number(),
toX: z.number(),
toY: z.number(),
outcome: z.enum(["captured", "repelled", "returned"]),
}),
z.object({
kind: z.literal("lost"),
at: z.iso.datetime(),
clan: MapActivityClanSchema,
// The attacking player.
player: MapActivityPlayerSchema,
pixels: z.number(),
x: z.number(),
y: z.number(),
}),
z.object({
kind: z.literal("siege"),
at: z.iso.datetime(),
// The annexing clan.
clan: MapActivityClanSchema,
pixels: z.number(),
x: z.number(),
y: z.number(),
}),
]);
export type MapActivityEvent = z.infer<typeof MapActivityEventSchema>;

export const MapActivityFilterSchema = z.enum(["all", "ours", "lost"]);
export type MapActivityFilter = z.infer<typeof MapActivityFilterSchema>;

export const MapActivityPageSchema = z.object({
// The last 24 hours; the same on every page.
summary: z.object({
gained: z.number(),
lost: z.number(),
shipsAtSea: z.number(),
nextLandingAt: z.iso.datetime().nullable(),
}),
// Ships at sea now; only on the first page.
atSea: ShipSailingSchema.array(),
// Newest first.
events: MapActivityEventSchema.array(),
// Pass as `before` for the next page; null when there is none.
nextBefore: z.iso.datetime().nullable(),
});
export type MapActivityPage = z.infer<typeof MapActivityPageSchema>;

export const MapActivityPeriodSchema = z.enum(["24h", "7d", "30d"]);
export type MapActivityPeriod = z.infer<typeof MapActivityPeriodSchema>;

export const MapActivityMemberSchema = z.object({
player: MapActivityPlayerSchema,
// null when the player has since left the clan.
role: z.enum(["leader", "officer", "member"]).nullable(),
claimed: z.number(),
// Caps spent on troops, a bigint as a decimal string.
caps: z.string(),
shipsSent: z.number(),
shipsLanded: z.number(),
lastActiveAt: z.iso.datetime().nullable(),
});
export type MapActivityMember = z.infer<typeof MapActivityMemberSchema>;

export const MapActivityMembersSchema = z.object({
period: MapActivityPeriodSchema,
members: MapActivityMemberSchema.array(),
});
export type MapActivityMembers = z.infer<typeof MapActivityMembersSchema>;
91 changes: 77 additions & 14 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@
"howto_1": "Clans claim land pixel by pixel, next to land they hold. Leaders and officers do the claiming.",
"howto_2": "Claiming costs troops: 1,000 a pixel of free land, 2,000 plus the defender's troops per pixel for enemy land.",
"howto_3": "Troops are bought with caps, 1 cap for 100. Every pixel earns 1 cap a day.",
"howto_4": "A clan's troops spread over its land are its defence. Ships carry troops across water.",
"howto_4": "A clan's troops spread over its land are its defence. Boats carry troops across water.",
"howto_5": "Land walled in by another clan, with no way out to the sea, is annexed a ring at a time (hatched, outlined in yellow).",
"howto_dismiss": "Dismiss",
"howto_title": "How it works",
Expand Down Expand Up @@ -436,8 +436,8 @@
"mode_pan_title": "Drag moves the map; click selects one pixel",
"mode_select": "Select",
"mode_select_title": "Drag selects pixels (Shift+drag works in any mode)",
"mode_ship": "Ship",
"mode_ship_title": "Click land across the water to send a transport ship",
"mode_ship": "Boat",
"mode_ship_title": "Click land across the water to send a transport boat",
"msg_bought": "Bought {troops} troops for {caps} caps.",
"msg_buy_failed": "Couldn't buy troops.",
"msg_buy_forbidden": "You can't buy troops for this clan any more.",
Expand All @@ -451,9 +451,9 @@
"msg_nothing_unclaimed": "Nothing unclaimed — those pixels aren't yours any more.",
"msg_reason_count": "{n} {reason}",
"msg_selection_full": "Selection is full — {max, plural, one {# pixel} other {# pixels}} per claim.",
"msg_ship_failed": "Couldn't launch the ship.",
"msg_ship_forbidden": "You can't launch ships for this clan any more.",
"msg_ship_launched": "Ship launched with {troops} troops — lands in ~{min} min.",
"msg_ship_failed": "Couldn't launch the boat.",
"msg_ship_forbidden": "You can't launch boats for this clan any more.",
"msg_ship_launched": "Boat launched with {troops} troops — lands in ~{min} min.",
"msg_unclaimed": "Unclaimed {n} px · +{troops} troops{missed}.",
"msg_unclaimed_missed": " · {n} no longer yours",
"page_title": "Clan Territory Map — OpenFront",
Expand All @@ -469,7 +469,7 @@
"panel_funds_failed": "Couldn't load troops.",
"panel_funds_loading": "Loading troops…",
"panel_history_paused": "Paused while you look back.",
"panel_maintenance": "Claiming land, ships and buying troops are paused during maintenance.",
"panel_maintenance": "Claiming land, boats and buying troops are paused during maintenance.",
"panel_no_clan": "Join a clan to claim land.",
"panel_officers_only": "Only {tag} leaders and officers can claim land.",
"panel_retry": "Retry",
Expand All @@ -488,19 +488,19 @@
"role_leader": "Leader",
"role_member": "Member",
"role_officer": "Officer",
"ship_aim": "Click land to aim a ship",
"ship_aim": "Click land to aim a boat",
"ship_cost": "Captures it on landing for ~{troops} troops. Short of that, it's repelled and the troops are lost; anything it doesn't spend rejoins your pool.",
"ship_full": "{max, plural, one {# ship} other {# ships}} at sea — wait for one to land",
"ship_hint": "Click land across the water: a ship captures it when it lands. It sails from your clan's nearest coast, however far that is. {count} ships at sea.",
"ship_full": "{max, plural, one {# boat} other {# boats}} at sea — wait for one to land",
"ship_hint": "Click land across the water: a boat captures it when it lands. It sails from your clan's nearest coast, however far that is. {count} boats at sea.",
"ship_landing": "Landing at {xy}",
"ship_launch": "Launch ship ({n} / {max} at sea)",
"ship_launch": "Launch boat ({n} / {max} at sea)",
"ship_launching": "Launching…",
"ship_reason_already_owned": "Your clan already owns that pixel.",
"ship_reason_insufficient_troops": "Not enough troops — buy more with your clan's caps.",
"ship_reason_no_sea_route": "No sea route from your clan's coast to there.",
"ship_reason_not_land": "Ships land on land — pick a coastal pixel.",
"ship_reason_not_land": "Boats land on land — pick a coastal pixel.",
"ship_reason_not_owned": "That coast isn't your clan's any more — aim again.",
"ship_reason_too_many_ships": "Your clan already has {max, plural, one {# ship} other {# ships}} at sea.",
"ship_reason_too_many_ships": "Your clan already has {max, plural, one {# boat} other {# boats}} at sea.",
"ship_sails": "Sails {px} px from {from} · ~{min} min",
"ship_troops_aboard": "Troops aboard",
"side_hide": "Hide panel",
Expand All @@ -524,7 +524,7 @@
"tip_claim_cost": "Claim it for {cost} troops",
"tip_defence": "Defence {defence}/px · take it for ~{cost} troops",
"tip_locked": "Locked for {time}",
"tip_ship": "{tag} ship",
"tip_ship": "{tag} boat",
"tip_ship_in": "in ~{min} min",
"tip_ship_info": " · {troops} troops · lands at {xy} {when}",
"tip_ship_now": "now",
Expand Down Expand Up @@ -678,6 +678,68 @@
"leave_clan": "Leave Clan",
"left_clan": "You left the clan.",
"manage_clan": "Manage",
"map_activity_active_date": "Active {date}",
"map_activity_active_today": "Active today at {time}",
"map_activity_at_sea": "At sea",
"map_activity_bought": "{name} bought {troops, plural, one {# troop} other {# troops}}",
"map_activity_by": "by {name}",
"map_activity_by_member": "By member",
"map_activity_claimed": "{name} claimed {count, plural, one {# pixel} other {# pixels}}",
"map_activity_deleted_clan": "Deleted clan",
"map_activity_deleted_player": "Deleted player",
"map_activity_empty": "No map activity yet.",
"map_activity_empty_lost": "No land lost yet.",
"map_activity_empty_ours": "No claims, troop purchases or boats yet.",
"map_activity_filter_all": "All",
"map_activity_filter_lost": "Lost",
"map_activity_filter_ours": "Ours",
"map_activity_gained_24h": "Gained · 24h",
"map_activity_left": "Left",
"map_activity_load_more": "Load more",
"map_activity_load_more_failed": "Couldn't load more map activity.",
"map_activity_lost": "{clan} took {count, plural, one {# pixel} other {# pixels}}",
"map_activity_lost_24h": "Lost · 24h",
"map_activity_members_empty": "No one has been active on the map in this period.",
"map_activity_members_only": "Only members of this clan can see its map activity.",
"map_activity_neutral": "{count, number} unclaimed",
"map_activity_none_24h": "No map activity in the last 24 hours",
"map_activity_none_30d": "No map activity in the last 30 days",
"map_activity_none_7d": "No map activity in the last 7 days",
"map_activity_open_map": "Open map",
"map_activity_outcome_captured": "Captured",
"map_activity_outcome_repelled": "Repelled",
"map_activity_outcome_returned": "Returned",
"map_activity_period_24h": "Last 24 hours",
"map_activity_period_30d": "Last 30 days",
"map_activity_period_7d": "Last 7 days",
"map_activity_period_label": "Period",
"map_activity_px": "{count, number} px",
"map_activity_px_gain": "+{count, number} px",
"map_activity_px_loss": "−{count, number} px",
"map_activity_recent": "Recent",
"map_activity_refunded": "refunded {troops, plural, one {# troop} other {# troops}}",
"map_activity_ship_landed": "{name} landed a boat at {x}, {y}",
"map_activity_ship_lands": "Lands at {time}",
"map_activity_ship_lands_date": "Lands {date} at {time}",
"map_activity_ship_sent": "{name} sent a boat to {x}, {y}",
"map_activity_ships_at_sea": "Boats at sea",
"map_activity_siege": "{clan} annexed {count, plural, one {# pixel} other {# pixels}} in a siege",
"map_activity_signed_out": "Sign in to see your clan's map activity.",
"map_activity_sort_active": "Last active",
"map_activity_sort_by": "Sort by",
"map_activity_sort_caps": "Caps on troops",
"map_activity_sort_claimed": "Claimed",
"map_activity_sort_label": "Sort members",
"map_activity_sort_ships": "Boats",
"map_activity_stat_caps": "Bought troops for {caps, plural, one {# cap} other {# caps}}",
"map_activity_stat_claimed": "Claimed {count, plural, one {# pixel} other {# pixels}}",
"map_activity_stat_ships": "{landed, number} of {sent, plural, one {# boat} other {# boats}} landed",
"map_activity_taken_from": "{count, number} from {tag}",
"map_activity_troops_aboard": "{troops, plural, one {# troop} other {# troops}} aboard",
"map_activity_unavailable": "Map activity is unavailable right now.",
"map_activity_unclaimed": "{name} gave up {count, plural, one {# pixel} other {# pixels}}",
"map_activity_view": "View",
"map_activity_view_at": "View {x}, {y} on the map",
"map_back": "Back to clans",
"map_frame_title": "Clan territory map",
"map_loading": "Loading clan map…",
Expand Down Expand Up @@ -756,6 +818,7 @@
"status": "Status",
"tab_donations": "Donations",
"tab_game_history": "Game History",
"tab_map_activity": "Map activity",
"tab_map_alpha": "Map (ALPHA)",
"tab_overview": "Overview",
"title": "Clans",
Expand Down
76 changes: 76 additions & 0 deletions src/client/ClanApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ import {
ClanRequestsResponseSchema,
DiscordInviteResponseSchema,
JoinClanResponseSchema,
type MapActivityFilter,
type MapActivityMembers,
MapActivityMembersSchema,
type MapActivityPage,
MapActivityPageSchema,
type MapActivityPeriod,
} from "@openfront/shared/ClanApiSchemas";
import { getApiBase, getUserMe } from "./Api";
import { getAuthHeader } from "./Auth";
Expand Down Expand Up @@ -58,6 +64,14 @@ export type {
ClanMemberStats,
ClanMemberWL,
ClanRequestsResponse,
MapActivityClan,
MapActivityEvent,
MapActivityFilter,
MapActivityMember,
MapActivityMembers,
MapActivityPage,
MapActivityPeriod,
MapActivityPlayer,
} from "@openfront/shared/ClanApiSchemas";

async function clanFetch(
Expand Down Expand Up @@ -898,3 +912,65 @@ export async function buyClanBoost(
return { error: "clan_modal.error_network" };
}
}

export type ClanMapActivityFetchError = "signed_out" | "forbidden" | "failed";

/**
* Members-only log of the clan's map activity, newest first: claims,
* unclaims, troop purchases, ships and land lost. `before` is the previous
* page's `nextBefore`; omitted for the first page (the only one with the
* ships at sea).
*/
export async function fetchClanMapActivity(
tag: string,
opts: { before?: string | null; filter?: MapActivityFilter } = {},
): Promise<MapActivityPage | { error: ClanMapActivityFetchError }> {
try {
const params = new URLSearchParams();
params.set("filter", opts.filter ?? "all");
if (opts.before) params.set("before", opts.before);
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/map-activity?${params}`,
);
if (res.status === 401) return { error: "signed_out" };
if (res.status === 403) return { error: "forbidden" };
if (!res.ok) return { error: "failed" };
const parsed = MapActivityPageSchema.safeParse(await res.json());
if (!parsed.success) {
console.warn("fetchClanMapActivity: Zod validation failed", parsed.error);
return { error: "failed" };
}
return parsed.data;
} catch {
return { error: "failed" };
}
}

/**
* Members-only per-member totals of map activity over the last 24 hours, 7
* or 30 days, plus the clan's leaders and officers with none.
*/
export async function fetchClanMapActivityMembers(
tag: string,
period: MapActivityPeriod = "7d",
): Promise<MapActivityMembers | { error: ClanMapActivityFetchError }> {
try {
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/map-activity/members?period=${period}`,
);
if (res.status === 401) return { error: "signed_out" };
if (res.status === 403) return { error: "forbidden" };
if (!res.ok) return { error: "failed" };
const parsed = MapActivityMembersSchema.safeParse(await res.json());
if (!parsed.success) {
console.warn(
"fetchClanMapActivityMembers: Zod validation failed",
parsed.error,
);
return { error: "failed" };
}
return parsed.data;
} catch {
return { error: "failed" };
}
}
Loading
Loading